Exact source used by this build. SHA-256: d0a55bf532fb6a2ec6b0c417ff0597276ed381003bd68cc11de47e5404511838.

1 import { z } from "zod";
2 import type { Player, SimRules, WorldState, IntentResult } from "../../types";
3 import { withPlayer, inToolRange, target, withTile } from "../../state";
4 import { defineIntent, defineSystem } from "../registry";
5 import { TilePositionSchema } from "../schema";
6 import { canonicalItemId, getItem, QualitySchema, sellPrice, type Quality } from "../items";
7 import { ToolIdSchema, toolAvailable, trashRefundRate } from "../tools";
8 
9 export interface ItemStack { id: string; quality: Quality; quantity: number }
10 export interface InventoryState { slots: (ItemStack | null)[]; capacity: 12 | 24 | 36; selectedSlot: number; magnetRadius: number; initialized?: boolean; overflow?: ItemStack[] }
11 export interface GroundDrop { id: string; areaId: string; x: number; y: number; stack: ItemStack; pickupAfterMinute?: number }
12 export interface Chest { id: string; areaId: string; x: number; y: number; slots: (ItemStack | null)[]; itemId?: string }
13 export interface InventoryWorld { drops: GroundDrop[]; chests: Chest[]; nextObjectId: number }
14 export const ItemStackSchema = z.object({ id: z.string().min(1), quality: QualitySchema, quantity: z.number().int().positive() });
15 const blank = (): InventoryState => ({ slots: Array<ItemStack | null>(12).fill(null), capacity: 12, selectedSlot: 0, magnetRadius: 2, initialized: false, overflow: [] });
16 const same = (a: ItemStack, b: ItemStack) => a.id === b.id && a.quality === b.quality;
17 /** Never loses legacy items: slots are canonical after initialization, excess is retained for migration. */
18 export function getInventory(player: Player, rules: SimRules): InventoryState {
19   const saved = player.systems.inventory as InventoryState | undefined;
20   if (saved?.initialized) return saved;
21   let inventory = saved ? { ...blank(), ...saved, slots: saved.slots.slice() } : blank();
22   const overflow: ItemStack[] = [];
23   for (const [legacyId, quantity] of Object.entries(player.inventory)) {
24     const id = canonicalItemId(rules, legacyId);
25     const stack: ItemStack = { id, quantity, quality: "regular" };
26     const inserted = insertSlots(inventory.slots, stack, getItem(rules, id)?.stackMax ?? 999);
27     inventory = { ...inventory, slots: inserted.slots };
28     if (inserted.remaining > 0) overflow.push({ ...stack, quantity: inserted.remaining });
29   }
30   return { ...inventory, initialized: true, overflow };
31 }
32 export function setInventory(player: Player, inventory: InventoryState): Player {
33   const totals: Record<string, number> = {};
34   for (const stack of [...inventory.slots, ...(inventory.overflow ?? [])]) if (stack) totals[stack.id] = (totals[stack.id] ?? 0) + stack.quantity;
35   return { ...player, inventory: totals, systems: { ...player.systems, inventory: { ...inventory, initialized: true } } };
36 }
37 export function insertSlots(slots: readonly (ItemStack | null)[], stack: ItemStack, max: number): { slots: (ItemStack | null)[]; remaining: number } {
38   const result = slots.slice(); let remaining = stack.quantity;
39   for (let i = 0; i < result.length && remaining > 0; i++) {
40     const current = result[i];
41     if (current && same(current, stack) && current.quantity < max) { const count = Math.min(remaining, max - current.quantity); result[i] = { ...current, quantity: current.quantity + count }; remaining -= count; }
42   }
43   for (let i = 0; i < result.length && remaining > 0; i++) if (!result[i]) { const count = Math.min(max, remaining); result[i] = { ...stack, quantity: count }; remaining -= count; }
44   return { slots: result, remaining };
45 }
46 export function addItem(player: Player, stack: ItemStack, rules: SimRules): { player: Player; remaining: number } {
47   const item = getItem(rules, stack.id);
48   if (!item || !Number.isSafeInteger(stack.quantity) || stack.quantity <= 0 || (!item.qualityAllowed && stack.quality !== "regular")) return { player, remaining: stack.quantity };
49   const inventory = getInventory(player, rules), inserted = insertSlots(inventory.slots, stack, item.stackMax);
50   return { player: setInventory(player, { ...inventory, slots: inserted.slots }), remaining: inserted.remaining };
51 }
52 export function countItem(player: Player, id: string, rules: SimRules): number { return getInventory(player, rules).slots.reduce((sum, stack) => sum + (stack?.id === id ? stack.quantity : 0), 0); }
53 export function takeSlot(player: Player, slot: number, quantity: number, rules: SimRules): { player: Player; stack: ItemStack } | null {
54   const inventory = getInventory(player, rules), stack = inventory.slots[slot];
55   if (!Number.isSafeInteger(quantity) || quantity <= 0 || !stack || stack.quantity < quantity) return null;
56   const slots = inventory.slots.slice(); slots[slot] = stack.quantity === quantity ? null : { ...stack, quantity: stack.quantity - quantity };
57   return { player: setInventory(player, { ...inventory, slots }), stack: { ...stack, quantity } };
58 }
59 export function removeItem(player: Player, id: string, quantity: number, rules: SimRules, quality?: Quality): Player | null {
60   const inventory = getInventory(player, rules);
61   if (!Number.isSafeInteger(quantity) || quantity <= 0) return null;
62   if (inventory.slots.reduce((sum, stack) => sum + (stack?.id === id && (!quality || quality === stack.quality) ? stack.quantity : 0), 0) < quantity) return null;
63   const slots = inventory.slots.map((stack) => {
64     if (!stack || stack.id !== id || (quality && stack.quality !== quality) || quantity === 0) return stack;
65     const count = Math.min(quantity, stack.quantity); quantity -= count;
66     return count === stack.quantity ? null : { ...stack, quantity: stack.quantity - count };
67   });
68   return setInventory(player, { ...inventory, slots });
69 }
70 export function getInventoryWorld(state: WorldState): InventoryWorld { return (state.systems.inventory as InventoryWorld | undefined) ?? { drops: [], chests: [], nextObjectId: 1 }; }
71 /** Retire storage belonging to an ephemeral area after its final occupant leaves. */
72 export function removeAreaItems(state: WorldState, areaId: string): WorldState {
73   const inventory = getInventoryWorld(state);
74   const drops = inventory.drops.filter((drop) => drop.areaId !== areaId);
75   const chests = inventory.chests.filter((chest) => chest.areaId !== areaId);
76   return drops.length === inventory.drops.length && chests.length === inventory.chests.length ? state : withInventoryWorld(state, { ...inventory, drops, chests });
77 }
78 function withInventoryWorld(state: WorldState, value: InventoryWorld): WorldState { return { ...state, revision: state.revision + 1, systems: { ...state.systems, inventory: value } }; }
79 /** Inventory remains the owner of shared storage, including feature-created chests. */
80 export function addSharedChest(state: WorldState, position: { areaId: string; x: number; y: number }, itemId?: string): { state: WorldState; chest: Chest } {
81   const world = getInventoryWorld(state);
82   const chest: Chest = { id: `chest_${world.nextObjectId}`, ...position, slots: Array<ItemStack | null>(36).fill(null), ...(itemId ? { itemId } : {}) };
83   return { chest, state: withInventoryWorld(state, { ...world, nextObjectId: world.nextObjectId + 1, chests: [...world.chests, chest] }) };
84 }
85 /** Trusted simulation helper; intent handlers validate range and atomic ingredient use. */
86 export function replaceChestSlots(state: WorldState, chestId: string, slots: readonly (ItemStack | null)[]): WorldState {
87   const world = getInventoryWorld(state), chest = world.chests.find((entry) => entry.id === chestId);
88   if (!chest || slots.length !== chest.slots.length) return state;
89   return withInventoryWorld(state, { ...world, chests: world.chests.map((entry) => entry.id === chestId ? { ...entry, slots: slots.map((stack) => stack ? { ...stack } : null) } : entry) });
90 }
91 export function removeSharedChest(state: WorldState, chestId: string): WorldState | null {
92   const world = getInventoryWorld(state), chest = world.chests.find((entry) => entry.id === chestId);
93   if (!chest || chest.slots.some(Boolean)) return null;
94   return withInventoryWorld(state, { ...world, chests: world.chests.filter((entry) => entry.id !== chestId) });
95 }
96 export function addGroundDrop(state: WorldState, areaId: string, x: number, y: number, stack: ItemStack, pickupAfterMinute?: number): WorldState {
97   const world = getInventoryWorld(state), id = `drop_${world.nextObjectId}`;
98   return withInventoryWorld(state, { ...world, nextObjectId: world.nextObjectId + 1, drops: [...world.drops, { id, areaId, x, y, stack: { ...stack }, ...(pickupAfterMinute === undefined ? {} : { pickupAfterMinute }) }] });
99 }
100 function result(state: WorldState, player: Player, type = "inventoryChanged"): IntentResult { return { state: withPlayer(state, player), events: [{ type, playerId: player.id, areaId: player.areaId }] }; }
101 function error(state: WorldState, message: string): IntentResult { return { state, events: [], error: message }; }
102 function moveSlots(slots: readonly (ItemStack | null)[], from: number, to: number, quantity: number | undefined, rules: SimRules, mode: "move" | "split" | "merge"): (ItemStack | null)[] | null {
103   const source = slots[from], destination = slots[to];
104   if (from === to || from < 0 || to < 0 || from >= slots.length || to >= slots.length || !source) return null;
105   const count = quantity ?? source.quantity;
106   if (count <= 0 || count > source.quantity || (mode === "split" && (destination || count === source.quantity)) || (mode === "merge" && (!destination || !same(source, destination)))) return null;
107   const next = slots.slice();
108   if (destination && !same(source, destination)) {
109     if (count !== source.quantity || mode !== "move") return null;
110     next[from] = destination; next[to] = source; return next;
111   }
112   const moved = Math.min(count, (getItem(rules, source.id)?.stackMax ?? 999) - (destination?.quantity ?? 0));
113   if (moved <= 0) return null;
114   next[to] = { ...source, quantity: moved + (destination?.quantity ?? 0) };
115   next[from] = moved === source.quantity ? null : { ...source, quantity: source.quantity - moved };
116   return next;
117 }
118 const slot = z.number().int().min(0).max(35), amount = z.number().int().positive().max(999);
119 const move = <T extends "inventoryMove" | "inventorySplit" | "inventoryMerge">(type: T, mode: "move" | "split" | "merge") => defineIntent(type,
120   z.object({ type: z.literal(type), from: slot, to: slot, quantity: amount.optional() }), ({ state, player, rules }, intent) => {
121     if (mode === "split" && intent.quantity === undefined) return error(state, "Choose a split quantity");
122     const inventory = getInventory(player, rules), slots = moveSlots(inventory.slots, intent.from, intent.to, intent.quantity, rules, mode);
123     return slots ? result(state, setInventory(player, { ...inventory, slots })) : error(state, "Items cannot be moved there");
124   });
125 export function pickupNearby(state: WorldState, playerId: string, rules: SimRules, radiusBonus = 0): IntentResult {
126   let player = state.players[playerId]; if (!player) return error(state, "Unknown player");
127   const world = getInventoryWorld(state), drops: GroundDrop[] = []; let changed = false;
128   for (const drop of world.drops) {
129     if ((drop.pickupAfterMinute ?? 0) > state.clock.machineMinutes || drop.areaId !== player.areaId || Math.hypot(drop.x - player.x, drop.y - player.y) > getInventory(player, rules).magnetRadius + Math.max(0, radiusBonus)) { drops.push(drop); continue; }
130     const added = addItem(player, drop.stack, rules); player = added.player;
131     if (added.remaining !== drop.stack.quantity) changed = true;
132     if (added.remaining) drops.push({ ...drop, stack: { ...drop.stack, quantity: added.remaining } });
133   }
134   return changed ? result(withInventoryWorld(state, { ...world, drops }), player, "itemsPickedUp") : { state, events: [] };
135 }
136 function hydrateInventory(state: WorldState, playerId: string, rules: SimRules): WorldState {
137   const player = state.players[playerId]; if (!player) return state;
138   const saved = player.systems.inventory as InventoryState | undefined;
139   if (saved?.initialized && !saved.overflow?.length) return state;
140   let inventory = getInventory(player, rules);
141   const overflow: ItemStack[] = [];
142   for (const stack of inventory.overflow ?? []) {
143     const inserted = insertSlots(inventory.slots, stack, getItem(rules, stack.id)?.stackMax ?? 999);
144     inventory = { ...inventory, slots: inserted.slots };
145     if (inserted.remaining) overflow.push({ ...stack, quantity: inserted.remaining });
146   }
147   if (saved?.initialized && JSON.stringify(overflow) === JSON.stringify(saved.overflow)) return state;
148   return withPlayer(state, setInventory(player, { ...inventory, overflow }));
149 }
150 export const inventorySystem = defineSystem({
151   id: "inventory", worldDefaults: () => ({ drops: [], chests: [], nextObjectId: 1 }), playerDefaults: () => ({ ...blank() }),
152   events: ["inventoryChanged", "itemsPickedUp", "chestPlaced"],
153   intents: [
154     move("inventoryMove", "move"), move("inventorySplit", "split"), move("inventoryMerge", "merge"),
155     defineIntent("inventorySelect", z.object({ type: z.literal("inventorySelect"), slot: z.number().int().min(0).max(11) }), ({ state, player, rules }, intent) => result(state, setInventory(player, { ...getInventory(player, rules), selectedSlot: intent.slot }))),
156     defineIntent("inventoryDrop", z.object({ type: z.literal("inventoryDrop"), slot, quantity: amount }), ({ state, player, rules }, intent) => {
157       const taken = takeSlot(player, intent.slot, intent.quantity, rules); if (!taken) return error(state, "Invalid inventory slot or amount");
158       if (getItem(rules, taken.stack.id)?.category === "tool") return error(state, "Tools cannot be dropped");
159       return result(addGroundDrop(state, player.areaId, player.x, player.y, taken.stack, state.clock.machineMinutes + 10), taken.player);
160     }),
161     defineIntent("inventoryTrash", z.object({ type: z.literal("inventoryTrash"), slot, quantity: amount }), ({ state, player, rules }, intent) => {
162       const taken = takeSlot(player, intent.slot, intent.quantity, rules), item = taken && getItem(rules, taken.stack.id);
163       if (!taken || !item) return error(state, "Invalid inventory slot or amount");
164       if (item.category === "tool") return error(state, "Tools cannot be trashed");
165       if (!toolAvailable(player, "trashCan", rules)) return error(state, "Trash can is being upgraded");
166       const refund = Math.floor(sellPrice(item, taken.stack.quality) * taken.stack.quantity * trashRefundRate(player, rules));
167       const wallet = state.wallet.mode === "shared" ? { ...state.wallet, shared: state.wallet.shared + refund } : { ...state.wallet, balances: { ...state.wallet.balances, [player.id]: (state.wallet.balances[player.id] ?? 0) + refund } };
168       return result({ ...state, wallet }, taken.player);
169     }),
170     defineIntent("backpackUpgrade", z.object({ type: z.literal("backpackUpgrade") }), ({ state, player, rules }) => {
171       const inventory = getInventory(player, rules);
172       if (inventory.capacity === 36) return error(state, "Backpack is fully upgraded");
173       const cost = inventory.capacity === 12 ? 2000 : 10000, balance = state.wallet.mode === "shared" ? state.wallet.shared : state.wallet.balances[player.id] ?? 0;
174       if (balance < cost) return error(state, "Insufficient funds");
175       const capacity = inventory.capacity === 12 ? 24 : 36;
176       const wallet = state.wallet.mode === "shared" ? { ...state.wallet, shared: balance - cost } : { ...state.wallet, balances: { ...state.wallet.balances, [player.id]: balance - cost } };
177       return result({ ...state, wallet }, setInventory(player, { ...inventory, capacity, slots: [...inventory.slots, ...Array<null>(12).fill(null)] }));
178     }),
179     defineIntent("pickupItems", z.object({ type: z.literal("pickupItems") }), ({ state, playerId, rules }) => pickupNearby(state, playerId, rules)),
180     defineIntent("placeChest", z.object({ type: z.literal("placeChest"), slot, tile: TilePositionSchema }), ({ state, player, rules }, intent) => {
181       const hit = target(state, player, intent.tile), inventory = getInventory(player, rules), stack = inventory.slots[intent.slot];
182       if (!hit || !inToolRange(player, intent.tile) || hit.area.collision[hit.index] || hit.tile.objectId || hit.tile.crop || hit.area.warps.some((warp) => intent.tile.x >= warp.x && intent.tile.x < warp.x + warp.width && intent.tile.y >= warp.y && intent.tile.y < warp.y + warp.height) || Object.values(state.players).some((p) => p.areaId === player.areaId && Math.floor(p.x) === intent.tile.x && Math.floor(p.y) === intent.tile.y)) return error(state, "Chest cannot be placed here");
183       if (!stack || !getItem(rules, stack.id)?.tags.includes("chest")) return error(state, "Select a chest");
184       const taken = takeSlot(player, intent.slot, 1, rules)!; const world = getInventoryWorld(state), id = `chest_${world.nextObjectId}`;
185       const chest: Chest = { id, areaId: player.areaId, ...intent.tile, slots: Array<ItemStack | null>(36).fill(null) };
186       const changed = withInventoryWorld(withTile(state, hit.area, hit.index, { ...hit.tile, objectId: id }), { ...world, nextObjectId: world.nextObjectId + 1, chests: [...world.chests, chest] });
187       return result(changed, taken.player, "chestPlaced");
188     }),
189     defineIntent("chestTransfer", z.object({ type: z.literal("chestTransfer"), chestId: z.string(), from: z.enum(["backpack", "chest"]), slot, toSlot: slot.optional(), quantity: amount }), ({ state, player, rules }, intent) => {
190       const world = getInventoryWorld(state), chest = world.chests.find((value) => value.id === intent.chestId);
191       if (!chest || chest.areaId !== player.areaId || !inToolRange(player, chest)) return error(state, "Chest is out of reach");
192       const inventory = getInventory(player, rules), source = intent.from === "backpack" ? inventory.slots : chest.slots, destination = intent.from === "backpack" ? chest.slots : inventory.slots;
193       const stack = source[intent.slot]; if (!stack || intent.quantity > stack.quantity) return error(state, "Invalid inventory slot or amount");
194       const tool = ToolIdSchema.safeParse(stack.id.replace(/^tool_/, ""));
195       if (intent.from === "backpack" && tool.success && !toolAvailable(player, tool.data, rules)) return error(state, "Tool is being upgraded");
196       const max = getItem(rules, stack.id)?.stackMax; if (!max) return error(state, "Unknown item");
197       let inserted: { slots: (ItemStack | null)[]; remaining: number };
198       if (intent.toSlot !== undefined) {
199         if (intent.toSlot >= destination.length) return error(state, "Slot is outside backpack");
200         const current = destination[intent.toSlot];
201         if (current && !same(current, stack)) return error(state, "Destination has a different item");
202         const moved = Math.min(intent.quantity, max - (current?.quantity ?? 0)), slots = destination.slice();
203         slots[intent.toSlot] = { ...stack, quantity: (current?.quantity ?? 0) + moved }; inserted = { slots, remaining: intent.quantity - moved };
204       } else inserted = insertSlots(destination, { ...stack, quantity: intent.quantity }, max);
205       const moved = intent.quantity - inserted.remaining; if (!moved) return error(state, "Inventory is full");
206       const remainingSource = source.slice(); remainingSource[intent.slot] = moved === stack.quantity ? null : { ...stack, quantity: stack.quantity - moved };
207       const changedChest = { ...chest, slots: intent.from === "backpack" ? inserted.slots : remainingSource };
208       return result(withInventoryWorld(state, { ...world, chests: world.chests.map((value) => value.id === chest.id ? changedChest : value) }), setInventory(player, { ...inventory, slots: intent.from === "backpack" ? remainingSource : inserted.slots }));
209     }),
210   ],
211   onPlayerAdded: ({ state, playerId, rules }) => ({ state: playerId && Object.keys(state.players[playerId]?.inventory ?? {}).length ? hydrateInventory(state, playerId, rules) : state, events: [] }),
212   onPresenceChange: ({ state, playerId, rules }) => ({ state: playerId ? hydrateInventory(state, playerId, rules) : state, events: [] }),
213   onClockTick: ({ state, rules }) => {
214     for (const player of Object.values(state.players)) if (player.connected) state = pickupNearby(hydrateInventory(state, player.id, rules), player.id, rules).state;
215     return { state, events: [] };
216   },
217 });
218