Exact source used by this build. SHA-256: f0a3bb81097aa2951db3f237d8338b2f97b51c02d519f437525e6dd820f2e453.
1 import { z } from "zod";
2 import type { Clock, Player, Season, SimRules, WorldState } from "../../types";
3 import { inToolRange } from "../../state";
4 import { createRng } from "../../rng";
5 import { defineIntent, defineSystem } from "../registry";
6 import { addItem, takeSlot } from "../inventory";
7 import { getItem } from "../items";
8 import { calendarDay } from "../shipping";
9 import { creditMoney, debitMoney } from "../shipping/money";
10 import { crackMineGeode } from "../mines/geodes";
11 import { shopkeeperPresent } from "../villagers";
12
13 export interface ShopStock { id: string; itemId: string; price: number; quantity: number | null; seasons: Season[]; weekdays: number[]; rotating: boolean }
14 export interface ShopDefinition { id: string; name: string; description: string; areaId: string; x: number; y: number; openMinute: number; closeMinute: number; closedDays: number[]; stock: ShopStock[]; rotationCount: number; toolUpgrades: boolean; geodeCracking: boolean }
15 export interface ShopsState { day: string; purchased: Record<string, number> }
16 export function getShops(rules: SimRules): readonly ShopDefinition[] {
17 const base = rules.content?.shops as ShopDefinition[] ?? [];
18 const extra = Object.entries(rules.content ?? {}).flatMap(([id, domain]) => id !== "shops" && domain && typeof domain === "object" && "shops" in domain && Array.isArray(domain.shops) ? domain.shops as ShopDefinition[] : []);
19 return [...base, ...extra];
20 }
21 export function shopIsOpen(shop: ShopDefinition, clock: Clock, state?: WorldState, rules?: SimRules): boolean { return !shop.closedDays.includes(clock.weekday) && clock.minuteOfDay >= shop.openMinute && clock.minuteOfDay < shop.closeMinute && (!state || !rules || shopkeeperPresent(state, rules, shop.id)); }
22 export function shopAccess(state: WorldState, player: Player, rules: SimRules, shopId: string): { shop: ShopDefinition } | { error: string } {
23 const shop = getShops(rules).find((entry) => entry.id === shopId);
24 if (!shop || shop.areaId !== player.areaId || !inToolRange(player, shop)) return { error: "Shop is out of reach" };
25 return shopIsOpen(shop, state.clock, state, rules) ? { shop } : { error: "Shop is closed" };
26 }
27 /** Rotation uses only calendar + shop ID, so previews do not consume the world's RNG. */
28 export function availableShopStock(shop: ShopDefinition, clock: Clock): ShopStock[] {
29 const candidates = shop.stock.filter((stock) => stock.seasons.includes(clock.season) && stock.weekdays.includes(clock.weekday));
30 let hash = 2166136261;
31 for (const char of `${clock.year}:${clock.season}:${clock.day}:${shop.id}`) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619) >>> 0;
32 const rng = createRng(hash), rotating = candidates.filter((stock) => stock.rotating);
33 for (let at = rotating.length - 1; at > 0; at--) { const other = rng.nextInt(at + 1); [rotating[at], rotating[other]] = [rotating[other]!, rotating[at]!]; }
34 const selected = new Set(rotating.slice(0, shop.rotationCount).map((stock) => stock.id));
35 return candidates.filter((stock) => !stock.rotating || selected.has(stock.id));
36 }
37 export function getShopStock(state: WorldState, rules: SimRules, shopId: string): (ShopStock & { remaining: number | null })[] {
38 const shop = getShops(rules).find((entry) => entry.id === shopId);
39 if (!shop) return [];
40 const saved = state.systems.shops as ShopsState | undefined, purchased = saved?.day === calendarDay(state) ? saved.purchased : {};
41 return availableShopStock(shop, state.clock).map((stock) => ({ ...stock, remaining: stock.quantity === null ? null : Math.max(0, stock.quantity - (purchased[`${shopId}:${stock.id}`] ?? 0)) }));
42 }
43
44 export const shopsSystem = defineSystem({
45 id: "shops", worldDefaults: () => ({ day: "", purchased: {} }), playerDefaults: () => ({}), events: ["shopBought", "shopSold", "geodeCracked"],
46 intents: [
47 defineIntent("shopBuy", z.object({ type: z.literal("shopBuy"), shopId: z.string().min(1), stockId: z.string().min(1), quantity: z.number().int().min(1).max(999) }), ({ state, player, playerId, rules }, intent) => {
48 const access = shopAccess(state, player, rules, intent.shopId);
49 if ("error" in access) return { state, events: [], error: access.error };
50 const stock = getShopStock(state, rules, intent.shopId).find((entry) => entry.id === intent.stockId);
51 if (!stock || stock.remaining !== null && stock.remaining < intent.quantity) return { state, events: [], error: "Stock is unavailable" };
52 if (!getItem(rules, stock.itemId)) return { state, events: [], error: "Unknown item" };
53 const cost = stock.price * intent.quantity, paid = debitMoney(state, playerId, cost);
54 if (!paid) return { state, events: [], error: "Insufficient funds" };
55 const added = addItem(player, { id: stock.itemId, quality: "regular", quantity: intent.quantity }, rules);
56 if (added.remaining > 0) return { state, events: [], error: "Inventory is full" };
57 const saved = state.systems.shops as ShopsState | undefined, day = calendarDay(state), purchased = saved?.day === day ? saved.purchased : {}, key = `${intent.shopId}:${stock.id}`;
58 return { state: { ...paid, revision: state.revision + 1, players: { ...paid.players, [playerId]: added.player }, systems: { ...paid.systems, shops: { day, purchased: { ...purchased, [key]: (purchased[key] ?? 0) + intent.quantity } } } }, events: [{ type: "shopBought", playerId, areaId: player.areaId, data: { itemId: stock.itemId, quantity: intent.quantity, cost } }] };
59 }),
60 defineIntent("shopSell", z.object({ type: z.literal("shopSell"), shopId: z.string().min(1), slot: z.number().int().nonnegative(), quantity: z.number().int().min(1).max(999) }), ({ state, player, playerId, rules }, intent) => {
61 const access = shopAccess(state, player, rules, intent.shopId);
62 if ("error" in access) return { state, events: [], error: access.error };
63 const taken = takeSlot(player, intent.slot, intent.quantity, rules);
64 if (!taken) return { state, events: [], error: "Item is not in inventory" };
65 const item = getItem(rules, taken.stack.id);
66 if (!item || item.baseSellPrice <= 0 || item.category === "tool" || item.category === "equipment") return { state, events: [], error: "Item cannot be sold" };
67 const total = item.baseSellPrice * taken.stack.quantity, paid = creditMoney(state, playerId, total);
68 return { state: { ...paid, revision: state.revision + 1, players: { ...paid.players, [playerId]: taken.player } }, events: [{ type: "shopSold", playerId, areaId: player.areaId, data: { itemId: item.id, quantity: taken.stack.quantity, total } }] };
69 }),
70 defineIntent("crackGeode", z.object({ type: z.literal("crackGeode"), shopId: z.string().min(1), slot: z.number().int().min(0).max(35).optional() }), (context, intent) => {
71 const { state, player, rules } = context;
72 const access = shopAccess(state, player, rules, intent.shopId);
73 if ("error" in access) return { state, events: [], error: access.error };
74 return access.shop.geodeCracking ? crackMineGeode(context, intent.slot) : { state, events: [], error: "This shop does not crack geodes" };
75 }),
76 ],
77 onOvernight: ({ state }) => ({ state: { ...state, revision: state.revision + 1, systems: { ...state.systems, shops: { day: calendarDay(state), purchased: {} } } }, events: [] }),
78 });
79