@netforge/template-dungeon 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@netforge/template-dungeon",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "CRYPTWICK: Template 3 of the NetNet game runtime, a co-op dungeon crawl for 4 players: procedural rooms, loot and permadeath runs. The game module, its browser client and its playtest bots.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/manifest.ts",
9
+ "./store": "./src/store.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "client",
14
+ "!client/vite.config.ts",
15
+ "llms.txt",
16
+ "README.md",
17
+ "NOTICE",
18
+ "LICENSE"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/netnetdev/net-forge.git",
26
+ "directory": "packages/template-dungeon"
27
+ },
28
+ "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-dungeon#readme",
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "dependencies": {
33
+ "nipplejs": "1.0.4",
34
+ "three": "0.186.1",
35
+ "@netforge/asset-library": "0.1.0",
36
+ "@netforge/engine": "0.1.0",
37
+ "@netforge/engine-sim": "0.1.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "22.20.1",
41
+ "@types/three": "0.186.0",
42
+ "tsx": "4.23.0",
43
+ "typescript": "5.8.3",
44
+ "vite": "6.4.3",
45
+ "vitest": "3.2.7"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/netnetdev/net-forge/issues"
49
+ },
50
+ "scripts": {
51
+ "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
52
+ "typecheck": "tsc -p tsconfig.json",
53
+ "test": "vitest run",
54
+ "dev": "vite --config client/vite.config.ts",
55
+ "preview": "vite preview --config client/vite.config.ts",
56
+ "bots": "tsx scripts/load-bots.ts",
57
+ "fun": "tsx scripts/fun.ts",
58
+ "bench": "tsx scripts/bench.ts"
59
+ }
6
60
  }
package/src/game.ts ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * CRYPTWICK: the co-op dungeon crawl template.
3
+ *
4
+ * Four wickbearers go down into a candle-lit crypt, room by room. Rooms
5
+ * wake as you near them: their dead stir and come for your light through
6
+ * the doors. Clear a room for its reliquary (pick an upgrade) and to bring
7
+ * back anyone who fell. Down the deepest stair the Wax Abbot waits. A run
8
+ * is one descent: if the whole party falls, it is over, and the next one
9
+ * starts from the top with nothing carried down.
10
+ *
11
+ * This file is the whole game as the model writes it: declarations only,
12
+ * against the typed API in @netforge/engine. The crypt's floor plan is
13
+ * dungeon(...) from a seed; the crawl (which rooms wake, what they hold,
14
+ * when the party is safe) is the "crawl" system below. Everything else
15
+ * (sim, pathing, netcode, renderer, referee) is the template's machinery.
16
+ * Design rules: packages/engine/GAME-DESIGN.md.
17
+ */
18
+ import { achievement, dungeon, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
19
+
20
+ // The crypt: ten rooms on a 24 m grid, grown from the seed. Change the seed for a new floor plan.
21
+ const CRYPT = dungeon({ seed: 2026, rooms: 10, cell: 24, size: [12, 18], corridor: 6, loops: 2 });
22
+
23
+ export default game({
24
+ template: "dungeon",
25
+ style: "lowpoly",
26
+ places: { crypt: { chunks: "places/crypt", spawn: [0, 1, 0] } },
27
+ field: CRYPT.field,
28
+ look: { preset: "night", bloom: 1.2, fog: 0.5, vignette: 0.5, tint: "#ffd9a0" },
29
+ players: {
30
+ prefab: "style.lowpoly.rogue",
31
+ max: 4,
32
+ weapon: "candle_burst",
33
+ xp: { base: 2, growth: 2, exponent: 2.4 },
34
+ magnet: 4,
35
+ hitIframes: 0.7,
36
+ share: "nearby",
37
+ crit: { chance: 0.08, multiplier: 2 },
38
+ reviveBuff: { seconds: 5, damage: 1.3, speed: 1.15 },
39
+ bossHealthPerPlayer: 0.45,
40
+ // Permadeath runs: a fallen wickbearer bleeds for 12 s (crawl to a friend), with no free get-up.
41
+ // Out, they stay out until the party clears a room. If everyone falls, the descent is over.
42
+ downed: { seconds: 12, crawl: 0.35, selfRevives: 0, respawn: 0 },
43
+ },
44
+ });
45
+
46
+ // The crypt's dead. Health in hit points, speed in m/s (wickbearers run at 7), damage per touch.
47
+ // Fodder sometimes leaves tallow (heal), a lure stone (pulls every shard) or a chapel bell (clears the room).
48
+ const LUCK = [{ id: "tallow", chance: 0.03 }, { id: "lure_stone", chance: 0.012 }, { id: "chapel_bell", chance: 0.01 }];
49
+ prefab("bone_rattler", { model: "style.lowpoly.skeleton_grunt", health: 22, speed: 3.6, radius: 0.4, damage: 7, ai: "chase", drops: ["wick_small", ...LUCK] });
50
+ prefab("crypt_bat", { model: "style.lowpoly.bat", appearance: { tint: "#6a5a8c" }, health: 9, speed: 5.4, radius: 0.3, damage: 3, ai: "runner", drops: ["wick_small", ...LUCK] });
51
+ prefab("grave_shambler", { model: "style.lowpoly.zombie_grunt", health: 55, speed: 2.4, radius: 0.5, damage: 11, ai: "tank", mass: 3, drops: ["wick_mid"] });
52
+ prefab("bone_archer", { model: "style.lowpoly.skeleton", appearance: { tint: "#d8cfb8" }, health: 24, speed: 2.8, radius: 0.4, damage: 5, ai: "ranged", attack: { damage: 9, range: 10, speed: 7 }, drops: ["wick_small"] });
53
+ prefab("wax_wisp", { model: "style.lowpoly.ghost_skull", appearance: { tint: "#ffcf7a" }, health: 18, speed: 3.4, radius: 0.4, damage: 8, ai: "bomber", attack: { radius: 2.5, damage: 26 }, drops: ["wick_small"] });
54
+ prefab("tomb_slime", { model: "style.lowpoly.slime_horned", health: 44, speed: 2.8, radius: 0.5, damage: 8, ai: "chase", split: { into: "crypt_bat", count: 3 }, drops: ["wick_small"] });
55
+ prefab("hex_imp", { model: "style.lowpoly.imp", health: 45, speed: 2.6, radius: 0.4, damage: 6, ai: "summoner", attack: { summon: "crypt_bat", count: 2 }, drops: ["wick_mid"] });
56
+ prefab("candle_mender", { model: "style.lowpoly.blob_wizard", health: 30, speed: 3, radius: 0.4, damage: 6, ai: "healer", drops: ["wick_mid"] });
57
+ prefab("ossuary_brute", { model: "style.lowpoly.orc_brute", appearance: { tint: "#8a8f7a" }, health: 170, speed: 2.2, radius: 0.8, damage: 18, ai: "tank", mass: 6, drops: ["wick_mid", "wick_mid"] });
58
+ prefab("grave_ram", { model: "style.lowpoly.demon_blue", health: 60, speed: 3, radius: 0.5, damage: 12, ai: "charger", drops: ["wick_mid"] });
59
+
60
+ // The Marrow Knight guards the middle of the crypt: a mini-boss with two telegraphed attacks and a hoard.
61
+ prefab("marrow_knight", {
62
+ model: "style.lowpoly.monarch",
63
+ appearance: { tint: "#cfc6b0", accent: "#ff9a3d", scale: 1.5 },
64
+ health: 420,
65
+ speed: 2.6,
66
+ radius: 1.2,
67
+ damage: 16,
68
+ boss: { attacks: ["slam", "dash"], rank: "mini" },
69
+ drops: ["wick_large"],
70
+ });
71
+ // The Wax Abbot waits in the deepest room: slams, rings of wax, a charge, and the dead called to his side.
72
+ prefab("wax_abbot", {
73
+ model: "style.lowpoly.demon",
74
+ appearance: { tint: "#efe2c4", accent: "#ff8a3d" },
75
+ health: 2800,
76
+ speed: 2.4,
77
+ radius: 1.5,
78
+ damage: 26,
79
+ boss: { attacks: ["slam", "ring", "dash", "summon"], summon: "bone_rattler", enrageAt: 0.5 },
80
+ drops: ["wick_large"],
81
+ });
82
+
83
+ // Props: broken columns and bone heaps to break for shards and luck, scattered over the floors at the start.
84
+ prefab("crypt_urn", { model: "style.lowpoly.column_broken", appearance: { tint: "#8f8578" }, health: 25, speed: 0, radius: 0.5, damage: 0, prop: true, drops: ["wick_small", { id: "tallow", chance: 0.15 }, { id: "lure_stone", chance: 0.04 }] });
85
+ prefab("bone_pile", { model: "style.lowpoly.rock_small", appearance: { tint: "#cdbf9e" }, health: 50, speed: 0, radius: 0.6, damage: 0, prop: true, drops: ["wick_mid", { id: "tallow", chance: 0.25 }, { id: "reliquary", chance: 0.08 }] });
86
+
87
+ // Special pickups. A cleared room leaves a reliquary at its heart; bosses leave the hoard.
88
+ prefab("tallow", { model: "style.lowpoly.cell_heal", appearance: { tint: "#ffe2a8" }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "heal", amount: 0.3 } });
89
+ prefab("lure_stone", { model: "style.lowpoly.crystal", health: 1, speed: 0, kind: "pickup", pickup: { effect: "vacuum" } });
90
+ prefab("chapel_bell", { model: "style.lowpoly.grenade", health: 1, speed: 0, kind: "pickup", pickup: { effect: "bomb" } });
91
+ prefab("reliquary", { model: "style.lowpoly.chest", health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 1 } });
92
+ prefab("abbey_hoard", { model: "style.lowpoly.chest", appearance: { tint: "#ffcf5a", scale: 1.6 }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 3, evolve: true } });
93
+
94
+ // Shards (pickups): the XP each is worth.
95
+ prefab("wick_small", { model: "style.lowpoly.gem_small", health: 1, speed: 0, kind: "pickup", value: 1 });
96
+ prefab("wick_mid", { model: "style.lowpoly.gem_mid", health: 1, speed: 0, kind: "pickup", value: 6 });
97
+ prefab("wick_large", { model: "style.lowpoly.gem_large", health: 1, speed: 0, kind: "pickup", value: 60 });
98
+
99
+ // Weapons fire by themselves. Cooldowns in seconds, ranges in metres. Each pairs with a passive to evolve.
100
+ weapon("candle_burst", {
101
+ kind: "nova", damage: 13, cooldown: 1.1, range: 3.8, knockback: 2.5,
102
+ levels: [{ damage: "+30%" }, { range: "+15%" }, { cooldown: "-15%" }, { damage: "+30%" }, { range: "+15%" }, { damage: "+25%", knockback: 3 }],
103
+ evolve: { into: "chapel_blaze", requires: "bellows" },
104
+ });
105
+ weapon("wax_dart", {
106
+ kind: "bolt", damage: 14, cooldown: 0.6, range: 13, speed: 24, pierce: 1, knockback: 2,
107
+ levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { pierce: 1 }, { count: 1 }, { damage: "+40%" }],
108
+ evolve: { into: "wax_volley", requires: "twin_wick" },
109
+ });
110
+ weapon("censer_chain", {
111
+ kind: "orbit", damage: 8, cooldown: 0.1, range: 2.4, count: 2, speed: 3.6, knockback: 3,
112
+ levels: [{ count: 1 }, { damage: "+30%" }, { range: "+20%" }, { count: 1 }, { speed: "+25%" }, { damage: "+40%" }],
113
+ evolve: { into: "thurible_halo", requires: "stained_glass" },
114
+ });
115
+ weapon("bone_spear", {
116
+ kind: "bolt", damage: 30, cooldown: 1.6, range: 18, speed: 34, pierce: 6, knockback: 4,
117
+ levels: [{ damage: "+30%" }, { pierce: 3 }, { cooldown: "-20%" }, { count: 1 }, { damage: "+40%" }],
118
+ evolve: { into: "relic_lance", requires: "tallow_oil" },
119
+ });
120
+ weapon("sexton_scythe", {
121
+ kind: "cone", damage: 18, cooldown: 1.1, range: 4, spread: 100, knockback: 6,
122
+ levels: [{ damage: "+30%" }, { spread: 40 }, { range: "+20%" }, { cooldown: "-20%" }, { damage: "+40%" }],
123
+ evolve: { into: "reaper_wheel", requires: "soft_soles" },
124
+ });
125
+ weapon("vesper_arc", {
126
+ kind: "chain", damage: 16, cooldown: 1.3, range: 9, count: 2, speed: 4, knockback: 1,
127
+ levels: [{ count: 1 }, { damage: "+35%" }, { count: 1 }, { cooldown: "-20%" }, { count: 2 }],
128
+ evolve: { into: "matins_storm", requires: "lodestone" },
129
+ });
130
+ weapon("ember_halo", {
131
+ kind: "aura", damage: 5, cooldown: 0.5, range: 2.5, knockback: 1,
132
+ levels: [{ range: "+20%" }, { damage: "+40%" }, { range: "+20%" }, { cooldown: "-20%" }, { damage: "+40%" }],
133
+ evolve: { into: "pyre", requires: "warded_heart" },
134
+ });
135
+ weapon("falling_wax", {
136
+ kind: "meteor", damage: 40, cooldown: 2.2, range: 11, count: 1, blast: 1.8, knockback: 6,
137
+ levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { count: 1 }, { damage: "+40%" }],
138
+ });
139
+
140
+ // Evolutions: about x1.7 damage and a new shape. Never offered on their own.
141
+ weapon("chapel_blaze", { kind: "nova", damage: 40, cooldown: 0.8, range: 8, knockback: 9 });
142
+ weapon("wax_volley", { kind: "bolt", damage: 34, cooldown: 0.4, range: 15, speed: 28, count: 6, pierce: 3, knockback: 3 });
143
+ weapon("thurible_halo", { kind: "orbit", damage: 20, cooldown: 0.1, range: 3.6, count: 6, speed: 4.5, knockback: 4 });
144
+ weapon("relic_lance", { kind: "bolt", damage: 80, cooldown: 1, range: 22, speed: 40, count: 3, pierce: 30, knockback: 6 });
145
+ weapon("reaper_wheel", { kind: "cone", damage: 45, cooldown: 0.8, range: 5.5, spread: 360, knockback: 8 });
146
+ weapon("matins_storm", { kind: "chain", damage: 34, cooldown: 0.9, range: 11, count: 9, speed: 5, knockback: 2 });
147
+ weapon("pyre", { kind: "aura", damage: 12, cooldown: 0.4, range: 4.5, knockback: 2 });
148
+
149
+ // Passive upgrades offered at level-up next to new weapons and weapon levels. Each evolution pairs with one.
150
+ upgrade("soft_soles", { stat: "speed", step: 0.07, maxLevel: 5 });
151
+ upgrade("warded_heart", { stat: "maxHealth", step: 20, maxLevel: 5 });
152
+ upgrade("lodestone", { stat: "magnet", step: 0.8, maxLevel: 5 });
153
+ upgrade("tallow_oil", { stat: "might", step: 0.1, maxLevel: 5 });
154
+ upgrade("bellows", { stat: "haste", step: 0.06, maxLevel: 5 });
155
+ upgrade("hymnal", { stat: "regen", step: 1, maxLevel: 3 });
156
+ upgrade("stained_glass", { stat: "area", step: 0.1, maxLevel: 5 });
157
+ upgrade("twin_wick", { stat: "amount", step: 1, maxLevel: 2 });
158
+
159
+ /**
160
+ * What a woken room holds, by its depth (doors from the start): [prefab, count] for one
161
+ * wickbearer. Each extra wickbearer adds 35% more. Deeper rooms repeat the last row.
162
+ */
163
+ const ENCOUNTERS: readonly (readonly (readonly [string, number])[])[] = [
164
+ [["bone_rattler", 7], ["crypt_bat", 5]],
165
+ [["bone_rattler", 9], ["bone_archer", 3], ["grave_shambler", 2]],
166
+ [["grave_shambler", 3], ["crypt_bat", 9], ["wax_wisp", 4], ["bone_archer", 3]],
167
+ [["tomb_slime", 3], ["hex_imp", 2], ["bone_rattler", 10], ["grave_ram", 2], ["candle_mender", 1]],
168
+ [["ossuary_brute", 1], ["bone_archer", 5], ["wax_wisp", 5], ["grave_ram", 3], ["candle_mender", 2], ["bone_rattler", 10]],
169
+ ];
170
+ /** Everything a room can send: a room is cleared when none of these is left standing. */
171
+ const DEAD = ["bone_rattler", "crypt_bat", "grave_shambler", "bone_archer", "wax_wisp", "tomb_slime", "hex_imp", "candle_mender", "ossuary_brute", "grave_ram", "marrow_knight", "wax_abbot"];
172
+ /** Seconds the crypt stays quiet with no fight before the nearest sleeping room wakes by itself. */
173
+ const STIR_AFTER = 18;
174
+
175
+ system("crawl", ({ onTick, tick, players, spawn, alive, emit, announce, upcoming, respawnDead, spawnPickup, scatter, random, onScreen, on, end, clear, bossAlive }) => {
176
+ scatter("crypt_urn", 36);
177
+ scatter("bone_pile", 14);
178
+ const woken = new Set<number>([CRYPT.start.id]);
179
+ const cleared = new Set<number>([CRYPT.start.id]);
180
+ const active: number[] = [];
181
+ // Spawns waiting for a spot in their room that nobody is looking at.
182
+ const queue: { prefab: string; room: number; elite: boolean; since: number }[] = [];
183
+ let quietSince = tick();
184
+ let fightSince = 0;
185
+ let wakes = 0;
186
+ let knightDone = false;
187
+
188
+ const living = () => DEAD.reduce((n, id) => n + alive(id), 0);
189
+ const pointIn = (room: number, margin: number): [number, number] => {
190
+ const r = CRYPT.rooms[room] ?? CRYPT.start;
191
+ return [r.x + (random() * 2 - 1) * (r.hx - margin), r.z + (random() * 2 - 1) * (r.hz - margin)];
192
+ };
193
+
194
+ const wake = (room: number) => {
195
+ if (woken.has(room)) return;
196
+ woken.add(room);
197
+ if (active.length === 0) fightSince = tick();
198
+ active.push(room);
199
+ wakes++;
200
+ const r = CRYPT.rooms[room] ?? CRYPT.start;
201
+ const party = Math.max(1, players().filter((p) => !p.bot).length);
202
+ const add = (prefab: string, count: number, elite = false) => {
203
+ for (let i = 0; i < count; i++) queue.push({ prefab, room, elite, since: tick() });
204
+ };
205
+ if (room === CRYPT.boss.id) {
206
+ announce("THE WAX ABBOT WAKES", { tone: "danger" });
207
+ add("wax_abbot", 1);
208
+ add("bone_rattler", 6);
209
+ return;
210
+ }
211
+ const row = ENCOUNTERS[Math.min(r.depth - 1, ENCOUNTERS.length - 1)] ?? ENCOUNTERS[0] ?? [];
212
+ for (const [prefab, n] of row) add(prefab, Math.max(1, Math.round(n * (1 + 0.35 * (party - 1)))));
213
+ // Every third room brings an elite; the Marrow Knight guards the first room three doors down.
214
+ if (wakes % 3 === 0) add("bone_rattler", 1, true);
215
+ if (!knightDone && r.depth >= 3) {
216
+ knightDone = true;
217
+ announce("THE MARROW KNIGHT STIRS", { tone: "danger" });
218
+ add("marrow_knight", 1);
219
+ }
220
+ };
221
+
222
+ const clearActive = () => {
223
+ for (const room of active) {
224
+ cleared.add(room);
225
+ const r = CRYPT.rooms[room] ?? CRYPT.start;
226
+ if (room !== CRYPT.boss.id) spawnPickup("reliquary", [r.x, 0.5, r.z]);
227
+ emit("room.cleared");
228
+ }
229
+ active.length = 0;
230
+ respawnDead();
231
+ announce("THE ROOM FALLS SILENT", { tone: "reward" });
232
+ quietSince = tick();
233
+ };
234
+
235
+ onTick(() => {
236
+ const t = tick();
237
+ // Bring in what waits, off every screen, inside its own room (after 3 s, wherever it fits).
238
+ for (let i = 0; i < queue.length && i < 8; i++) {
239
+ const q = queue[i];
240
+ if (!q) continue;
241
+ const late = t - q.since > 90;
242
+ for (let k = 0; k < 4; k++) {
243
+ const [x, z] = pointIn(q.room, 1.5);
244
+ if (!late && onScreen(x, z)) continue;
245
+ spawn(q.prefab, [x, 0.5, z], { elite: q.elite });
246
+ queue.splice(i--, 1);
247
+ break;
248
+ }
249
+ }
250
+ if (t % 10 !== 0) return;
251
+ // Where the party stands wakes the rooms around it.
252
+ for (const p of players()) {
253
+ if (!p.alive) continue;
254
+ const here = CRYPT.roomAt(p.position[0], p.position[2]);
255
+ if (here < 0) continue;
256
+ if (!woken.has(here) && active.length < 2) wake(here);
257
+ if (cleared.has(here)) for (const next of CRYPT.rooms[here]?.links ?? []) if (!woken.has(next) && active.length < 2) wake(next);
258
+ }
259
+ if (active.length > 0) {
260
+ // A long fight down to its last few: the stragglers slink back into the dark (never a boss).
261
+ if (queue.length === 0 && !bossAlive() && t - fightSince > 75 * 30 && living() <= 3) for (const id of DEAD) clear(id);
262
+ if (queue.length === 0 && living() === 0) clearActive();
263
+ return;
264
+ }
265
+ // Quiet too long: the nearest sleeping room next to a cleared one stirs by itself.
266
+ if (t - quietSince >= STIR_AFTER * 30) {
267
+ let pick = -1;
268
+ for (const r of CRYPT.rooms) {
269
+ if (woken.has(r.id) || !r.links.some((l) => cleared.has(l))) continue;
270
+ if (pick < 0 || r.depth < (CRYPT.rooms[pick]?.depth ?? 99)) pick = r.id;
271
+ }
272
+ if (pick >= 0) {
273
+ announce("SOMETHING STIRS IN THE DARK", { tone: "danger" });
274
+ wake(pick);
275
+ }
276
+ } else if (t - quietSince === 30) upcoming("THE CRYPT STIRS", STIR_AFTER - 1);
277
+ });
278
+
279
+ on("miniboss.killed", () => announce("THE KNIGHT FALLS · TAKE THE HOARD", { tone: "reward" }));
280
+ on("boss.killed", () => end("won"));
281
+ });
282
+
283
+ // Keepers: pick one before your first level. New keepers unlock with achievements, never with coins.
284
+ keeper("lampwright", { name: "Lampwright", text: "A steady candle burst and a long reach for shards.", weapon: "candle_burst", passive: "lodestone" });
285
+ keeper("sexton", { name: "Sexton", text: "A sweeping scythe and soft soles.", model: "style.lowpoly.hooded_adventurer", tint: "#9fd17a", weapon: "sexton_scythe", passive: "soft_soles", unlock: "three_doors" });
286
+ keeper("vesper", { name: "Vesper", text: "Arcs that leap between the dead; quicker hands.", model: "style.lowpoly.mini_female_c", tint: "#7ad7ff", weapon: "vesper_arc", passive: "bellows", unlock: "knight_felled" });
287
+ keeper("chandler", { name: "Chandler", text: "Calls falling wax; a wider glass.", model: "style.lowpoly.monarch", tint: "#ffcf7a", weapon: "falling_wax", passive: "stained_glass", unlock: "abbot_silenced" });
288
+
289
+ // Achievements (counted in a run; they unlock keepers and pay nothing).
290
+ achievement("three_doors", { title: "Three Doors Down", text: "Clear three rooms in one descent.", when: "room.cleared", n: 3 });
291
+ achievement("knight_felled", { title: "Marrow and Bone", text: "Fell the Marrow Knight.", when: "miniboss.killed" });
292
+ achievement("abbot_silenced", { title: "The Candle Outlasts", text: "Silence the Wax Abbot.", when: "boss.killed" });
293
+ achievement("evolver", { title: "Rekindled", text: "Evolve a weapon.", when: "player.evolved" });
294
+
295
+ // Daily goals: the HUD shows three a day. They point at the game's systems and pay nothing.
296
+ mission("rooms", { text: "Clear 4 rooms", when: "room.cleared", n: 4 });
297
+ mission("elite", { text: "Kill an elite", when: "elite.killed" });
298
+ mission("revive", { text: "Revive a wickbearer", when: "player.revived" });
299
+ mission("chest", { text: "Open 3 reliquaries", when: "chest.collected", n: 3 });
300
+ mission("knight", { text: "Fell the Marrow Knight", when: "miniboss.killed" });
301
+ mission("evolve", { text: "Evolve a weapon", when: "player.evolved" });
302
+
303
+ // End-screen titles: who led the descent at what.
304
+ title("gravedigger", { text: "The Gravedigger", most: "kills" });
305
+ title("pyre", { text: "The Pyre", most: "damage" });
306
+ title("chaplain", { text: "Chaplain", most: "revives" });
307
+ title("unscathed", { text: "Unscathed", least: "downs" });
308
+ title("magpie", { text: "Magpie", most: "pickups" });
309
+
310
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per run each.
311
+ // Pay amounts copy EMBERWATCH's (5, 20, 50, 5) until CRYPTWICK's own are set: PLACEHOLDER.
312
+ earn.rule("first_doors", { when: "room.cleared", n: 2, pay: 5, oncePer: "run" });
313
+ earn.rule("deep_delve", { when: "room.cleared", n: 7, pay: 20, oncePer: "run" });
314
+ earn.rule("abbot_breaker", { when: "boss.killed", n: 1, pay: 50, oncePer: "run" });
315
+ earn.rule("bone_breaker", { when: "enemy.killed", n: 60, pay: 5, oncePer: "run" });
316
+ // Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play.
317
+ // A staked run also pays up to stake x multiple, by the share of this run's earn the player makes.
318
+ // What it does not win back stays in the reward pool. Values are PLACEHOLDER.
319
+ earn.stake({ min: 20, max: 200, multiple: 2 });
320
+
321
+ // Store (the Kit), priced in coins (the default coin model): a revive used while
322
+ // downed, and a cosmetic wickbearer palette owned for good (kept in the profile).
323
+ // Prices copy EMBERWATCH's until CRYPTWICK's own are set: PLACEHOLDER.
324
+ store.sku("revive", { coins: 50 });
325
+ store.sku("skin_ossuary", { coins: 300, grants: { cosmetic: 1 } });
326
+ // Losable gear: owned for good until lost. It brings one level of its upgrade into every staked run,
327
+ // and is lost when that run makes less than loseBelow of its most earn. Values are PLACEHOLDER.
328
+ store.sku("wick_charm", { coins: 150, grants: { gear: "tallow_oil", loseBelow: 0.25 } });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Loads the game module once per process and compiles it. The room server,
3
+ * the client, the bots and the tests all start here.
4
+ */
5
+ import { collectManifest, compileGame, engineSimAdapter } from "@netforge/engine/game";
6
+ import type { CompiledGame, EngineRoomWorld, RunnerOptions } from "@netforge/engine/game";
7
+ import type { SimAdapter } from "@netforge/engine/net";
8
+ import type { InputCommand } from "@netforge/engine-sim";
9
+ import "./game.js";
10
+
11
+ /** Content version: bump with any change to game.ts (hot swap compares it). */
12
+ export const GAME_VERSION = "cryptwick-0.1.1";
13
+ export const GAME_NAME = "cryptwick";
14
+ /** Room template and place used by the client and the bots. */
15
+ export const TEMPLATE = "dungeon";
16
+ export const PLACE = "crypt";
17
+
18
+ /**
19
+ * Collected as soon as the module is imported: one process may load more
20
+ * than one game module (the room server hosts every template), and each
21
+ * manifest takes its own game before the next module declares.
22
+ */
23
+ const MANIFEST = collectManifest();
24
+ let compiled: CompiledGame | null = null;
25
+
26
+ /** The compiled game (prefabs, weapons, upgrades and the crypt's walls and floors as sim content). */
27
+ export function dungeonGame(): CompiledGame {
28
+ compiled ??= compileGame(MANIFEST);
29
+ return compiled;
30
+ }
31
+
32
+ /** The SimAdapter for this game. `runner` options are server-side measurement modes. */
33
+ export function dungeonAdapter(runner?: RunnerOptions): SimAdapter<EngineRoomWorld, InputCommand> {
34
+ return engineSimAdapter({ name: GAME_NAME, version: GAME_VERSION, game: dungeonGame(), ...(runner ? { runner } : {}) });
35
+ }
36
+
37
+ /** Cosmetic ids for store SKUs (0 is the default palette). */
38
+ export const COSMETICS: Readonly<Record<string, number>> = { skin_ossuary: 1 };
package/src/store.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Store hooks (the Coins model): every
3
+ * game has coins, 1 coin = 1 game token, shown under the token's own name,
4
+ * symbol and logo. The store sells a revive (spent while downed), a
5
+ * cosmetic wickbearer palette (permanent) and a losable charm, priced in coins.
6
+ *
7
+ * `KitStoreBridge` is the slice of @netforge/kit's `Kit` this game
8
+ * calls, with the Kit's own names (`kit.coins.*`, `kit.store.owns/buy`), so
9
+ * the real Kit drops in unchanged. Until a game's token is live `mockKit()`
10
+ * stands in, in memory. Earned coins are spent first (off chain, no wallet
11
+ * prompt), then the wallet, exactly as the Kit does.
12
+ */
13
+ export interface KitStoreBridge {
14
+ readonly store: {
15
+ owns(id: string): boolean;
16
+ count(id: string): number;
17
+ buy(id: string, opts?: { pay?: "usdg" | "token" | "coins" }): Promise<unknown>;
18
+ };
19
+ readonly coins: {
20
+ /** The token's own name, symbol and logo (its metadata). Never a generic "COIN". */
21
+ name(): string;
22
+ symbol(): string;
23
+ logo(): string | null;
24
+ /** Whole coins: earned (unclaimed) plus the wallet. */
25
+ balance(): number;
26
+ /** Earned first, then the wallet. `key` proves the paid room command (sent as the command's `proof`). */
27
+ spend(amount: number, reason: string): Promise<{ key: string | null }>;
28
+ };
29
+ }
30
+
31
+ /** Coin prices the client is wired to (they match store.sku(...) in game.ts; the generator's gate keeps them). */
32
+ export const COIN_PRICES = { revive: 50, skin_ossuary: 300 } as const;
33
+ /** What a revive costs, in coins. */
34
+ export const REVIVE_PRICE = COIN_PRICES.revive;
35
+ /** What the Ossuary palette costs, in coins. */
36
+ export const COSMETIC_PRICE = COIN_PRICES.skin_ossuary;
37
+
38
+ /** The template's own coin, as its token metadata names it until a creator renames it. */
39
+ export const WICK_COIN = {
40
+ name: "Wick",
41
+ symbol: "WICK",
42
+ logo: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%23f2d9a0'/%3E%3Crect x='12' y='14' width='8' height='12' rx='1' fill='%23fff4dc'/%3E%3Cpath d='M16 5c3 4 4 6 4 8a4 4 0 0 1-8 0c0-2 1-4 4-8z' fill='%23ff8a3d'/%3E%3C/svg%3E",
43
+ } as const;
44
+
45
+ export interface SkuPrice {
46
+ readonly coins?: number;
47
+ readonly usdg?: number;
48
+ }
49
+
50
+ export interface MockKit extends KitStoreBridge {
51
+ /** Dollars left (mock only). */
52
+ usdg(): number;
53
+ /** Earned, unclaimed coins left (mock only). */
54
+ earned(): number;
55
+ }
56
+
57
+ /** An in-memory Kit for local play and tests: starting balances, prices from game.ts. */
58
+ export function mockKit(
59
+ start: { earned: number; wallet: number; usdg: number },
60
+ prices: Readonly<Record<string, SkuPrice>>,
61
+ coin: { name: string; symbol: string; logo: string | null } = WICK_COIN,
62
+ ): MockKit {
63
+ let earned = start.earned;
64
+ let wallet = start.wallet;
65
+ let usdg = start.usdg;
66
+ const owned = new Map<string, number>();
67
+ let spends = 0;
68
+ /** Earned first, then the wallet: the Kit's order. */
69
+ const spend = (amount: number, reason: string) => {
70
+ if (!Number.isInteger(amount) || amount <= 0 || earned + wallet < amount) throw new Error(`cannot spend ${amount} for ${reason}`);
71
+ const fromEarned = Math.min(earned, amount);
72
+ earned -= fromEarned;
73
+ wallet -= amount - fromEarned;
74
+ return { fromEarned, onChain: amount - fromEarned, key: `mock-spend-${++spends}` };
75
+ };
76
+ return {
77
+ usdg: () => usdg,
78
+ earned: () => earned,
79
+ store: {
80
+ owns: (id) => (owned.get(id) ?? 0) > 0,
81
+ count: (id) => owned.get(id) ?? 0,
82
+ async buy(id, opts) {
83
+ const p = prices[id];
84
+ if (!p) throw new Error(`no SKU "${id}"`);
85
+ const payUsdg = opts?.pay === "usdg" || (opts?.pay === undefined && p.coins === undefined);
86
+ if (payUsdg) {
87
+ if (p.usdg === undefined || usdg < p.usdg) throw new Error("not enough USDG");
88
+ usdg -= p.usdg;
89
+ } else {
90
+ if (p.coins === undefined) throw new Error(`"${id}" has no coin price`);
91
+ spend(p.coins, id);
92
+ }
93
+ owned.set(id, (owned.get(id) ?? 0) + 1);
94
+ return { id };
95
+ },
96
+ },
97
+ coins: {
98
+ name: () => coin.name,
99
+ symbol: () => coin.symbol,
100
+ logo: () => coin.logo,
101
+ balance: () => earned + wallet,
102
+ async spend(amount, reason) {
103
+ return spend(amount, reason);
104
+ },
105
+ },
106
+ };
107
+ }