Exact source used by this build. SHA-256: 987249593398687638cad64b6f7d5701904593cae49b10178b481555db60d9ba.

1 import { z } from "zod";
2 import { createRng } from "../../rng";
3 import { inToolRange, withPlayer } from "../../state";
4 import type { IntentResult, Player, SimRules, WorldState } from "../../types";
5 import { addItem, countItem, getInventory, removeAreaItems, pickupNearby, takeSlot } from "../inventory";
6 import { defineIntent, defineSystem } from "../registry";
7 import { FacingSchema } from "../schema";
8 import { grantXp } from "../skills";
9 import { toolAvailable, toolEnergyCost, toolTier } from "../tools";
10 import { attackMine, lineOfSight, rollMineDrops, simulateMineFloor } from "./combat";
11 import { generateMineFloor, ladderChance, mineFloorSeed } from "./generator";
12 import { entrancePosition, getMineContent, getMineEquipmentEffects, getMineFloor, getMinePlayer, getMines, maxMineHealth, mineAreaId, mineDay, minePlayerDefaults, mineWorldDefaults, setMineFloor, setMinePlayer, setMines, type MinesContent } from "./model";
13 export * from "./model";
14 export * from "./generator";
15 export { attackMine, inAttackArc, mineKnockout } from "./combat";
16 const reject = (state: WorldState, error: string): IntentResult => ({ state, events: [], error });
17 function initializeMinePlayer(player: Player, data: MinesContent, rules: SimRules): Player {
18   const mine = getMinePlayer(player), maxHealth = maxMineHealth(player, data, rules);
19   if (mine.starterReceived && mine.maxHealth === maxHealth) return player;
20   const weapon = data.weapons.find((entry) => entry.id === data.config.starterWeaponId)!;
21   return setMinePlayer(player, { ...mine, maxHealth, health: mine.starterReceived ? Math.min(maxHealth, mine.health + Math.max(0, maxHealth - mine.maxHealth)) : maxHealth, equipment: mine.starterReceived ? mine.equipment : { ...mine.equipment, weapon: weapon.itemId }, starterReceived: true });
22 }
23 function initializeMines(state: WorldState, rules: SimRules): WorldState {
24   const mine = getMines(state); if (mine.worldSeed === null) state = setMines(state, { ...mine, worldSeed: state.rngState });
25   const data = getMineContent(rules); if (!data) return state;
26   for (const player of Object.values(state.players)) { const updated = initializeMinePlayer(player, data, rules); if (updated !== player) state = withPlayer(state, updated); }
27   return state;
28 }
29 /** Ephemeral content is removed as soon as its final connected visitor leaves. */
30 export function retireEmptyMineFloors(state: WorldState): WorldState {
31   let next = state;
32   for (const area of Object.values(state.areas)) {
33     if (!area.systems.mines || Object.values(next.players).some((player) => player.connected && player.areaId === area.id)) continue;
34     const areas = { ...next.areas }; delete areas[area.id];
35     next = { ...removeAreaItems(next, area.id), revision: next.revision + 1, areas };
36   }
37   return next;
38 }
39 function enterFloor(state: WorldState, player: Player, number: number, rules: SimRules): IntentResult {
40   const data = getMineContent(rules)!;
41   let next = initializeMines(state, rules), area = next.areas[mineAreaId(number)];
42   if (!area || getMineFloor(next, number)?.day !== mineDay(next)) { area = generateMineFloor(next, number, data); next = { ...next, revision: next.revision + 1, areas: { ...next.areas, [area.id]: area } }; }
43   const mines = getMines(next), mine = getMinePlayer(next.players[player.id]!);
44   const unlocked = number % data.config.elevatorEvery === 0 && !mines.elevatorFloors.includes(number);
45   if (unlocked) next = setMines(next, { ...mines, elevatorFloors: [...mines.elevatorFloors, number].sort((a, b) => a - b) });
46   const moved = setMinePlayer({ ...next.players[player.id]!, areaId: area.id, x: area.spawn.x, y: area.spawn.y }, { ...mine, invulnerableUntilMs: mines.timeMs + data.config.descentInvulnerabilityMs, blockUntilMs: 0 });
47   next = retireEmptyMineFloors(withPlayer(next, moved));
48   return { state: next, events: [{ type: "mineEntered", playerId: player.id, areaId: area.id, snapshot: true, data: { floor: number } }, ...(unlocked ? [{ type: "mineElevatorUnlocked", data: { floor: number } }] : [])] };
49 }
50 function equip(state: WorldState, player: Player, rules: SimRules, slot: "weapon" | "ring1" | "ring2" | "boots", inventorySlot: number | undefined): IntentResult {
51   const data = getMineContent(rules); if (!data) return reject(state, "Mines are unavailable");
52   const mine = getMinePlayer(player), equipment = { ...mine.equipment, rings: [...mine.equipment.rings] as [string | null, string | null] }, ringIndex = slot === "ring1" ? 0 : 1;
53   const old = slot === "ring1" || slot === "ring2" ? equipment.rings[ringIndex] : equipment[slot];
54   let updated = player, selected: string | null = null;
55   if (inventorySlot !== undefined) {
56     const stack = getInventory(player, rules).slots[inventorySlot];
57     const definitions = slot === "weapon" ? data.weapons : slot === "boots" ? data.boots : data.rings;
58     if (!stack || !definitions.some((entry) => entry.itemId === stack.id)) return reject(state, "Item does not fit that equipment slot");
59     const taken = takeSlot(player, inventorySlot, 1, rules)!; updated = taken.player; selected = stack.id;
60   }
61   if (old) { const returned = addItem(updated, { id: old, quantity: 1, quality: "regular" }, rules); if (returned.remaining) return reject(state, "Make room in the backpack first"); updated = returned.player; }
62   if (slot === "ring1" || slot === "ring2") equipment.rings[ringIndex] = selected; else equipment[slot] = selected;
63   updated = setMinePlayer(updated, { ...mine, equipment });
64   return { state: withPlayer(state, updated), events: [{ type: "mineEquipment", playerId: player.id }] };
65 }
66 export function mineDailyLuck(state: WorldState): number { return createRng(mineFloorSeed(getMines(state).worldSeed ?? state.rngState, 0, mineDay(state))).next() * .2 - .1; }
67 export const minesSystem = defineSystem({
68   id: "mines", worldDefaults: () => ({ ...mineWorldDefaults() }), playerDefaults: () => ({ ...minePlayerDefaults() }),
69   events: ["mineEntered", "mineLeft", "mineRockHit", "mineLadder", "mineSwing", "mineDamage", "mineDefeated", "mineKnockout", "mineBlocked", "mineChestOpened", "mineEquipment", "mineElevatorUnlocked", "mineGathered"],
70   updateIntervalMs: (state: WorldState) => !state.paused && Object.values(state.players).some((player) => player.connected && !player.sleeping && !!getMineFloor(state, player.areaId)) ? 250 : undefined,
71   intents: [
72     defineIntent("mineEnter", z.object({ type: z.literal("mineEnter"), floor: z.number().int().min(0).max(120).optional() }), ({ state, player, rules }, intent) => {
73       const data = getMineContent(rules); if (!data) return reject(state, "Mines are unavailable");
74       const entrance = entrancePosition(state, data);
75       if (player.areaId !== entrance.areaId || Math.hypot(player.x - entrance.x, player.y - entrance.y) > data.config.entrance.range) return reject(state, "Mine entrance is out of reach");
76       const floor = intent.floor ?? 1;
77       if (floor < 1 || floor > data.config.maxFloor || floor !== 1 && !getMines(state).elevatorFloors.includes(floor)) return reject(state, "Elevator floor is locked");
78       return enterFloor(state, player, floor, rules);
79     }),
80     defineIntent("mineDescend", z.object({ type: z.literal("mineDescend") }), ({ state, player, rules }) => {
81       const data = getMineContent(rules), floor = getMineFloor(state, player.areaId);
82       if (!data || !floor?.ladder || !inToolRange(player, floor.ladder)) return reject(state, "Ladder is out of reach");
83       if (floor.number >= data.config.maxFloor) return reject(state, "You have reached the bottom");
84       return enterFloor(state, player, floor.number + 1, rules);
85     }),
86     defineIntent("mineLeave", z.object({ type: z.literal("mineLeave") }), ({ state, player, rules }) => {
87       const data = getMineContent(rules), floor = getMineFloor(state, player.areaId), area = state.areas[player.areaId];
88       if (!data || !floor || !area || !inToolRange(player, area.spawn)) return reject(state, "Return to this floor's entrance to leave");
89       const entrance = entrancePosition(state, data), destination = state.areas[entrance.areaId];
90       if (!destination) return reject(state, "Mine entrance is unavailable");
91       return { state: retireEmptyMineFloors(withPlayer(state, { ...player, ...entrance })), events: [{ type: "mineLeft", playerId: player.id, areaId: entrance.areaId, snapshot: true }] };
92     }),
93     defineIntent("mineRock", z.object({ type: z.literal("mineRock"), rockId: z.string().min(1) }), ({ state, player, rules }, intent) => {
94       const data = getMineContent(rules), floor = getMineFloor(state, player.areaId), area = state.areas[player.areaId], rock = floor?.rocks[intent.rockId];
95       if (!data || !floor || !area || !rock || !inToolRange(player, rock) || !lineOfSight(area, player, rock, true)) return reject(state, "Rock is out of reach");
96       if (countItem(player, "tool_pickaxe", rules) < 1 || !toolAvailable(player, "pickaxe", rules)) return reject(state, "Pickaxe is unavailable");
97       const mine = getMinePlayer(player), now = getMines(state).timeMs;
98       if (mine.miningReadyAtMs > now) return reject(state, "Pickaxe is recovering");
99       const tier = toolTier(player, "pickaxe", rules), node = data.nodes.find((entry) => entry.id === rock.nodeId)!;
100       if (tier < node.minTier) return reject(state, "Pickaxe tier is too low");
101       const cost = toolEnergyCost(player, "pickaxe", rules, true);
102       if (player.energy < cost) return reject(state, "Not enough energy");
103       const hits = rock.hits + 1, broken = hits >= node.hitsByTier[tier]!, rocks = { ...floor.rocks }, tiles = area.tiles.slice(), rng = createRng(floor.rngState);
104       let ladder = floor.ladder, updated = setMinePlayer({ ...player, energy: player.energy - cost }, { ...mine, miningReadyAtMs: now + data.config.miningSwingMs });
105       let next = state;
106       if (broken) {
107         delete rocks[rock.id]; tiles[rock.y * area.width + rock.x] = { ...tiles[rock.y * area.width + rock.x]!, objectId: null };
108         let coalXp = 0; next = rollMineDrops(next, area.id, rock, node.drops, rng, (itemId) => { if (itemId === "coal") coalXp += data.config.coalMiningXp; }); updated = grantXp(updated, "mining", node.xp + coalXp, rules);
109         if (!ladder && floor.kind !== "infested" && floor.number < data.config.maxFloor && (Object.keys(rocks).length === 0 || rng.next() < ladderChance(data, Object.keys(rocks).length, Object.keys(floor.monsters).length, mineDailyLuck(state)))) ladder = { x: rock.x, y: rock.y };
110       } else rocks[rock.id] = { ...rock, hits };
111       next = setMineFloor(withPlayer(next, updated), { ...area, tiles }, { ...floor, rocks, ladder, rngState: rng.state });
112       return { state: next, events: [{ type: "mineRockHit", playerId: player.id, areaId: area.id, transient: !broken, data: { rockId: rock.id, hits, broken } }, ...(ladder && !floor.ladder ? [{ type: "mineLadder", areaId: area.id, data: { ...ladder } }] : [])] };
113     }, { persistence: "batched" }),
114     defineIntent("mineAttack", z.object({ type: z.literal("mineAttack"), facing: FacingSchema, special: z.boolean().optional() }), ({ state, player, rules }, intent) => attackMine(state, player, rules, intent.facing, intent.special ?? false), { persistence: "batched" }),
115     defineIntent("mineEquip", z.object({ type: z.literal("mineEquip"), slot: z.enum(["weapon", "ring1", "ring2", "boots"]), inventorySlot: z.number().int().min(0).max(35).optional() }), ({ state, player, rules }, intent) => equip(state, player, rules, intent.slot, intent.inventorySlot)),
116     defineIntent("mineChest", z.object({ type: z.literal("mineChest") }), ({ state, player, rules }) => {
117       const data = getMineContent(rules), floor = getMineFloor(state, player.areaId), area = state.areas[player.areaId];
118       if (!data || !floor?.chest || !area || !inToolRange(player, floor.chest)) return reject(state, "Treasure chest is out of reach");
119       if (floor.chest.opened || getMines(state).claimedTreasureFloors.includes(floor.number)) return reject(state, "Treasure chest is empty");
120       let updated = player;
121       for (const item of data.treasure.find((entry) => entry.floor === floor.number)?.items ?? []) { const added = addItem(updated, { id: item.itemId, quantity: item.quantity, quality: "regular" }, rules); if (added.remaining) return reject(state, "Make room in the backpack first"); updated = added.player; }
122       let next = setMineFloor(withPlayer(state, updated), area, { ...floor, chest: { ...floor.chest, opened: true } });
123       next = setMines(next, { ...getMines(next), claimedTreasureFloors: [...getMines(next).claimedTreasureFloors, floor.number] });
124       return { state: next, events: [{ type: "mineChestOpened", playerId: player.id, areaId: area.id, data: { floor: floor.number } }] };
125     }),
126     defineIntent("mineGather", z.object({ type: z.literal("mineGather"), id: z.string().min(1) }), ({ state, player, rules }, intent) => {
127       const floor = getMineFloor(state, player.areaId), area = state.areas[player.areaId], mushroom = floor?.mushrooms[intent.id];
128       if (!floor || !area || !mushroom || !inToolRange(player, mushroom)) return reject(state, "Item is out of reach");
129       const added = addItem(player, { id: mushroom.itemId, quantity: 1, quality: "regular" }, rules); if (added.remaining) return reject(state, "Inventory is full");
130       const mushrooms = { ...floor.mushrooms }; delete mushrooms[intent.id];
131       return { state: setMineFloor(withPlayer(state, added.player), area, { ...floor, mushrooms }), events: [{ type: "mineGathered", playerId: player.id, areaId: area.id }] };
132     }),
133   ],
134   onPlayerAdded: ({ state, rules }) => ({ state: initializeMines(state, rules), events: [] }),
135   onPresenceChange: ({ state, rules, playerId, connected }) => {
136     let next = initializeMines(state, rules); const player = playerId ? next.players[playerId] : undefined, data = getMineContent(rules);
137     // Disconnected characters never keep an instance alive; returning visitors resume at the entrance.
138     if (player && data && (getMineFloor(next, player.areaId) || player.areaId.startsWith("mine_floor_"))) {
139       if (!connected || !next.areas[player.areaId]) next = withPlayer(next, { ...player, ...entrancePosition(next, data) });
140     }
141     return { state: retireEmptyMineFloors(next), events: [] };
142   },
143   onElapsedTime: ({ state, rules, elapsedMs }) => {
144     const data = getMineContent(rules); if (!data || state.paused || !elapsedMs || elapsedMs <= 0 || state.clock.minuteOfDay >= 1560) return { state, events: [] };
145     let next = retireEmptyMineFloors(initializeMines(state, rules));
146     const occupied = [...new Set(Object.values(next.players).filter((player) => player.connected && !player.sleeping && getMineFloor(next, player.areaId)).map((player) => player.areaId))];
147     if (!occupied.length) return { state: next, events: [] };
148     const mine = getMines(next), startMs = mine.timeMs; next = setMines(next, { ...mine, timeMs: startMs + elapsedMs });
149     const events = [] as IntentResult["events"];
150     for (const areaId of occupied) { const result = simulateMineFloor(next, areaId, rules, elapsedMs, startMs); next = result.state; events.push(...result.events); }
151     for (const player of Object.values(next.players)) if (player.connected && getMineFloor(next, player.areaId)) { const result = pickupNearby(next, player.id, rules, getMineEquipmentEffects(player, rules).magnet); next = result.state; events.push(...result.events); }
152     return { state: retireEmptyMineFloors(next), events };
153   },
154   onOvernight: ({ state, rules }) => {
155     const data = getMineContent(rules); if (!data) return { state, events: [] };
156     let next = state;
157     for (const original of Object.values(next.players)) {
158       let player = initializeMinePlayer(original, data, rules), mine = getMinePlayer(player);
159       if (player.connected) {
160         if (getMineFloor(next, player.areaId)) player = { ...player, areaId: next.starterPlayer.areaId, x: next.starterPlayer.x, y: next.starterPlayer.y };
161         // Energy's overnight hook already applies the shared before/after-midnight restoration ratio.
162         mine = { ...mine, health: Math.max(mine.health, Math.round(mine.maxHealth * player.energy / player.maxEnergy)), invulnerableUntilMs: 0, blockUntilMs: 0 };
163       }
164       const remaining = [] as typeof mine.returnedTools;
165       for (const stack of mine.returnedTools) { const added = addItem(player, stack, rules); player = added.player; if (added.remaining) remaining.push({ ...stack, quantity: added.remaining }); }
166       mine = { ...mine, returnedTools: remaining }; next = withPlayer(next, setMinePlayer(player, mine));
167     }
168     return { state: retireEmptyMineFloors(next), events: [] };
169   },
170 });
171