Exact source used by this build. SHA-256: ecd6fde87c7e525f4ad9b2eea39216301bd5647ad1db6ba43cfce0d3e57838c2.
1 import json from "../../../data/mines/mines.json";
2 import { ITEMS, validateItemReferences } from "../items";
3 import { AREA_MAPS, isMapBlocked, type AreaMap } from "../maps";
4 import { SHOPS } from "../shops";
5 import type { ContentDomain } from "../types";
6 import { MinesSchema } from "./schema";
7
8 export * from "./schema";
9 const MinesSourceSchema = MinesSchema.extend({ config: MinesSchema.shape.config.extend({
10 entrance: MinesSchema.shape.config.shape.entrance.omit({ x: true, y: true }),
11 }) });
12
13 /** Resolve the entrance from its authored POI/warp on every content load. */
14 export function loadMinesContent(input: unknown, maps: Readonly<Record<string, AreaMap>> = AREA_MAPS) {
15 const source = MinesSourceSchema.parse(input), entrance = source.config.entrance;
16 const area = maps[entrance.areaId];
17 if (!area) throw new Error("Mine entrance references missing area");
18 const anchor = [...area.warps, ...area.pois, ...area.markers].find((entry) => entry.id === entrance.warpId);
19 if (!anchor) throw new Error("Mine entrance references missing warp or marker/POI");
20 const position = { x: anchor.x + anchor.width / 2, y: anchor.y + anchor.height / 2 };
21 if (isMapBlocked(area, position.x, position.y)) throw new Error("Mine entrance must be walkable");
22 return MinesSchema.parse({ ...source, config: { ...source.config, entrance: { ...entrance, ...position } } });
23 }
24 export const MINES = loadMinesContent(json);
25
26 /** Validate producers, equipment, floor coverage and movable entrance references together. */
27 export function validateMines(input: unknown, items: readonly { id: string }[] = ITEMS): void {
28 const data = MinesSchema.parse(input), config = data.config;
29 const unique = (ids: readonly string[], kind: string): Set<string> => {
30 const result = new Set(ids);
31 if (result.size !== ids.length) throw new Error(`Duplicate mine ${kind} id`);
32 return result;
33 };
34 const nodes = unique(data.nodes.map((entry) => entry.id), "node");
35 const monsters = unique(data.monsters.map((entry) => entry.id), "monster");
36 const weapons = unique(data.weapons.map((entry) => entry.id), "weapon");
37 unique(data.bands.map((entry) => entry.id), "band");
38 unique(data.rings.map((entry) => entry.id), "ring");
39 unique(data.boots.map((entry) => entry.id), "boots");
40 unique(data.minerals.map((entry) => entry.id), "mineral");
41 unique(data.geodes.map((entry) => entry.itemId), "geode");
42 unique(data.treasure.map((entry) => String(entry.floor)), "treasure floor");
43 if (!weapons.has(config.starterWeaponId)) throw new Error("Unknown starter weapon");
44 if (config.mvpContentFloors > config.maxFloor) throw new Error("MVP floor count exceeds mine depth");
45 if (config.rooms.min > config.rooms.max || config.rooms.minSize > config.rooms.maxSize || config.rooms.maxSize + 2 > Math.min(config.width, config.height)) throw new Error("Mine room bounds are invalid");
46 if (config.knockout.maxGoldRate < config.knockout.minGoldRate) throw new Error("Knockout loss range is inverted");
47 if (Math.abs(Object.values(config.nodeWeights).reduce((sum, value) => sum + value, 0) - 1) > 0.000001) throw new Error("Mine node weights must sum to one");
48 if (data.mushrooms.max < data.mushrooms.min) throw new Error("Mushroom count range is inverted");
49 for (let floor = 1; floor <= config.maxFloor; floor++) {
50 if (data.bands.filter((band) => floor >= band.minFloor && floor <= band.maxFloor).length !== 1) throw new Error(`Mine floor ${floor} must belong to exactly one band`);
51 if (floor % config.treasureEvery === 0 && !data.treasure.some((entry) => entry.floor === floor)) throw new Error(`Mine floor ${floor} has no treasure reward`);
52 }
53 for (const band of data.bands) {
54 if (band.maxFloor > config.maxFloor || band.minFloor > band.maxFloor) throw new Error("Mine band bounds are invalid");
55 for (const id of [band.rockNodeId, band.oreNodeId, band.geodeNodeId, ...band.gemNodeIds]) if (!nodes.has(id)) throw new Error(`Unknown mine node ${id}`);
56 for (const id of band.monsterIds) if (!monsters.has(id)) throw new Error(`Unknown mine monster ${id}`);
57 for (const [id, kind] of [[band.rockNodeId, "rock"], [band.oreNodeId, "ore"], [band.geodeNodeId, "geode"], ...band.gemNodeIds.map((id) => [id, "gem"])] as [string, string][]) {
58 if (data.nodes.find((node) => node.id === id)?.kind !== kind) throw new Error(`Mine band node ${id} has wrong kind`);
59 }
60 }
61 for (const node of data.nodes) if (node.hitsByTier.some((hits, index) => index > 0 && hits > node.hitsByTier[index - 1]!)) throw new Error(`Mine node ${node.id} becomes harder with upgrades`);
62 for (const weapon of data.weapons) {
63 const expected = { sword: "block", dagger: "flurry", club: "slam" }[weapon.type];
64 if (weapon.special.kind !== expected) throw new Error(`Mine weapon ${weapon.id} has wrong special move`);
65 }
66 for (const geode of data.geodes) for (const drop of geode.drops) if (drop.max < drop.min) throw new Error("Geode drop range is inverted");
67 for (const chest of data.treasure) if (chest.floor > config.maxFloor || chest.floor % config.treasureEvery !== 0) throw new Error("Treasure must be on a reward floor");
68 validateItemReferences([
69 config.mushroomItemId,
70 ...data.nodes.flatMap((node) => node.drops.map((drop) => drop.itemId)),
71 ...data.monsters.flatMap((monster) => monster.drops.map((drop) => drop.itemId)),
72 ...data.weapons.map((weapon) => weapon.itemId), ...data.rings.map((ring) => ring.itemId),
73 ...data.boots.map((boots) => boots.itemId), ...data.minerals.map((mineral) => mineral.itemId),
74 ...data.geodes.flatMap((geode) => [geode.itemId, ...geode.drops.map((drop) => drop.itemId)]),
75 ...data.treasure.flatMap((chest) => chest.items.map((item) => item.itemId)), ...data.mushrooms.itemIds,
76 ], items);
77 const area = AREA_MAPS[config.entrance.areaId];
78 if (!area) throw new Error("Mine entrance references missing area");
79 const resolved = loadMinesContent(data).config.entrance;
80 if (resolved.x !== config.entrance.x || resolved.y !== config.entrance.y) throw new Error("Mine entrance coordinates disagree with map metadata");
81 if (!SHOPS.some((shop) => shop.geodeCracking)) throw new Error("Mines require a geode-cracking shop");
82 }
83
84 export const minesDomain: ContentDomain = { id: "mines", data: MINES, validate() { validateMines(MINES); } };
85 minesDomain.validate();
86