Exact source used by this build. SHA-256: 1834e208cb97998171d196139e573ac6e3ee06a1f474cce2af2c5bd9f3843c28.

1 import { z } from "zod";
2 import type { IntentResult, Player, Season, SimRules, WorldState } from "../../types";
3 import { inToolRange } from "../../state";
4 import { defineIntent, defineSystem, type IntentContext, type SystemContext } from "../registry";
5 import { addItem, getInventory, type ItemStack } from "../inventory";
6 import { getItem, sellPrice, type Quality } from "../items";
7 import { calendarDay } from "../shipping";
8 import { startFishingCast, hookFishing } from "../fishing";
9 import { creditMoney, debitMoney } from "../shipping/money";
10 
11 export type FestivalGameKind = "collect" | "fishing" | "display" | "targets";
12 interface Point { x: number; y: number }
13 interface PoiPoint extends Point { poiId: string }
14 export interface FestivalDefinition {
15   id: string; name: string; description: string; manifestKey: string; season: Season; day: number;
16   startMinute: number; endMinute: number; returnMinute: number; areaId: string; entrance: PoiPoint;
17   layout: (PoiPoint & { id: string; kind: "decor" | "stall" | "game"; manifestKey: string })[];
18   shop: PoiPoint & { stock: { id: string; itemId: string; price: number; quantity: number }[] };
19   game: { kind: FestivalGameKind; durationMs: number; winScore: number; multiplayerWinScore: number; rewardGold: number;
20     collectibles: (Point & { id: string; manifestKey: string })[];
21     targets: { id: string; appearsMs: number; expiresMs: number; points: number; manifestKey: string }[];
22     fishingBiteDelayMs: number; fishingBiteWindowMs: number; fishingCastCooldownMs: number; displaySlots: number; shotCooldownMs: number };
23 }
24 export interface FestivalsContent {
25   festivals: FestivalDefinition[];
26   closedBuildings: (PoiPoint & { areaId: string; width: number; height: number })[];
27   displayScoring: { basePoints: number; itemCountMultiplier: number; emptySlotPenalty: number; categoryPoints: number; maxCategories: number; priceThresholds: number[]; points: Record<Quality, number[]>; placements: { score: number; rewardGold: number }[] };
28 }
29 export interface FestivalRun {
30   kind: FestivalGameKind; elapsedMs: number; score: number; finished: boolean; claimed: boolean; collected: string[];
31   casts: number; castAtMs: number | null; nextActionAtMs: number; hits: string[]; display: ItemStack[]; shots: number; participants: number;
32 }
33 export interface FestivalPlayerState { festivalId: string | null; day: string; enteredMinute: number; leftDay: string | null; run: FestivalRun | null; attended: number; won: number }
34 export interface FestivalsState {
35   day: string; purchased: Record<string, number>; clockFrozen: boolean; roundStarted: boolean; collected: string[];
36   display: { items: ItemStack[]; score: number } | null; totalAttendance: number; totalWins: number;
37 }
38 const blankPlayer = (): FestivalPlayerState => ({ festivalId: null, day: "", enteredMinute: 0, leftDay: null, run: null, attended: 0, won: 0 });
39 const blankWorld = (): FestivalsState => ({ day: "", purchased: {}, clockFrozen: false, roundStarted: false, collected: [], display: null, totalAttendance: 0, totalWins: 0 });
40 export function getFestivalsContent(rules: SimRules): FestivalsContent | undefined { return rules.content?.festivals as FestivalsContent | undefined; }
41 export function getFestivals(rules: SimRules): FestivalDefinition[] { return getFestivalsContent(rules)?.festivals ?? []; }
42 export function getFestivalPlayer(player: Player): FestivalPlayerState { return player.systems.festivals as FestivalPlayerState ?? blankPlayer(); }
43 export function getFestivalsState(state: WorldState): FestivalsState { return state.systems.festivals as FestivalsState ?? blankWorld(); }
44 export function festivalToday(state: WorldState, rules: SimRules): FestivalDefinition | undefined { return getFestivals(rules).find((festival) => festival.season === state.clock.season && festival.day === state.clock.day); }
45 export function attendingFestival(state: WorldState, player: Player, rules: SimRules): FestivalDefinition | undefined {
46   const saved = getFestivalPlayer(player), today = festivalToday(state, rules);
47   return saved.day === calendarDay(state) && saved.festivalId === today?.id && player.areaId === today?.areaId ? today : undefined;
48 }
49 export function festivalPausesClock(state: WorldState, rules: SimRules): boolean {
50   const connected = Object.values(state.players).filter((player) => player.connected);
51   return connected.length > 0 && connected.every((player) => !!attendingFestival(state, player, rules) && !!getFestivalPlayer(player).run && !getFestivalPlayer(player).run!.finished);
52 }
53 function worldForDay(state: WorldState): FestivalsState {
54   const saved = getFestivalsState(state), day = calendarDay(state);
55   return saved.day === day ? saved : { ...blankWorld(), day, totalAttendance: saved.totalAttendance, totalWins: saved.totalWins };
56 }
57 function setPlayer(player: Player, saved: FestivalPlayerState): Player { return { ...player, systems: { ...player.systems, festivals: saved } }; }
58 function changed(state: WorldState, rules: SimRules, world = worldForDay(state)): WorldState {
59   return { ...state, revision: state.revision + 1, systems: { ...state.systems, festivals: { ...world, clockFrozen: festivalPausesClock(state, rules) } } };
60 }
61 function reject(state: WorldState, error: string): IntentResult { return { state, events: [], error }; }
62 function event(state: WorldState, type: string, playerId?: string): IntentResult { return { state, events: [{ type, ...(playerId ? { playerId } : {}), ...(type === "festivalGameUpdated" ? { transient: true } : {}) }] }; }
63 function setRun(state: WorldState, player: Player, run: FestivalRun, rules: SimRules): WorldState {
64   return changed({ ...state, players: { ...state.players, [player.id]: setPlayer(player, { ...getFestivalPlayer(player), run }) } }, rules);
65 }
66 /** Only the fishing producer calls this after a verified rod catch; pot/trash results never score. */
67 export function recordFestivalCatch(state: WorldState, playerId: string, rules: SimRules): WorldState {
68   const player = state.players[playerId], festival = player && attendingFestival(state, player, rules), run = player && getFestivalPlayer(player).run;
69   if (!player || !festival || !run || run.kind !== "fishing" || run.finished || !atGame(player, festival)) return state;
70   return setRun(state, player, { ...run, score: run.score + 1 }, rules);
71 }
72 function activeRun(context: IntentContext, kind?: FestivalGameKind): { festival: FestivalDefinition; run: FestivalRun } | undefined {
73   const festival = attendingFestival(context.state, context.player, context.rules), run = getFestivalPlayer(context.player).run;
74   return festival && run && !run.finished && (!kind || run.kind === kind) ? { festival, run } : undefined;
75 }
76 function atGame(player: Player, festival: FestivalDefinition): boolean {
77   const point = festival.layout.find((entry) => entry.kind === "game");
78   return !!point && player.areaId === festival.areaId && inToolRange(player, point);
79 }
80 
81 /** A closed building can be exited by someone who was already inside at dawn. */
82 export function festivalBlocksPosition({ state, rules, player }: { state: WorldState; rules: SimRules; player: Player }, x: number, y: number): string | undefined {
83   if (!festivalToday(state, rules)) return;
84   if (attendingFestival(state, player, rules) && state.areas[player.areaId]?.warps.some((warp) => x >= warp.x && y >= warp.y && x < warp.x + warp.width && y < warp.y + warp.height)) return "Leave through the festival menu";
85   for (const building of getFestivalsContent(rules)?.closedBuildings ?? []) {
86     if (building.areaId !== player.areaId) continue;
87     const inside = (point: Point) => point.x >= building.x && point.y >= building.y && point.x < building.x + building.width && point.y < building.y + building.height;
88     if (inside({ x, y }) && !inside(player)) return "Homes and shops are closed for the festival";
89   }
90 }
91 export function festivalIntentGuard(context: IntentContext, intent: { type: string; [key: string]: unknown }): string | undefined {
92   if (festivalToday(context.state, context.rules) && ["shopBuy", "shopSell", "upgradeTool", "crackGeode", "fishingBuy"].includes(intent.type)) return "Shops are closed for the festival";
93   const festival = attendingFestival(context.state, context.player, context.rules);
94   if (!festival) return;
95   if (intent.type === "interact") return "Leave through the festival menu";
96   if (intent.type === "move") {
97     const area = context.state.areas[context.player.areaId];
98     if (area?.warps.some((warp) => typeof intent.x === "number" && typeof intent.y === "number" && intent.x >= warp.x && intent.y >= warp.y && intent.x < warp.x + warp.width && intent.y < warp.y + warp.height)) return "Leave through the festival menu";
99     return;
100   }
101   if (["fishingStartCast", "fishingCast", "fishingInput", "fishingHook", "fishingCancel", "fishingClaim", "fishingDismiss", "fishingEquip", "fishingBait", "fishingTackle"].includes(intent.type)) {
102     const run = getFestivalPlayer(context.player).run;
103     if (run?.kind === "fishing" && !run.finished && atGame(context.player, festival)) return;
104     if (["fishingClaim", "fishingDismiss", "fishingCancel"].includes(intent.type)) return;
105     return "The fishing contest is unavailable or out of reach";
106   }
107   // Festival attendees can manage their backpack, but cannot change the ordinary world beneath its overlay.
108   if (!intent.type.startsWith("festival") && !["pause", "inventorySelect", "inventoryMove", "inventorySplit", "inventoryMerge"].includes(intent.type)) return "That activity is unavailable inside the festival";
109 }
110 
111 /** Item values use actual quality sale price and the six parity price bands. */
112 export function gradeFestivalDisplay(stacks: readonly ItemStack[], rules: SimRules): number {
113   const scoring = getFestivalsContent(rules)?.displayScoring;
114   if (!scoring) return 0;
115   const categories = new Set<string>(); let points = scoring.basePoints + scoring.itemCountMultiplier * stacks.length - scoring.emptySlotPenalty;
116   for (const stack of stacks) {
117     const item = getItem(rules, stack.id); if (!item) continue;
118     const category = item.tags.includes("animal_product") ? "animal" : item.tags.includes("fruit") ? "fruit" : item.category === "crop" ? "vegetable" : ["artisan", "cooked", "fish", "forage", "mineral"].includes(item.category) ? item.category : undefined;
119     if (category) categories.add(category);
120     const price = sellPrice(item, stack.quality), band = scoring.priceThresholds.reduce((result, threshold, at) => price >= threshold ? at : result, 0);
121     points += scoring.points[stack.quality][band] ?? 0;
122   }
123   return points + Math.min(scoring.maxCategories, categories.size) * scoring.categoryPoints;
124 }
125 export function advanceFestivalElapsed({ state, rules, elapsedMs }: SystemContext): IntentResult {
126   if (state.paused || !Object.values(state.players).some((player) => player.connected) || !Number.isFinite(elapsedMs) || !elapsedMs || elapsedMs < 0) return { state, events: [] };
127   let touched = false; const finished: string[] = [];
128   const players = Object.fromEntries(Object.entries(state.players).map(([id, player]) => {
129     const festival = attendingFestival(state, player, rules), saved = getFestivalPlayer(player), run = saved.run;
130     // A disconnect cannot stop a contest already in progress while the other player is online.
131     if (!festival || !run || run.finished || run.kind === "display") return [id, player];
132     touched = true;
133     const elapsed = Math.min(festival.game.durationMs, run.elapsedMs + elapsedMs), done = elapsed >= festival.game.durationMs;
134     if (done) finished.push(id);
135     const score = done && run.kind === "targets" ? Math.floor(run.score * (run.shots ? 1 + 3 * run.hits.length / run.shots : 1)) : run.score;
136     return [id, setPlayer(player, { ...saved, run: { ...run, elapsedMs: elapsed, finished: done, score } })];
137   }));
138   const frozen = festivalPausesClock(state, rules);
139   if (!touched && frozen === getFestivalsState(state).clockFrozen) return { state, events: [] };
140   return { state: changed({ ...state, players }, rules), events: finished.map((playerId) => ({ type: "festivalGameFinished", playerId })) };
141 }
142 
143 export const festivalsSystem = defineSystem({
144   id: "festivals", worldDefaults: () => ({ ...blankWorld() }), playerDefaults: () => ({ ...blankPlayer() }),
145   events: ["festivalEntered", "festivalLeft", "festivalPurchased", "festivalGameStarted", "festivalGameUpdated", "festivalGameFinished", "festivalReward"],
146   updateIntervalMs: (state: WorldState) => !state.paused && Object.values(state.players).some((player) => player.connected && getFestivalPlayer(player).run && !getFestivalPlayer(player).run!.finished && getFestivalPlayer(player).run!.kind !== "display") ? 250 : undefined,
147   clockPauseRemainingMs: (state: WorldState, rules: SimRules) => {
148     const remaining = Object.values(state.players).flatMap((player) => {
149       const festival = attendingFestival(state, player, rules), run = getFestivalPlayer(player).run;
150       return festival && run && !run.finished && run.kind !== "display" ? [festival.game.durationMs - run.elapsedMs] : [];
151     }).filter((duration) => duration > 0);
152     return remaining.length ? Math.min(...remaining) : undefined;
153   },
154   weatherOverride: (state: WorldState, rules: SimRules) => festivalToday(state, rules) ? "sunny" as const : undefined,
155   pausesClock: festivalPausesClock, guardIntent: festivalIntentGuard, blocksPosition: festivalBlocksPosition, onElapsedTime: advanceFestivalElapsed,
156   onPresenceChange: ({ state, rules }) => ({ state: festivalPausesClock(state, rules) === getFestivalsState(state).clockFrozen ? state : changed(state, rules), events: [] }),
157   onOvernight: ({ state, rules }) => {
158     const farm = state.areas.farm, players = Object.fromEntries(Object.entries(state.players).map(([id, player]) => {
159       const saved = getFestivalPlayer(player), moved = saved.festivalId && farm ? { ...player, areaId: farm.id, x: farm.spawn.x, y: farm.spawn.y } : player;
160       return [id, setPlayer(moved, { ...blankPlayer(), attended: saved.attended, won: saved.won })];
161     }));
162     return { state: changed({ ...state, players }, rules), events: [] };
163   },
164   intents: [
165     defineIntent("festivalEnter", z.object({ type: z.literal("festivalEnter"), festivalId: z.string().min(1) }), ({ state, rules, player }, intent) => {
166       const festival = festivalToday(state, rules), saved = getFestivalPlayer(player), day = calendarDay(state);
167       if (!festival || festival.id !== intent.festivalId || state.clock.minuteOfDay < festival.startMinute || state.clock.minuteOfDay >= festival.endMinute) return reject(state, "Festival admission is closed");
168       if (saved.festivalId || saved.leftDay === day) return reject(state, "Already attended this festival today");
169       if (player.areaId !== festival.areaId || !inToolRange(player, festival.entrance)) return reject(state, "Festival entrance is out of reach");
170       const world = worldForDay(state), updated = setPlayer(player, { ...saved, festivalId: festival.id, day, enteredMinute: state.clock.minuteOfDay, run: null, attended: saved.attended + 1 });
171       return event(changed({ ...state, players: { ...state.players, [player.id]: updated } }, rules, { ...world, totalAttendance: world.totalAttendance + 1 }), "festivalEntered", player.id);
172     }),
173     defineIntent("festivalLeave", z.object({ type: z.literal("festivalLeave") }), ({ state, rules, player }) => {
174       const festival = attendingFestival(state, player, rules), farm = state.areas.farm;
175       if (!festival || !farm) return reject(state, "Not attending a festival");
176       const saved = getFestivalPlayer(player), updated = setPlayer({ ...player, areaId: farm.id, x: farm.spawn.x, y: farm.spawn.y }, { ...saved, festivalId: null, leftDay: calendarDay(state), run: saved.run ? { ...saved.run, finished: true } : null });
177       const minute = Math.max(state.clock.minuteOfDay, festival.returnMinute);
178       return event(changed({ ...state, players: { ...state.players, [player.id]: updated }, clock: { ...state.clock, minuteOfDay: minute, machineMinutes: state.clock.machineMinutes + minute - state.clock.minuteOfDay } }, rules), "festivalLeft", player.id);
179     }),
180     defineIntent("festivalBuy", z.object({ type: z.literal("festivalBuy"), stockId: z.string().min(1), quantity: z.number().int().min(1).max(999) }), ({ state, rules, player }, intent) => {
181       const festival = attendingFestival(state, player, rules), stock = festival?.shop.stock.find((entry) => entry.id === intent.stockId);
182       if (!festival || !stock || !inToolRange(player, festival.shop)) return reject(state, "Festival stall is out of reach");
183       const world = worldForDay(state), purchased = world.purchased[stock.id] ?? 0;
184       if (purchased + intent.quantity > stock.quantity) return reject(state, "Festival stock is sold out");
185       const paid = debitMoney(state, player.id, stock.price * intent.quantity);
186       if (!paid) return reject(state, "Insufficient funds");
187       const added = addItem(player, { id: stock.itemId, quality: "regular", quantity: intent.quantity }, rules);
188       if (added.remaining) return reject(state, "Inventory is full");
189       return event(changed({ ...paid, players: { ...paid.players, [player.id]: added.player } }, rules, { ...world, purchased: { ...world.purchased, [stock.id]: purchased + intent.quantity } }), "festivalPurchased", player.id);
190     }),
191     defineIntent("festivalStart", z.object({ type: z.literal("festivalStart") }), ({ state, rules, player }) => {
192       const festival = attendingFestival(state, player, rules), world = worldForDay(state);
193       if (!festival || !atGame(player, festival)) return reject(state, "Festival game is out of reach");
194       if (world.roundStarted) return reject(state, "The festival contest has already started");
195       const attendees = Object.values(state.players).filter((p) => p.connected && attendingFestival(state, p, rules));
196       const run = (): FestivalRun => ({ kind: festival.game.kind, elapsedMs: 0, score: 0, finished: false, claimed: false, collected: [], casts: 0, castAtMs: null, nextActionAtMs: 0, hits: [], display: [], shots: 0, participants: attendees.length });
197       const players = { ...state.players };
198       for (const attendee of attendees) players[attendee.id] = setPlayer(attendee, { ...getFestivalPlayer(attendee), run: run() });
199       return event(changed({ ...state, players }, rules, { ...world, roundStarted: true }), "festivalGameStarted", player.id);
200     }),
201     defineIntent("festivalCollect", z.object({ type: z.literal("festivalCollect"), targetId: z.string().min(1) }), (context, intent) => {
202       const { state, rules, player } = context, active = activeRun(context, "collect"), target = active?.festival.game.collectibles.find((entry) => entry.id === intent.targetId), world = worldForDay(state);
203       if (!active || !target || !inToolRange(player, target)) return reject(state, "Collectible is out of reach");
204       if (world.collected.includes(target.id)) return reject(state, "Collectible was already taken");
205       const next = setRun(state, player, { ...active.run, score: active.run.score + 1, collected: [...active.run.collected, target.id] }, rules);
206       return event(changed(next, rules, { ...world, collected: [...world.collected, target.id] }), "festivalGameUpdated", player.id);
207     }),
208     defineIntent("festivalCast", z.object({ type: z.literal("festivalCast") }), (context) => {
209       const active = activeRun(context, "fishing");
210       if (!active || !atGame(context.player, active.festival)) return reject(context.state, "Fishing contest is out of reach");
211       return startFishingCast(context);
212     }, { timing: "fishing", persistence: "batched" }),
213     defineIntent("festivalReel", z.object({ type: z.literal("festivalReel") }), (context) => {
214       const active = activeRun(context, "fishing");
215       if (!active || !atGame(context.player, active.festival)) return reject(context.state, "Fishing contest is out of reach");
216       return hookFishing(context);
217     }, { timing: "fishing", persistence: "batched" }),
218     defineIntent("festivalDisplay", z.object({ type: z.literal("festivalDisplay"), slots: z.array(z.number().int().nonnegative()).min(1).max(9) }), (context, intent) => {
219       const { state, rules, player } = context, active = activeRun(context, "display"), world = worldForDay(state);
220       if (!active || !atGame(player, active.festival) || world.display) return reject(state, "Display is unavailable");
221       const inventory = getInventory(player, rules), requested = new Map<number, number>();
222       for (const slot of intent.slots) requested.set(slot, (requested.get(slot) ?? 0) + 1);
223       if ([...requested].some(([slot, quantity]) => !inventory.slots[slot] || inventory.slots[slot]!.quantity < quantity)) return reject(state, "Display items are not in inventory");
224       // Judging is synchronous: inspect owned items without consuming or duplicating them.
225       const items = intent.slots.map((slot) => ({ ...inventory.slots[slot]!, quantity: 1 })), score = gradeFestivalDisplay(items, rules);
226       const players = Object.fromEntries(Object.entries(state.players).map(([id, participant]) => {
227         const saved = getFestivalPlayer(participant);
228         return [id, saved.day === calendarDay(state) && saved.run?.kind === "display" ? setPlayer(participant, { ...saved, run: { ...saved.run, display: items, score, finished: true } }) : participant];
229       }));
230       return event(changed({ ...state, players }, rules, { ...world, display: { items, score } }), "festivalGameFinished", player.id);
231     }),
232     defineIntent("festivalShoot", z.object({ type: z.literal("festivalShoot"), targetId: z.string().min(1) }), (context, intent) => {
233       const { state, rules, player } = context, active = activeRun(context, "targets");
234       if (!active || !atGame(player, active.festival)) return reject(state, "Target game is out of reach");
235       const { run, festival } = active;
236       if (run.elapsedMs < run.nextActionAtMs) return reject(state, "Wait before shooting again");
237       const target = festival.game.targets.find((entry) => entry.id === intent.targetId), hit = target && run.elapsedMs >= target.appearsMs && run.elapsedMs < target.expiresMs && !run.hits.includes(target.id);
238       return event(setRun(state, player, { ...run, shots: run.shots + 1, score: run.score + (hit ? target.points : 0), hits: hit ? [...run.hits, target.id] : run.hits, nextActionAtMs: run.elapsedMs + festival.game.shotCooldownMs }, rules), "festivalGameUpdated", player.id);
239     }, { persistence: "batched" }),
240     defineIntent("festivalClaim", z.object({ type: z.literal("festivalClaim") }), ({ state, rules, player }) => {
241       const festival = attendingFestival(state, player, rules), saved = getFestivalPlayer(player), run = saved.run;
242       if (!festival || !run?.finished || run.claimed) return reject(state, "Festival reward is unavailable");
243       const threshold = run.participants > 1 ? festival.game.multiplayerWinScore : festival.game.winScore;
244       const competitors = Object.values(state.players).map(getFestivalPlayer).filter((p) => p.day === saved.day && p.run?.kind === run.kind);
245       const won = run.score >= threshold && (run.kind !== "collect" || competitors.every((p) => (p.run?.score ?? 0) <= run.score));
246       const reward = run.kind === "display" ? getFestivalsContent(rules)?.displayScoring.placements.find((p) => run.score >= p.score)?.rewardGold ?? 0 : won ? festival.game.rewardGold : 0;
247       const updated = setPlayer(player, { ...saved, won: saved.won + (won ? 1 : 0), run: { ...run, claimed: true } }), world = worldForDay(state);
248       const paid = creditMoney(state, player.id, reward);
249       return event(changed({ ...paid, players: { ...paid.players, [player.id]: updated } }, rules, { ...world, totalWins: world.totalWins + (won ? 1 : 0) }), "festivalReward", player.id);
250     }),
251     defineIntent("festivalPulse", z.object({ type: z.literal("festivalPulse") }), ({ state, rules, player }) => attendingFestival(state, player, rules) ? { state, events: [] } : reject(state, "Not attending a festival"), { persistence: "batched" }),
252   ],
253 });
254