Exact source used by this build. SHA-256: 1bc90d7aa7edffca141f6013283fe96017f64f929f56b5777d836b15a4391c4f.
1 import { z } from "zod";
2 import type { IntentResult, Player, SimEvent, SimRules, TilePosition, Warp, Weather, WorldConfig, WorldState } from "../types";
3
4 export interface IntentPolicy {
5 /** Transient input may be checkpointed at the server’s bounded persistence interval. */
6 persistence?: "immediate" | "batched";
7 /** Independent, server-maintained elapsed-time budget; only accepted inputs consume it. */
8 timing?: string;
9 /** Initial credit absorbs ordinary network jitter without granting credit per packet. */
10 timingBufferMs?: number;
11 allowPaused?: boolean;
12 allowSleeping?: boolean;
13 }
14 export interface SystemContext {
15 state: WorldState;
16 rules: SimRules;
17 playerId?: string;
18 elapsedMs?: number;
19 preserveRemainder?: boolean;
20 connected?: boolean;
21 weatherOverride?: Weather;
22 positionError?(player: Player, x: number, y: number): string | undefined;
23 isWarpUnlocked?(warp: Warp): boolean;
24 allowsOutOfSeasonCrop?(areaId: string, x: number, y: number): boolean;
25 overnight(state: WorldState, events?: SimEvent[], preserveRemainder?: boolean): IntentResult;
26 }
27 export interface IntentContext extends SystemContext {
28 playerId: string; player: Player;
29 useItemTool?: (context: IntentContext, use: ItemToolUse) => IntentResult | undefined;
30 movementSpeed?: number;
31 }
32 export interface ItemToolUse { itemId: string; tile: TilePosition; targetId?: string }
33 export interface RegisteredIntent<S extends z.ZodType<{ type: string }> = z.ZodType<{ type: string }>> {
34 schema: S;
35 type: string;
36 policy: IntentPolicy;
37 run(context: IntentContext, value: unknown): IntentResult;
38 }
39 /** Handler inputs are inferred from their own schema, independent of the global union. */
40 export function defineIntent<S extends z.ZodType<{ type: string }>>(type: z.infer<S>["type"], schema: S, handle: (context: IntentContext, intent: z.infer<S>) => IntentResult, policy: IntentPolicy = {}): RegisteredIntent<S> {
41 return { type, schema, policy, run: (context, value) => handle(context, schema.parse(value)) };
42 }
43 export type HookName = "onOvernight" | "onClockTick" | "onElapsedTime" | "onPlayerAdded" | "onPresenceChange" | "onOfflineElapsed";
44 export interface SimSystem {
45 id: string;
46 worldDefaults: () => Record<string, unknown>;
47 playerDefaults: () => Record<string, unknown>;
48 intents: readonly RegisteredIntent[];
49 events: readonly string[];
50 /** Compatibility fields for the original P0 API. New systems use their own systems[id] slice. */
51 initializeWorld?: (config: WorldConfig) => Partial<WorldState>;
52 initializePlayer?: (state: WorldState, player: { id: string; name: string; areaId: string }) => Partial<Player>;
53 onOvernight?: (context: SystemContext) => IntentResult;
54 onClockTick?: (context: SystemContext) => IntentResult;
55 /** Real elapsed time while the world runs, split at calendar tick boundaries. */
56 onElapsedTime?: (context: SystemContext) => IntentResult;
57 /** Request a faster authoritative update cadence only while this system is active. */
58 updateIntervalMs?: (state: WorldState, rules: SimRules) => number | undefined;
59 onPlayerAdded?: (context: SystemContext) => IntentResult;
60 onPresenceChange?: (context: SystemContext) => IntentResult;
61 /** Server-supplied elapsed time while nobody is connected; the calendar stays frozen. */
62 onOfflineElapsed?: (context: SystemContext) => IntentResult;
63 /** Feature-owned item tools and contextual uses of existing tools. */
64 onItemTool?: (context: IntentContext, use: ItemToolUse) => IntentResult | undefined;
65 movementSpeedMultiplier?: (state: WorldState, player: Player, rules: SimRules) => number;
66 weatherOverride?: (state: WorldState, rules: SimRules) => Weather | undefined;
67 pausesClock?: (state: WorldState, rules: SimRules) => boolean;
68 /** Time to the next event that can end a calendar freeze, for exact elapsed splitting. */
69 clockPauseRemainingMs?: (state: WorldState, rules: SimRules) => number | undefined;
70 guardIntent?: (context: IntentContext, intent: { type: string; [key: string]: unknown }) => string | undefined;
71 blocksPosition?: (context: { state: WorldState; rules: SimRules; player: Player }, x: number, y: number) => string | undefined;
72 warpUnlocked?: (state: WorldState, rules: SimRules, warp: Warp) => boolean;
73 allowsOutOfSeasonCrop?: (state: WorldState, rules: SimRules, areaId: string, x: number, y: number) => boolean;
74 }
75 export function defineSystem<const S extends SimSystem>(system: S): S { return system; }
76 export type RegistryIntent<T extends readonly SimSystem[]> = z.infer<T[number]["intents"][number]["schema"]>;
77
78 /** Recursively backfill newly added JSON fields while retaining saved values and unknown keys. */
79 function mergeDefaults(defaults: Record<string, unknown>, saved: Record<string, unknown> | undefined): Record<string, unknown> {
80 const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
81 return Object.fromEntries(Object.entries({ ...defaults, ...saved }).map(([key, value]) => [key,
82 isObject(defaults[key]) && isObject(value) ? mergeDefaults(defaults[key], value) : value]));
83 }
84 export function createSystemRegistry<const T extends readonly SimSystem[]>(systems: T) {
85 function getMovementSpeed(state: WorldState, player: Player, rules: SimRules): number {
86 return systems.reduce((speed, system) => {
87 const multiplier = system.movementSpeedMultiplier?.(state, player, rules) ?? 1;
88 return Number.isFinite(multiplier) && multiplier > 0 ? speed * multiplier : speed;
89 }, 4);
90 }
91 function useItemTool(context: IntentContext, use: ItemToolUse): IntentResult | undefined {
92 for (const system of systems) {
93 const result = system.onItemTool?.(context, use);
94 if (result) return result;
95 }
96 return undefined;
97 }
98 const handlers = new Map<string, RegisteredIntent>();
99 const ids = new Set<string>();
100 for (const system of systems) {
101 if (ids.has(system.id)) throw new Error(`Duplicate system: ${system.id}`);
102 ids.add(system.id);
103 for (const intent of system.intents) {
104 if (handlers.has(intent.type)) throw new Error(`Duplicate intent: ${intent.type}`);
105 handlers.set(intent.type, intent);
106 }
107 }
108 const schemas = [...handlers.values()].map((entry) => entry.schema);
109 // z.union needs a tuple; runtime validation and the inferred registry union share the same schemas.
110 const intentSchema = (schemas.length ? z.union(schemas as [z.ZodType<{ type: string }>, ...z.ZodType<{ type: string }>[]]) : z.never()) as z.ZodType<RegistryIntent<T>>;
111 function capabilities(state: WorldState, rules: SimRules) {
112 return {
113 weatherOverride: systems.map((system) => system.weatherOverride?.(state, rules)).find((weather) => weather !== undefined),
114 positionError: (player: Player, x: number, y: number) => {
115 for (const system of systems) { const error = system.blocksPosition?.({ state, rules, player }, x, y); if (error) return error; }
116 return undefined;
117 },
118 isWarpUnlocked: (warp: Warp) => systems.some((system) => system.warpUnlocked?.(state, rules, warp) === true),
119 allowsOutOfSeasonCrop: (areaId: string, x: number, y: number) => systems.some((system) => system.allowsOutOfSeasonCrop?.(state, rules, areaId, x, y) === true),
120 };
121 }
122 function runHook(name: HookName, state: WorldState, rules: SimRules = { crops: {} }, options: Pick<SystemContext, "playerId" | "connected" | "preserveRemainder" | "elapsedMs"> = {}): IntentResult {
123 let next = state;
124 const events: SimEvent[] = [];
125 for (const system of systems) {
126 const hook = system[name];
127 if (!hook) continue;
128 const result = hook({ ...options, ...capabilities(next, rules), state: next, rules, overnight: (value, supplied = [], preserveRemainder = false) => {
129 const night = runHook("onOvernight", value, rules, { preserveRemainder });
130 return { ...night, events: [...supplied, ...night.events] };
131 } });
132 next = result.state; events.push(...result.events);
133 if (result.error) return { state: next, events, error: result.error };
134 }
135 return { state: next, events };
136 }
137 function dispatch(state: WorldState, playerId: string, input: RegistryIntent<T>, options: { elapsedMs?: number; rules?: SimRules } = {}): IntentResult {
138 const player = state.players[playerId];
139 if (!player) return { state, events: [], error: "Unknown player" };
140 if (!player.connected) return { state, events: [], error: "Player is not connected" };
141 const parsed = intentSchema.safeParse(input);
142 if (!parsed.success) return { state, events: [], error: "Invalid intent" };
143 const entry = handlers.get(parsed.data.type);
144 if (!entry) return { state, events: [], error: "Unknown intent" };
145 if (state.paused && !entry.policy.allowPaused) return { state, events: [], error: "World is paused" };
146 if (player.sleeping && !entry.policy.allowSleeping) return { state, events: [], error: "Player is sleeping" };
147 const rules = options.rules ?? { crops: {} };
148 const context: IntentContext = { ...capabilities(state, rules), state, rules, playerId, player, elapsedMs: options.elapsedMs, useItemTool, movementSpeed: getMovementSpeed(state, player, rules), overnight: (value, supplied = [], preserveRemainder = false) => {
149 const night = runHook("onOvernight", value, rules, { preserveRemainder });
150 return { ...night, events: [...supplied, ...night.events] };
151 } };
152 for (const system of systems) {
153 const error = system.guardIntent?.(context, parsed.data);
154 if (error) return { state, events: [], error };
155 }
156 return entry.run(context, parsed.data);
157 }
158 return {
159 systems, intentSchema, dispatch, runHook, getMovementSpeed,
160 getUpdateIntervalMs: (state: WorldState, rules: SimRules, fallbackMs: number): number => {
161 if (state.paused || !Object.values(state.players).some((player) => player.connected)) return fallbackMs;
162 return systems.reduce((interval, system) => {
163 const requested = system.updateIntervalMs?.(state, rules);
164 return requested !== undefined && Number.isFinite(requested) && requested > 0 ? Math.min(interval, Math.max(100, requested)) : interval;
165 }, fallbackMs);
166 },
167 getClockPauseRemainingMs: (state: WorldState, rules: SimRules): number => systems.reduce((remaining, system) => {
168 const requested = system.clockPauseRemainingMs?.(state, rules);
169 return requested !== undefined && Number.isFinite(requested) && requested > 0 ? Math.min(remaining, requested) : remaining;
170 }, Infinity),
171 pausesClock: (state: WorldState, rules: SimRules) => systems.some((system) => system.pausesClock?.(state, rules) === true),
172 getIntentPolicy: (type: string): IntentPolicy | undefined => handlers.get(type)?.policy,
173 worldDefaults: () => Object.fromEntries(systems.map((system) => [system.id, system.worldDefaults()])),
174 playerDefaults: () => Object.fromEntries(systems.map((system) => [system.id, system.playerDefaults()])),
175 initializeState: (state: WorldState): WorldState => ({
176 ...state,
177 systems: mergeDefaults(Object.fromEntries(systems.map((system) => [system.id, system.worldDefaults()])), state.systems),
178 players: Object.fromEntries(Object.entries(state.players).map(([id, player]) => [id, { ...player,
179 systems: mergeDefaults(Object.fromEntries(systems.map((system) => [system.id, system.playerDefaults()])), player.systems),
180 }])),
181 }),
182 initializeWorld: (config: WorldConfig): Partial<WorldState> => Object.assign({}, ...systems.map((system) => system.initializeWorld?.(config) ?? {})),
183 initializePlayer: (state: WorldState, player: { id: string; name: string; areaId: string }): Partial<Player> => Object.assign({}, ...systems.map((system) => system.initializePlayer?.(state, player) ?? {})),
184 };
185 }
186 export function shouldCheckpoint(events: readonly SimEvent[]): boolean { return events.some((event) => event.checkpoint === true); }
187