Exact source used by this build. SHA-256: e78c22361989a2ffbf821ce802e730bf6f3439171be66d592dd451df95653163.
1 import { z } from "zod";
2 import type { CommunityContent, CommunityReward } from "../../../../content/src/domains/community/schema";
3 import type { IntentResult, Player, SimEvent, SimRules, WorldState } from "../../types";
4 import { createRng } from "../../rng";
5 import { inToolRange } from "../../state";
6 import { defineIntent, defineSystem } from "../registry";
7 import { addItem, countItem, getInventory, takeSlot } from "../inventory";
8 import { getItem, type Quality } from "../items";
9 import { calendarDay, getPlayerShipping, getShipping } from "../shipping";
10 import { creditMoney } from "../shipping/money";
11 import { toolAvailable, toolEnergyCost } from "../tools";
12 export type { CommunityContent } from "../../../../content/src/domains/community/schema";
13
14 export type CollectionCategory = "shipped" | "fish" | "cooking" | "crafting" | "museum";
15 export interface BundleProgress { donated: Record<string, number>; completed: boolean }
16 export interface CommunityPendingReward extends CommunityReward { claimedBy: string | null }
17 export interface ArtifactSpot { id: string; areaId: string; x: number; y: number; itemId: string }
18 export interface CommunityState {
19 bundles: Record<string, BundleProgress>;
20 completedRooms: string[];
21 repairedWarps: string[];
22 greenhouseRestored: boolean;
23 museum: { donated: Record<string, { playerId: string; day: string }>; display: (string | null)[]; claimedMilestones: number[] };
24 rewards: Record<string, CommunityPendingReward>;
25 collections: Record<CollectionCategory, Record<string, number>>;
26 counters: Record<string, number>;
27 achievements: string[];
28 artifactSpots: ArtifactSpot[];
29 lastArtifactDay: string;
30 nextSpotId: number;
31 lastShippingDay: string;
32 }
33 function defaults(): CommunityState { return { bundles: {}, completedRooms: [], repairedWarps: [], greenhouseRestored: false, museum: { donated: {}, display: [], claimedMilestones: [] }, rewards: {}, collections: { shipped: {}, fish: {}, cooking: {}, crafting: {}, museum: {} }, counters: {}, achievements: [], artifactSpots: [], lastArtifactDay: "", nextSpotId: 1, lastShippingDay: "" }; }
34 export function getCommunity(state: Pick<WorldState, "systems">): CommunityState { return state.systems.community as CommunityState ?? defaults(); }
35 export function getCommunityContent(rules: SimRules): CommunityContent | undefined { return rules.content?.community as CommunityContent | undefined; }
36 function save(state: WorldState, community: CommunityState): WorldState { return { ...state, revision: state.revision + 1, systems: { ...state.systems, community } }; }
37 const fail = (state: WorldState, error: string): IntentResult => ({ state, events: [], error });
38 const levels: Record<Quality, number> = { regular: 0, silver: 1, gold: 2, iridium: 3 };
39 function present(player: Player, location: { areaId: string; x: number; y: number }): boolean { return player.areaId === location.areaId && inToolRange(player, location); }
40 export function isWarpRepaired(state: Pick<WorldState, "systems">, warpId: string): boolean { return getCommunity(state).repairedWarps.includes(warpId); }
41 export function isGreenhouseTile(state: Pick<WorldState, "systems">, areaId: string, x: number, y: number, rules: SimRules): boolean {
42 const greenhouse = getCommunityContent(rules)?.greenhouse;
43 return !!greenhouse && getCommunity(state).greenhouseRestored && areaId === greenhouse.areaId && x >= greenhouse.x && x < greenhouse.x + greenhouse.width && y >= greenhouse.y && y < greenhouse.y + greenhouse.height;
44 }
45 function withCounters(value: CommunityState, data: CommunityContent): CommunityState {
46 const total = (category: CollectionCategory) => Object.values(value.collections[category]).reduce((sum, count) => sum + count, 0);
47 const unique = (category: CollectionCategory) => Object.values(value.collections[category]).filter(count => count > 0).length;
48 const counters: Record<string, number> = { ...value.counters, shippedUnique: unique("shipped"), shippedTotal: total("shipped"), singleShipment: Math.max(0, ...Object.values(value.collections.shipped)), fishUnique: unique("fish"), fishTotal: total("fish"), cookingUnique: unique("cooking"), craftingUnique: unique("crafting"), museumUnique: Object.keys(value.museum.donated).length, bundlesCompleted: Object.values(value.bundles).filter(bundle => bundle.completed).length, roomsCompleted: value.completedRooms.length };
49 return { ...value, counters, achievements: [...new Set([...value.achievements, ...data.achievements.filter(entry => (counters[entry.counter] ?? 0) >= entry.target).map(entry => entry.id)])] };
50 }
51 function finish(state: WorldState, value: CommunityState, rules: SimRules, events: SimEvent[] = []): IntentResult {
52 const data = getCommunityContent(rules), next = data ? withCounters(value, data) : value;
53 for (const achievementId of next.achievements) if (!getCommunity(state).achievements.includes(achievementId)) events.push({ type: "communityAchievement", data: { achievementId }, checkpoint: true });
54 return { state: save(state, next), events };
55 }
56 /** Server-only integration hook. Never exposed as a client intent; call after a verified catch/recipe completion. */
57 export function recordCollection(state: WorldState, category: CollectionCategory, id: string, count: number, rules: SimRules): WorldState {
58 const data = getCommunityContent(rules);
59 if (!data || !id || !Number.isSafeInteger(count) || count <= 0 || category === "museum") return state;
60 const item = getItem(rules, id);
61 if (category === "fish" && item?.category !== "fish" || category === "shipped" && !item) return state;
62 const value = getCommunity(state), collection = value.collections[category];
63 return finish(state, { ...value, collections: { ...value.collections, [category]: { ...collection, [id]: (collection[id] ?? 0) + count } } }, rules).state;
64 }
65 function queueReward(value: CommunityState, rewardId: string, reward: CommunityReward): CommunityState {
66 return value.rewards[rewardId] ? value : { ...value, rewards: { ...value.rewards, [rewardId]: { items: reward.items.map(item => ({ ...item })), money: reward.money, claimedBy: null } } };
67 }
68 function completeRooms(state: WorldState, value: CommunityState, data: CommunityContent, events: SimEvent[]): { state: WorldState; value: CommunityState } {
69 let next = value;
70 for (const room of data.rooms) {
71 if (next.completedRooms.includes(room.id) || !room.bundleIds.every(id => next.bundles[id]?.completed)) continue;
72 const repairs = data.repairs.filter(repair => room.repairIds.includes(repair.id));
73 next = { ...next, completedRooms: [...next.completedRooms, room.id], repairedWarps: [...new Set([...next.repairedWarps, ...repairs.flatMap(repair => repair.warpIds)])], greenhouseRestored: next.greenhouseRestored || repairs.some(repair => repair.kind === "greenhouse") };
74 events.push({ type: "communityRoomCompleted", data: { roomId: room.id, repairIds: room.repairIds }, checkpoint: true });
75 }
76 return { state: projectRepairs(state, next, data), value: next };
77 }
78 /** Project durable repairs into map metadata so rendering, forage and travel agree. */
79 function projectRepairs(state: WorldState, value: CommunityState, data: CommunityContent): WorldState {
80 const unlockedAreas = new Set(Object.values(state.areas).flatMap(area => area.warps.filter(warp => value.repairedWarps.includes(warp.id)).map(warp => warp.targetAreaId)));
81 let changed = false; const areas = { ...state.areas };
82 for (const [areaId, original] of Object.entries(state.areas)) {
83 let area = original;
84 if (area.locked && unlockedAreas.has(areaId)) area = { ...area, locked: false };
85 if (area.warps.some(warp => warp.locked && value.repairedWarps.includes(warp.id))) area = { ...area, warps: area.warps.map(warp => warp.locked && value.repairedWarps.includes(warp.id) ? { ...warp, locked: false, label: "Open" } : warp) };
86 const soil = data.greenhouse;
87 if (value.greenhouseRestored && areaId === soil.areaId) {
88 let tiles = area.tiles;
89 for (let y = soil.y; y < soil.y + soil.height; y++) for (let x = soil.x; x < soil.x + soil.width; x++) {
90 const index = y * area.width + x, tile = tiles[index];
91 if (tile && !tile.tillable && !area.collision[index]) { if (tiles === area.tiles) tiles = area.tiles.slice(); tiles[index] = { ...tile, tillable: true }; }
92 }
93 if (tiles !== area.tiles) area = { ...area, tiles };
94 }
95 if (area !== original) { changed = true; areas[areaId] = area; }
96 }
97 return changed ? { ...state, areas } : state;
98 }
99 function museumAccess(state: WorldState, player: Player, data: CommunityContent): boolean {
100 return present(player, data.museum.location) && state.clock.minuteOfDay >= data.museum.openMinute && state.clock.minuteOfDay < data.museum.closeMinute;
101 }
102 function hydrate(value: CommunityState, data: CommunityContent): CommunityState {
103 return value.museum.display.length >= data.museum.displaySlots ? value : { ...value, museum: { ...value.museum, display: [...value.museum.display, ...Array<null>(data.museum.displaySlots - value.museum.display.length).fill(null)] } };
104 }
105 /** Shipping owns its ledger; this observer copies cumulative counts and records each payout day exactly once. */
106 export function synchronizeCommunity(state: WorldState, rules: SimRules): WorldState {
107 const data = getCommunityContent(rules); if (!data) return state;
108 const old = getCommunity(state); let value = hydrate(old, data);
109 const shipped = { ...value.collections.shipped };
110 for (const [id, count] of Object.entries(getShipping(state).shipped)) shipped[id] = Math.max(count, shipped[id] ?? 0);
111 value = { ...value, collections: { ...value.collections, shipped } };
112 const summaries = Object.values(state.players).map(player => getPlayerShipping(player).summary).filter(summary => !!summary);
113 const current = summaries.find(summary => summary.day === calendarDay(state));
114 if (current && current.day !== value.lastShippingDay) value = { ...value, lastShippingDay: current.day, counters: { ...value.counters, earnedMoney: (value.counters.earnedMoney ?? 0) + current.total } };
115 value = withCounters(value, data);
116 const projected = projectRepairs(state, value, data);
117 return JSON.stringify(old) === JSON.stringify(value) && projected === state ? state : save(projected, value);
118 }
119
120 function pickMuseumItem(data: CommunityContent, source: "artifactSpot" | "mines" | "fishingTreasure", rng: ReturnType<typeof createRng>): string | undefined {
121 const entries = data.museum.donations.filter(entry => entry.sources.includes(source)), weight = entries.reduce((sum, entry) => sum + entry.weight, 0);
122 if (!entries.length) return undefined;
123 let roll = rng.next() * weight;
124 return (entries.find(entry => (roll -= entry.weight) < 0) ?? entries.at(-1))?.itemId;
125 }
126 /** Trusted producer hook: mine nodes/treasure handlers decide when a find is earned, then call this once. */
127 export function awardMuseumFind(state: WorldState, playerId: string, source: "mines" | "fishingTreasure", rules: SimRules): IntentResult {
128 const data = getCommunityContent(rules), player = state.players[playerId];
129 if (!data || !player || source !== "mines" && source !== "fishingTreasure") return fail(state, "Museum find is unavailable");
130 const rng = createRng(state.rngState), id = pickMuseumItem(data, source, rng);
131 if (!id) return fail(state, "No museum loot configured");
132 const added = addItem(player, { id, quantity: 1, quality: "regular" }, rules);
133 if (added.remaining) return fail(state, "Inventory is full");
134 return { state: { ...state, revision: state.revision + 1, rngState: rng.state, players: { ...state.players, [playerId]: added.player } }, events: [{ type: "museumFind", playerId, data: { itemId: id, source } }] };
135 }
136 export function spawnArtifactSpots(state: WorldState, rules: SimRules): WorldState {
137 const data = getCommunityContent(rules); if (!data) return state;
138 let value = hydrate(getCommunity(state), data);
139 const day = calendarDay(state); if (value.lastArtifactDay === day) return state;
140 const rng = createRng(state.rngState), spots = value.artifactSpots.slice(); let nextId = value.nextSpotId;
141 for (const areaId of data.artifactSpots.areaIds) {
142 const area = state.areas[areaId]; if (!area || !area.outdoor || area.locked && !Object.values(state.areas).some(source => source.warps.some(warp => warp.targetAreaId === area.id && value.repairedWarps.includes(warp.id)))) continue;
143 for (let attempt = 0; attempt < data.artifactSpots.attempts && spots.filter(spot => spot.areaId === areaId).length < data.artifactSpots.maximumPerArea; attempt++) {
144 if (rng.next() >= data.artifactSpots.spawnChance) continue;
145 const candidates: number[] = [];
146 for (let index = 0; index < area.tiles.length; index++) {
147 const tile = area.tiles[index]!, x = index % area.width, y = Math.floor(index / area.width);
148 if (area.collision[index] || !tile.tillable || tile.tilled || tile.crop || tile.objectId || isGreenhouseTile(state, areaId, x, y, rules)) continue;
149 if (spots.some(spot => spot.areaId === areaId && spot.x === x && spot.y === y) || area.warps.some(warp => x >= warp.x && x < warp.x + warp.width && y >= warp.y && y < warp.y + warp.height) || area.landmarks.some(home => x >= home.x && x < home.x + home.width && y >= home.y && y < home.y + home.height)) continue;
150 if (Object.values(state.players).some(player => player.areaId === areaId && Math.hypot(player.x - x, player.y - y) < 1)) continue;
151 candidates.push(index);
152 }
153 if (!candidates.length) break;
154 const index = candidates[rng.nextInt(candidates.length)]!, itemId = pickMuseumItem(data, "artifactSpot", rng);
155 if (itemId && getItem(rules, itemId)) spots.push({ id: `artifact_spot_${nextId++}`, areaId, x: index % area.width, y: Math.floor(index / area.width), itemId });
156 }
157 }
158 value = { ...value, artifactSpots: spots, nextSpotId: nextId, lastArtifactDay: day };
159 return save({ ...state, rngState: rng.state }, value);
160 }
161 const slot = z.number().int().min(0).max(35);
162 const displaySlot = z.number().int().min(0).max(10000);
163 export const communitySystem = defineSystem({
164 id: "community", worldDefaults: () => ({ ...defaults() }), playerDefaults: () => ({}),
165 events: ["bundleDonated", "bundleCompleted", "communityRoomCompleted", "communityRewardClaimed", "museumDonated", "museumDisplayMoved", "artifactSpotDug", "museumFind", "communityAchievement"],
166 warpUnlocked: (state: WorldState, _rules: SimRules, warp: { id: string }) => isWarpRepaired(state, warp.id),
167 allowsOutOfSeasonCrop: (state: WorldState, rules: SimRules, areaId: string, x: number, y: number) => isGreenhouseTile(state, areaId, x, y, rules),
168 intents: [
169 defineIntent("bundleDonate", z.object({ type: z.literal("bundleDonate"), bundleId: z.string().min(1), requirementId: z.string().min(1), slot, quantity: z.number().int().min(1).max(999) }), ({ state, player, playerId, rules }, intent) => {
170 const data = getCommunityContent(rules), bundle = data?.bundles.find(entry => entry.id === intent.bundleId), requirement = bundle?.requirements.find(entry => entry.id === intent.requirementId);
171 if (!data || !bundle || !requirement) return fail(state, "Unknown bundle requirement");
172 if (!present(player, data.restoration)) return fail(state, "Restoration building is out of reach");
173 const value = getCommunity(state), progress = value.bundles[bundle.id] ?? { donated: {}, completed: false };
174 if (progress.completed) return fail(state, "Bundle is already complete");
175 const supplied = progress.donated[requirement.id] ?? 0, active = Object.values(progress.donated).filter(count => count > 0).length;
176 if (supplied === 0 && active >= bundle.choose) return fail(state, "Finish the selected requirements first");
177 if (intent.quantity > requirement.count - supplied) return fail(state, "Donation exceeds the remaining requirement");
178 const taken = takeSlot(player, intent.slot, intent.quantity, rules), item = taken && getItem(rules, taken.stack.id);
179 if (!taken || !item || (requirement.itemId ? requirement.itemId !== item.id : !item.tags.includes(requirement.tag!)) || levels[taken.stack.quality] < levels[requirement.minQuality]) return fail(state, "Item or quality does not match the requirement");
180 const donated = { ...progress.donated, [requirement.id]: supplied + intent.quantity }, completed = bundle.requirements.filter(entry => (donated[entry.id] ?? 0) >= entry.count).length >= bundle.choose;
181 let next: CommunityState = { ...value, bundles: { ...value.bundles, [bundle.id]: { donated, completed } } };
182 const events: SimEvent[] = [{ type: "bundleDonated", playerId, data: { bundleId: bundle.id, requirementId: requirement.id, quantity: intent.quantity }, checkpoint: true }];
183 if (completed) { next = queueReward(next, `bundle:${bundle.id}`, bundle.reward); events.push({ type: "bundleCompleted", data: { bundleId: bundle.id }, checkpoint: true }); }
184 const changed = completeRooms({ ...state, players: { ...state.players, [playerId]: taken.player } }, next, data, events);
185 return finish(changed.state, changed.value, rules, events);
186 }),
187 defineIntent("communityClaimReward", z.object({ type: z.literal("communityClaimReward"), rewardId: z.string().min(1) }), ({ state, player, playerId, rules }, intent) => {
188 const data = getCommunityContent(rules), value = getCommunity(state), reward = value.rewards[intent.rewardId];
189 if (!data || !reward || reward.claimedBy !== null) return fail(state, "Reward is unavailable");
190 const museumReward = intent.rewardId.startsWith("museum:");
191 if (museumReward ? !museumAccess(state, player, data) : !present(player, data.restoration)) return fail(state, "Reward station is unavailable or out of reach");
192 let awarded = player;
193 for (const stack of reward.items) { const added = addItem(awarded, stack, rules); if (added.remaining) return fail(state, "Inventory is full"); awarded = added.player; }
194 const paid = creditMoney(state, playerId, reward.money), next = { ...value, rewards: { ...value.rewards, [intent.rewardId]: { ...reward, claimedBy: playerId } }, museum: museumReward ? { ...value.museum, claimedMilestones: [...value.museum.claimedMilestones, Number(intent.rewardId.slice(7))] } : value.museum };
195 return finish({ ...paid, players: { ...paid.players, [playerId]: awarded } }, next, rules, [{ type: "communityRewardClaimed", playerId, data: { rewardId: intent.rewardId }, checkpoint: true }]);
196 }),
197 defineIntent("museumDonate", z.object({ type: z.literal("museumDonate"), slot, displaySlot }), ({ state, player, playerId, rules }, intent) => {
198 const data = getCommunityContent(rules); if (!data || !museumAccess(state, player, data)) return fail(state, "Museum is closed or out of reach");
199 const value = hydrate(getCommunity(state), data), stack = getInventory(player, rules).slots[intent.slot];
200 if (!stack || !data.museum.donations.some(entry => entry.itemId === stack.id)) return fail(state, "Item is not a museum donation");
201 if (value.museum.donated[stack.id]) return fail(state, "Item was already donated");
202 if (intent.displaySlot >= data.museum.displaySlots || value.museum.display[intent.displaySlot]) return fail(state, "Display space is unavailable");
203 const taken = takeSlot(player, intent.slot, 1, rules)!;
204 const display = value.museum.display.slice(); display[intent.displaySlot] = stack.id;
205 let next: CommunityState = { ...value, museum: { ...value.museum, display, donated: { ...value.museum.donated, [stack.id]: { playerId, day: calendarDay(state) } } }, collections: { ...value.collections, museum: { ...value.collections.museum, [stack.id]: 1 } } };
206 const count = Object.keys(next.museum.donated).length;
207 for (const milestone of data.museum.milestones) if (count >= milestone.count) next = queueReward(next, `museum:${milestone.count}`, milestone.reward);
208 return finish({ ...state, players: { ...state.players, [playerId]: taken.player } }, next, rules, [{ type: "museumDonated", playerId, data: { itemId: stack.id, displaySlot: intent.displaySlot }, checkpoint: true }]);
209 }),
210 defineIntent("museumMoveDisplay", z.object({ type: z.literal("museumMoveDisplay"), fromSlot: displaySlot, toSlot: displaySlot }), ({ state, player, rules }, intent) => {
211 const data = getCommunityContent(rules); if (!data || !museumAccess(state, player, data)) return fail(state, "Museum is closed or out of reach");
212 const value = hydrate(getCommunity(state), data);
213 if (intent.fromSlot >= data.museum.displaySlots || intent.toSlot >= data.museum.displaySlots || !value.museum.display[intent.fromSlot] || intent.fromSlot === intent.toSlot) return fail(state, "Invalid display move");
214 const display = value.museum.display.slice(); [display[intent.fromSlot], display[intent.toSlot]] = [display[intent.toSlot]!, display[intent.fromSlot]!];
215 return finish(state, { ...value, museum: { ...value.museum, display } }, rules, [{ type: "museumDisplayMoved", data: { fromSlot: intent.fromSlot, toSlot: intent.toSlot }, checkpoint: true }]);
216 }),
217 defineIntent("digArtifactSpot", z.object({ type: z.literal("digArtifactSpot"), spotId: z.string().min(1) }), ({ state, player, playerId, rules }, intent) => {
218 const value = getCommunity(state), spot = value.artifactSpots.find(entry => entry.id === intent.spotId);
219 if (!spot || player.areaId !== spot.areaId || !inToolRange(player, spot)) return fail(state, "Artifact spot is out of reach");
220 if (!countItem(player, "tool_hoe", rules) || !toolAvailable(player, "hoe", rules)) return fail(state, "An available hoe is required");
221 const area = state.areas[spot.areaId]!, index = spot.y * area.width + spot.x, tile = area.tiles[index], cost = toolEnergyCost(player, "hoe", rules, true);
222 if (!tile || !tile.tillable || area.collision[index] || tile.crop || tile.objectId) return fail(state, "Artifact spot is blocked");
223 if (player.energy < cost) return fail(state, "Not enough energy");
224 const added = addItem(player, { id: spot.itemId, quality: "regular", quantity: 1 }, rules);
225 if (added.remaining) return fail(state, "Inventory is full");
226 const tiles = area.tiles.slice(); tiles[index] = { ...tile, tilled: true };
227 return finish({ ...state, players: { ...state.players, [playerId]: { ...added.player, energy: player.energy - cost } }, areas: { ...state.areas, [area.id]: { ...area, tiles } } }, { ...value, artifactSpots: value.artifactSpots.filter(entry => entry.id !== spot.id), counters: { ...value.counters, artifactSpotsDug: (value.counters.artifactSpotsDug ?? 0) + 1 } }, rules, [{ type: "artifactSpotDug", playerId, areaId: player.areaId, data: { spotId: spot.id, itemId: spot.itemId } }]);
228 }),
229 ],
230 onPlayerAdded: ({ state, rules }) => ({ state: spawnArtifactSpots(synchronizeCommunity(state, rules), rules), events: [] }),
231 onPresenceChange: ({ state, rules }) => ({ state: spawnArtifactSpots(synchronizeCommunity(state, rules), rules), events: [] }),
232 onOvernight: ({ state, rules }) => ({ state: spawnArtifactSpots(synchronizeCommunity(state, rules), rules), events: [] }),
233 onClockTick: ({ state, rules }) => ({ state: synchronizeCommunity(state, rules), events: [] }),
234 });
235