Exact source used by this build. SHA-256: 8448e7976fea4764cea9ccef0e032341f3b64fb62709a7d895b58f31e39c0ce7.
1 import { z } from "zod";
2 import type { Player, PointOfInterest, SimEvent, WorldState } from "../../types";
3 import { withPlayer } from "../../state";
4 import { chargePassOut } from "../wallet";
5 import { defineIntent, defineSystem } from "../registry";
6 export const MAX_ENERGY = 270;
7 export interface SleepReadiness { playerId: string; name: string; sleeping: boolean }
8 export function getSleepReadiness(state: WorldState): SleepReadiness[] {
9 return (state.systems.energy as { sleepReadiness?: SleepReadiness[] } | undefined)?.sleepReadiness ?? [];
10 }
11 /** Shared presence is deliberately tiny, so players in other areas can see sleep requests. */
12 export function refreshSleepReadiness(state: WorldState): WorldState {
13 const sleepReadiness = Object.values(state.players).filter((player) => player.connected)
14 .map(({ id, name, sleeping }) => ({ playerId: id, name, sleeping })).sort((a, b) => a.playerId.localeCompare(b.playerId));
15 if (JSON.stringify(sleepReadiness) === JSON.stringify(getSleepReadiness(state))) return state;
16 return { ...state, revision: state.revision + 1, systems: { ...state.systems,
17 energy: { ...state.systems.energy as object | undefined, sleepReadiness } } };
18 }
19 /** Current players share the starter home; NPC/inn beds are not player-owned beds. */
20 export function sleepBed(state: WorldState, player: Player): PointOfInterest | undefined {
21 if (player.areaId !== state.starterPlayer.areaId) return undefined;
22 const area = state.areas[player.areaId];
23 return area?.pois?.find((poi) => poi.kind === "bed" && area.landmarks.some((home) => home.kind === "home"
24 && poi.x >= home.x && poi.y >= home.y && poi.x + poi.width <= home.x + home.width && poi.y + poi.height <= home.y + home.height
25 && player.x >= home.x && player.y >= home.y && player.x < home.x + home.width && player.y < home.y + home.height)
26 && Math.hypot(player.x - Math.max(poi.x, Math.min(player.x, poi.x + poi.width)), player.y - Math.max(poi.y, Math.min(player.y, poi.y + poi.height))) <= 1);
27 }
28 export function spendToolEnergy(player: Player): Player | null { return player.energy < 2 ? null : { ...player, energy: player.energy - 2 }; }
29 export function passOut(state: WorldState, playerIds: readonly string[]): WorldState {
30 const players = { ...state.players };
31 for (const id of playerIds) {
32 const player = players[id]; if (!player) continue;
33 players[id] = { ...player, areaId: state.starterPlayer.areaId, x: state.starterPlayer.x, y: state.starterPlayer.y, energy: Math.floor(player.maxEnergy / 2), sleeping: false, sleepConfirmedAt: null, passedOut: true };
34 }
35 return { ...chargePassOut(state, playerIds), revision: state.revision + 1, players };
36 }
37 export const energySystem = defineSystem({
38 id: "energy", worldDefaults: () => ({ sleepReadiness: [] }), playerDefaults: () => ({}), events: ["sleep", "passout"],
39 initializePlayer: () => ({ energy: MAX_ENERGY, maxEnergy: MAX_ENERGY, sleeping: false, sleepConfirmedAt: null, passedOut: false }),
40 intents: [defineIntent("sleep", z.object({ type: z.literal("sleep") }), ({ state, player, playerId, overnight }) => {
41 if (player.sleeping) return { state, events: [], error: "Player is already sleeping" };
42 if (!sleepBed(state, player)) return { state, events: [], error: "Return to your bed to sleep" };
43 const sleeping = refreshSleepReadiness(withPlayer(state, { ...player, sleeping: true, sleepConfirmedAt: state.clock.minuteOfDay }));
44 const events: SimEvent[] = [{ type: "sleep", playerId, message: `${player.name} wants to sleep` }];
45 const active = Object.values(sleeping.players).filter((candidate) => candidate.connected);
46 return active.length > 0 && active.every((candidate) => candidate.sleeping) ? overnight(sleeping, events) : { state: sleeping, events };
47 }, { allowSleeping: true })],
48 onPlayerAdded: ({ state }) => ({ state: refreshSleepReadiness(state), events: [] }),
49 onPresenceChange: ({ state, playerId, connected, overnight }) => {
50 const player = playerId ? state.players[playerId] : undefined;
51 if (connected === false && player) state = withPlayer(state, { ...player, sleeping: false, sleepConfirmedAt: null });
52 state = refreshSleepReadiness(state);
53 const active = Object.values(state.players).filter((player) => player.connected);
54 return connected === false && active.length > 0 && active.every((player) => player.sleeping) ? overnight(state) : { state, events: [] };
55 },
56 onClockTick: ({ state, overnight }) => {
57 if (state.clock.minuteOfDay < 1560) return { state, events: [] };
58 const awake = Object.values(state.players).filter((player) => player.connected && !player.sleeping).map((player) => player.id);
59 return overnight(awake.length ? passOut(state, awake) : state, awake.map((playerId) => ({ type: "passout", playerId })), true);
60 },
61 onOvernight: ({ state }) => {
62 const players = Object.fromEntries(Object.entries(state.players).map(([id, player]) => {
63 if (!player.connected) return [id, player];
64 const confirmed = player.sleepConfirmedAt;
65 const restored = player.passedOut ? player.energy : (confirmed === null || confirmed <= 1440 ? player.maxEnergy : Math.round(player.maxEnergy * (1 - 0.5 * ((confirmed - 1440) / 120))));
66 return [id, { ...player, energy: Math.max(0, Math.min(player.maxEnergy, restored)), sleeping: false, sleepConfirmedAt: null, passedOut: false }];
67 }));
68 return { state: refreshSleepReadiness({ ...state, revision: state.revision + 1, players }), events: [] };
69 },
70 });
71