@netforge/template-arena 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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The buy-in run in the store panel (earn.stake, the mandatory sink loop).
3
+ * The player picks an amount inside the game's terms and stakes it: the Kit
4
+ * spends it under the reason "stake" (earned coins first, then the wallet),
5
+ * then the room is told. The room fixes the stake from its own ledger when
6
+ * the next run starts (or this one, if it only just began) and says so.
7
+ */
8
+ import type { PotMessage, StakeMessage, StakeTermsView } from "@netforge/engine/net";
9
+ import type { KitStoreBridge } from "../src/store.js";
10
+
11
+ export interface StakeConn {
12
+ readonly welcome: { readonly stake?: StakeTermsView };
13
+ stake(amount?: number): void;
14
+ onStake(cb: (m: StakeMessage) => void): void;
15
+ }
16
+
17
+ const fmt = (n: number) => (Math.round(n * 100) / 100).toLocaleString("en-US");
18
+
19
+ /** Wires #stake-amount, #stake-go and #stake-line; hides #stake-row when the game takes no stakes. */
20
+ export function wireStake(conn: StakeConn, kit: KitStoreBridge, toast: (t: string) => void): void {
21
+ const row = document.getElementById("stake-row");
22
+ const input = document.getElementById("stake-amount") as HTMLInputElement | null;
23
+ const go = document.getElementById("stake-go") as HTMLButtonElement | null;
24
+ const line = document.getElementById("stake-line");
25
+ const terms = conn.welcome.stake;
26
+ if (!row || !input || !go || !line) return;
27
+ if (!terms) {
28
+ row.style.display = "none";
29
+ return;
30
+ }
31
+ const sym = kit.coins.symbol();
32
+ input.min = String(terms.min);
33
+ input.max = String(terms.max);
34
+ input.step = "any";
35
+ input.value = String(terms.min);
36
+ const idle = () => {
37
+ const a = Number(input.value) || terms.min;
38
+ line.textContent = `Up to ${fmt(a * terms.multiple)} ${sym} back`;
39
+ };
40
+ idle();
41
+ input.addEventListener("input", idle);
42
+ go.addEventListener("click", async () => {
43
+ const a = Number(input.value);
44
+ if (!(a >= terms.min && a <= terms.max)) return toast(`Stake ${fmt(terms.min)} to ${fmt(terms.max)} ${sym}`);
45
+ go.disabled = true;
46
+ try {
47
+ await kit.coins.spend(a, "stake");
48
+ conn.stake(a);
49
+ } catch {
50
+ toast(`Not enough ${sym}`);
51
+ } finally {
52
+ go.disabled = false;
53
+ }
54
+ });
55
+ conn.onStake((m) => {
56
+ if (m.state === "live") line.textContent = `Staked ${fmt(m.stake)}, up to ${fmt(m.ceiling ?? 0)} ${sym} this run`;
57
+ else if (m.state === "waiting") line.textContent = m.stake > 0 ? `Next run: ${fmt(m.stake)} ${sym}` : "Stake waits for tomorrow's cap";
58
+ else if (m.state === "closed") {
59
+ toast(`Stake ${fmt(m.stake)} ${sym} ${fmt(m.paid ?? 0)} back${m.lost && m.lost.length > 0 ? ` · gear lost` : ""}`);
60
+ idle();
61
+ } else toast(m.reason === "sign in to stake" ? "Sign in to stake" : "Stake not taken");
62
+ });
63
+ }
64
+
65
+ /** One losable gear SKU (store.sku grants { gear, loseBelow }): a buy button and its loss line. Hides #gear-row without one. */
66
+ export function wireGear(gear: { id: string; name: string; coins: number; loseBelow: number } | null, kit: KitStoreBridge, toast: (t: string) => void): void {
67
+ const row = document.getElementById("gear-row");
68
+ const name = document.getElementById("gear-name");
69
+ const line = document.getElementById("gear-line");
70
+ const buy = document.getElementById("gear-buy") as HTMLButtonElement | null;
71
+ if (!row || !name || !line || !buy) return;
72
+ if (!gear) {
73
+ row.style.display = "none";
74
+ return;
75
+ }
76
+ const sym = kit.coins.symbol();
77
+ name.textContent = gear.name;
78
+ line.textContent = `Staked runs only. Lost under ${Math.round(gear.loseBelow * 100)}% of a run's earn`;
79
+ const show = () => {
80
+ buy.textContent = kit.store.owns(gear.id) ? "Owned" : `${gear.coins} ${sym}`;
81
+ buy.disabled = kit.store.owns(gear.id);
82
+ };
83
+ show();
84
+ buy.addEventListener("click", async () => {
85
+ try {
86
+ await kit.store.buy(gear.id, { pay: "coins" });
87
+ } catch {
88
+ toast(`Not enough ${sym}`);
89
+ }
90
+ show();
91
+ });
92
+ }
93
+
94
+ /** The game's first gear SKU from its manifest, with its label. */
95
+ export function gearOf(skus: ReadonlyMap<string, { coins?: number; grants?: unknown }>, names: Readonly<Record<string, string>>): { id: string; name: string; coins: number; loseBelow: number } | null {
96
+ for (const [id, s] of skus) {
97
+ const g = s.grants as { gear?: string; loseBelow?: number } | undefined;
98
+ if (g?.gear && typeof g.loseBelow === "number" && s.coins !== undefined) return { id, name: names[id] ?? id, coins: s.coins, loseBelow: g.loseBelow };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ export interface PotConn {
104
+ readonly welcome: { readonly pot?: { readonly entry: number; readonly recycle: number } };
105
+ pot(): void;
106
+ onPot(cb: (m: PotMessage) => void): void;
107
+ }
108
+
109
+ /** The round's PvP pot: #pot-go pays the entry and tells the room; #pot-line shows the pot. Hides #pot-row without one. */
110
+ export function wirePot(conn: PotConn, kit: KitStoreBridge, toast: (t: string) => void): void {
111
+ const row = document.getElementById("pot-row");
112
+ const go = document.getElementById("pot-go") as HTMLButtonElement | null;
113
+ const line = document.getElementById("pot-line");
114
+ const pot = conn.welcome.pot;
115
+ if (!row || !go || !line) return;
116
+ if (!pot) {
117
+ row.style.display = "none";
118
+ return;
119
+ }
120
+ const sym = kit.coins.symbol();
121
+ const idle = `${fmt(pot.entry)} ${sym} in. The round splits it`;
122
+ line.textContent = idle;
123
+ go.textContent = `${fmt(pot.entry)} ${sym}`;
124
+ go.addEventListener("click", async () => {
125
+ go.disabled = true;
126
+ try {
127
+ await kit.coins.spend(pot.entry, "pot");
128
+ conn.pot();
129
+ } catch {
130
+ toast(`Not enough ${sym}`);
131
+ } finally {
132
+ go.disabled = false;
133
+ }
134
+ });
135
+ conn.onPot((m) => {
136
+ if (m.state === "in") line.textContent = `In. Pot ${fmt(m.pot ?? m.entry)} ${sym}`;
137
+ else if (m.state === "waiting") line.textContent = "In the next round";
138
+ else if (m.state === "closed") {
139
+ toast(`Pot ${fmt(m.pot ?? 0)} ${sym} ${fmt(m.paid ?? 0)} to you`);
140
+ line.textContent = idle;
141
+ } else toast(m.reason === "sign in to enter" ? "Sign in to enter" : "Not entered");
142
+ });
143
+ }
package/llms.txt ADDED
@@ -0,0 +1,164 @@
1
+ # VOLTYARD: the arena shooter template (NetNet game runtime, Template 2)
2
+
3
+ > A twin-stick arena shooter for up to 16 players, with bots filling empty
4
+ > seats. Rounds last a few minutes; the top of the board takes the round.
5
+ > You change the game by editing src/game.ts only. The template already has
6
+ > loadouts, aiming, projectiles, lag-compensated beams, death and respawn,
7
+ > heal drops, rounds, a scoreboard, bots, netcode, rendering, touch and
8
+ > gamepad controls, earn and the store. Do not write renderers, netcode,
9
+ > physics or bot AI.
10
+
11
+ ## Files
12
+
13
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
14
+ game(), prefab(), weapon(), upgrade(), system(), earn.rule(), store.sku().
15
+ - src/names.json: the title and every label players read (weapons,
16
+ upgrades, earn rules, store items).
17
+ - Everything else is template machinery.
18
+
19
+ ## Rules the build enforces
20
+
21
+ - game({ template: "arena", style: "lowpoly" }) with players.loadout (1 to 4
22
+ declared weapons), an arena { halfSize, pillars, spawns } and a round.
23
+ - Arena weapons are kind "blaster", "beam" or "nova" (never bolt or orbit).
24
+ - Asset ids look like "style.lowpoly.mini_male_f", never URLs or paths, and must be in the asset library.
25
+ - game({ style }) is the art style the creator picked: lowpoly, toon, ps1, pixel, voxel or handdrawn. Keep it. The template draws everything in it, and a "style.lowpoly.<name>" id draws as that style's own model of the same name when the style has one.
26
+ - Ids are lower_snake_case. Every game declares at least one earn.rule and one earn.stake (the buy-in run).
27
+ - Pillars fit on the floor; spawns sit 1 m inside the floor and 1 m clear of pillars.
28
+ - 4 players plus seat bots must all score kills, and an earn rule must fire,
29
+ in a 4-minute playtest. A round should end within about 4 minutes.
30
+
31
+ ## Units
32
+
33
+ Seconds, metres, metres per second, hit points (players have 100).
34
+ Tick rate 60 Hz (shooters). Yaw 0 aims up the screen (-z).
35
+
36
+ ## The loop
37
+
38
+ Players spawn at a spawn point no opponent can see and at least 12 m from
39
+ all of them (a floor point when no declared spawn qualifies), with 2 s of
40
+ protection on joining, on respawn and at each new round (firing ends it). They move with one stick or WASD, aim with the
41
+ other or the mouse, hold fire, and pick weapons with 1 2 3 (or cycle).
42
+ A killed player drops players.drop (a heal cell), waits players.respawn
43
+ seconds and comes back. A round ends after round.seconds, or when someone
44
+ reaches round.frags kills; the end screen shows the board, then a new round.
45
+
46
+ ## Common changes
47
+
48
+ ### Tune a weapon
49
+ weapon("arc_rifle", { kind: "blaster", damage: 12, cooldown: 0.15, range: 26, speed: 44 });
50
+ blaster: count projectiles fanned over spread degrees at speed m/s for range m.
51
+ beam: an instant hit to range m (pillars stop it); the server rewinds what
52
+ the shooter saw (lag compensation), so keep cooldown at 0.8 s or more.
53
+ nova: hits every opponent within range m (a short-range blast).
54
+
55
+ ### Add a weapon to the loadout
56
+ weapon("ion_cannon", { kind: "blaster", damage: 40, cooldown: 1.2, range: 30, speed: 22, pierce: 2 });
57
+ players.loadout: ["arc_rifle", "scatter_coil", "rail_lance", "ion_cannon"] (at most 4; slot 4 is reached by cycling).
58
+ Add its label to names.json weapons.
59
+
60
+ ### Change the yard
61
+ arena: { halfSize: 26, pillars: [[x, z, radius], ...], spawns: [[x, z], ...] }.
62
+ Smaller halfSize and more pillars make closer fights. Keep spawns spread out.
63
+
64
+ ### Change round length or the kill target
65
+ round: { seconds: 180, frags: 25 }. 120 to 300 seconds plays well.
66
+
67
+ ### More or fewer bots
68
+ players.bots: 6 fills a room up to 6 players with bots (0 turns them off).
69
+ Bots never earn, and killing a bot pays no earn rule (it still scores and
70
+ shows in the kill feed). players.max caps humans in one room. Bots go easy on a
71
+ person with a poor record and sharpen as they win, ease off once they
72
+ lead the best person (they never stop shooting), and never take the round
73
+ (only a person reaches round.frags; the end screen names the best person).
74
+
75
+ ### Faster respawn, bigger heals
76
+ players.respawn: 2; prefab("volt_cell", { ..., kind: "pickup", value: 50 }).
77
+
78
+ ### Add an earn rule
79
+ earn.rule("first_blood", { when: "opponent.killed", n: 1, pay: 2, oncePer: "run" });
80
+ Events: opponent.killed (credits the killer), player.died (credits the one
81
+ who died), round.top (the top fragger when a round ends), round.finished
82
+ (everyone present at the end), run.won, and anything you emit(). A run is a
83
+ round. Each rule pays once per round per player, in coins; the room server
84
+ is the referee; bots never earn. Bot kills never pay: opponent.killed for a
85
+ bot's death, and anything a system emits while handling it (a streak that a
86
+ bot kill completes), score but count toward no earn rule. round.top pays
87
+ only when another person was in the round, never for topping a board of
88
+ bots. Alone with bots a player earns nothing from kills or the board.
89
+
90
+ ### The buy-in run (required)
91
+ earn.stake({ min: 10, max: 100, multiple: 2 });
92
+ Every game declares one, on one top-level line; a game without it does not
93
+ build. A player stakes coins at the start of a run (the Kit spends them as
94
+ coins.spend(amount, "stake"), then the client calls conn.stake()). The room
95
+ fixes the stake when the next run starts. A staked run pays its normal earn
96
+ plus a share of stake x multiple: the share is the part of the run's most
97
+ earn the player makes. What it does not win back stays in the reward pool.
98
+ multiple is 1 to 2. "stake" is reserved: never a SKU or meta upgrade id.
99
+
100
+ ### PvP pot (optional, arena only)
101
+ earn.pot({ entry: 20, recycle: 0.05 });
102
+ Each entrant pays the entry (coins.spend(entry, "pot"), then conn.pot()) and
103
+ joins the next round's pot. At the round's end the pot, less the recycle
104
+ share that stays in the reward pool, is split by the earn each entrant made.
105
+ A round where no entrant earned recycles the whole pot. "pot" is reserved.
106
+
107
+ ### Losable gear (optional)
108
+ store.sku("plating_kit", { coins: 120, grants: { gear: "plating", loseBelow: 0.25 } });
109
+ Gear is owned in the player's profile and sells for coins only. It brings one
110
+ level of its declared upgrade into every staked run, and is lost when that run
111
+ makes less than loseBelow of its most earn. Its coins stay in the reward pool.
112
+
113
+ ### Track something with a system
114
+ system("streaks", ({ on, emit }) => {
115
+ const streak = new Map<number, number>();
116
+ on("opponent.killed", (ids) => { for (const id of ids) { const n = (streak.get(id) ?? 0) + 1; streak.set(id, n); if (n === 5) emit("streak.five", [id]); } });
117
+ on("player.died", (ids) => { for (const id of ids) streak.set(id, 0); });
118
+ });
119
+ emit(event, ids) credits those players; emit(event) credits everyone present.
120
+ players() gives each player's id, position, health, alive, kills, deaths, bot.
121
+
122
+ ### Coins: earn and the store
123
+
124
+ Every game has coins: 1 coin = 1 of the game's own token, and players see
125
+ them under the token's own name, symbol and logo (never "coins" or "tokens"
126
+ as a label). Earn rules pay coins (pay is whole coins). The recommended
127
+ coin model (the default) sells in-round consumables and looks, never
128
+ permanent stat power:
129
+
130
+ store.sku("plating", { coins: 40 }); // one level of the "plating" upgrade for the round
131
+ store.sku("skin_neon", { coins: 300, grants: { cosmetic: 1 } }); // a finish, owned for good (saved in the player's profile)
132
+
133
+ A store item named after a declared upgrade(...) is a round upgrade: the
134
+ room grants one level of it when a player pays. Keep the five SKUs the
135
+ client is wired to (plating, servo, capacitor, skin_neon, skin_chrome) at
136
+ their prices, with the finishes' grants (neon 1, chrome 2). "Cosmetics only":
137
+ economy({ store: "cosmetics" }). Permanent upgrades are opt-in:
138
+ economy({ metaUpgrades: true }) and metaUpgrade(...), capped by the engine
139
+ at +15% a stat and +30% in all (see GAME-DESIGN.md). Price against what a round pays (the four rules here pay up
140
+ to 51): an upgrade under a round, a finish a few rounds. Before the game
141
+ launches coins are play money; the Kit switches by itself.
142
+
143
+ ### Change who the players are (the asset library)
144
+ players.prefab is a real CC0 character by id; each player is drawn in their
145
+ colour holding a blaster, and plays its walk, run and shoot animations.
146
+ players: { prefab: "style.lowpoly.soldier", ... } or a dressed original:
147
+ players: { prefab: "style.lowpoly.blocky_g", appearance: { tint: "#16e0ff", parts: ["antenna"] }, ... }
148
+ appearance: { tint: "#rrggbb", accent: "#rrggbb", scale: 0.4 to 3, parts: up to 4 }.
149
+ The heal cell is prefab("volt_cell", { model: "style.lowpoly.cell_heal", ... });
150
+ any pickup model works (style.lowpoly.gem_pink, style.lowpoly.coin, style.lowpoly.burger).
151
+
152
+ ## Systems API
153
+
154
+ every(seconds, n => ...), after(seconds, fn), onTick(fn), spawn(prefab, [x,y,z]),
155
+ spawnRing(prefab, { count, radius, around? }), alive(prefab?), players(),
156
+ emit(event, ids?), on(event, ids => ...), end("won" | "lost"), random(),
157
+ tick(), time(). Enemies are optional in the arena: a declared enemy prefab
158
+ spawned by a system chases the nearest player and can be shot.
159
+
160
+ ## Test
161
+
162
+ engine.test.run({ ticks: 60 * 240, bots: 4, assert: [...] }) plays the game
163
+ on the server sim with 4 players plus seat bots and reports kills, deaths,
164
+ rounds (runs.won), topScore, scorers, earned by rule, softlock and ms per tick.
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@netforge/template-arena",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "VOLTYARD: Template 2 of the NetNet game runtime, an arena shooter with lag-compensated beams, loadouts, rounds and seat-filling bots. 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-arena"
27
+ },
28
+ "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-arena#readme",
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "dependencies": {
33
+ "nipplejs": "1.0.4",
34
+ "three": "0.186.1",
35
+ "@netforge/engine-sim": "0.1.0",
36
+ "@netforge/asset-library": "0.1.0",
37
+ "@netforge/engine": "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
+ }
6
57
  }
package/src/game.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * VOLTYARD: the arena shooter template.
3
+ *
4
+ * Wirejacks duel at night in a yard of humming transformers. Move with one
5
+ * hand, aim with the other, swap between a rifle, a scatter coil and a rail
6
+ * lance. Every kill drops a cell that heals. Rounds last three minutes or
7
+ * end at 25 kills; the top of the board takes the round. Bots fill empty
8
+ * seats so a round always has a fight.
9
+ *
10
+ * This file is the whole game as the model writes it: declarations only,
11
+ * against the typed API in @netforge/engine. Everything else (sim,
12
+ * netcode, lag compensation, renderer, bots, referee) is the template's
13
+ * machinery.
14
+ */
15
+ import { earn, game, prefab, store, system, upgrade, weapon } from "@netforge/engine";
16
+
17
+ export default game({
18
+ template: "arena",
19
+ style: "lowpoly",
20
+ tickRate: 60,
21
+ places: { yard: { chunks: "places/yard", spawn: [0, 1, 0] } },
22
+ players: {
23
+ prefab: "style.lowpoly.mini_male_f",
24
+ max: 16,
25
+ loadout: ["arc_rifle", "scatter_coil", "rail_lance"],
26
+ bots: 6,
27
+ respawn: 3,
28
+ drop: "volt_cell",
29
+ },
30
+ // The yard: 52 m square. Pillars are transformers, [x, z, radius]; spawns are [x, z].
31
+ arena: {
32
+ halfSize: 26,
33
+ pillars: [
34
+ [0, 0, 2.6],
35
+ [9, 9, 1.4],
36
+ [-9, 9, 1.4],
37
+ [9, -9, 1.4],
38
+ [-9, -9, 1.4],
39
+ [0, 16, 1.8],
40
+ [0, -16, 1.8],
41
+ [16, 0, 1.8],
42
+ [-16, 0, 1.8],
43
+ [18, 18, 1.2],
44
+ [-18, 18, 1.2],
45
+ [18, -18, 1.2],
46
+ [-18, -18, 1.2],
47
+ ],
48
+ spawns: [
49
+ [22, 22],
50
+ [-22, -22],
51
+ [22, -22],
52
+ [-22, 22],
53
+ [0, 22],
54
+ [0, -22],
55
+ [22, 0],
56
+ [-22, 0],
57
+ ],
58
+ },
59
+ round: { seconds: 180, frags: 25 },
60
+ });
61
+
62
+ // The heal cell a downed wirejack drops: walking over it restores 35 health.
63
+ prefab("volt_cell", { model: "style.lowpoly.cell_heal", health: 1, speed: 0, kind: "pickup", value: 35 });
64
+
65
+ // The loadout. Damage in hit points (players have 100), cooldowns in seconds, ranges in metres.
66
+ weapon("arc_rifle", { kind: "blaster", damage: 12, cooldown: 0.15, range: 26, speed: 44, knockback: 0 });
67
+ weapon("scatter_coil", { kind: "blaster", damage: 9, cooldown: 0.75, range: 11, speed: 34, count: 7, spread: 26, knockback: 0 });
68
+ weapon("rail_lance", { kind: "beam", damage: 60, cooldown: 1.4, range: 40, knockback: 0 });
69
+
70
+ // Upgrades sold in the store, one level each, for the rest of the round.
71
+ upgrade("plating", { stat: "maxHealth", step: 25, maxLevel: 1 });
72
+ upgrade("servo", { stat: "speed", step: 0.08, maxLevel: 1 });
73
+ upgrade("capacitor", { stat: "haste", step: 0.12, maxLevel: 1 });
74
+
75
+ // Streaks: five kills without dying.
76
+ system("streaks", ({ on, emit }) => {
77
+ const streak = new Map<number, number>();
78
+ on("opponent.killed", (ids) => {
79
+ for (const id of ids) {
80
+ const n = (streak.get(id) ?? 0) + 1;
81
+ streak.set(id, n);
82
+ if (n === 5) emit("streak.five", [id]);
83
+ }
84
+ });
85
+ on("player.died", (ids) => {
86
+ for (const id of ids) streak.set(id, 0);
87
+ });
88
+ // A player's earning run started over (signed in, or staked): the streak does too.
89
+ on("run.started", (ids) => {
90
+ for (const id of ids) streak.set(id, 0);
91
+ });
92
+ });
93
+
94
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per round each. Bots never earn; bot kills and a board of bots pay nothing.
95
+ earn.rule("live_wire", { when: "opponent.killed", n: 3, pay: 4, oncePer: "run" });
96
+ earn.rule("long_shift", { when: "opponent.killed", n: 12, pay: 10, oncePer: "run" });
97
+ earn.rule("overload", { when: "streak.five", n: 1, pay: 12, oncePer: "run" });
98
+ earn.rule("top_of_the_yard", { when: "round.top", n: 1, pay: 25, oncePer: "run" });
99
+ // Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play.
100
+ // A staked run also pays up to stake x multiple, by the share of this run's earn the player makes.
101
+ // What it does not win back stays in the reward pool. Values are PLACEHOLDER.
102
+ earn.stake({ min: 10, max: 100, multiple: 2 });
103
+ // PvP pot: each entrant pays the entry before a round; the round splits the pot by the earn each entrant
104
+ // makes, and the recycle share stays in the reward pool. Values are PLACEHOLDER.
105
+ // Off until a legal review: declared, not enabled, so no room takes an entry.
106
+ earn.pot({ entry: 20, recycle: 0.05 });
107
+
108
+ // Store (the Kit), priced in coins (the default coin model): three round upgrades
109
+ // used in a round, and two cosmetic finishes owned for good (kept in the profile).
110
+ store.sku("plating", { coins: 40 });
111
+ store.sku("servo", { coins: 40 });
112
+ store.sku("capacitor", { coins: 60 });
113
+ store.sku("skin_neon", { coins: 300, grants: { cosmetic: 1 } });
114
+ store.sku("skin_chrome", { coins: 500, grants: { cosmetic: 2 } });
115
+ // Losable gear: owned for good until lost. It brings one level of its upgrade into every staked run,
116
+ // and is lost when that run makes less than loseBelow of its most earn. Values are PLACEHOLDER.
117
+ store.sku("plating_kit", { coins: 120, grants: { gear: "plating", 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 = "voltyard-0.1.1";
13
+ export const GAME_NAME = "voltyard";
14
+ /** Room template (as the Net Forge room server names it) and place used by the client and the bots. */
15
+ export const TEMPLATE = "shooter";
16
+ export const PLACE = "yard";
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 (weapons, loadout, arena, rounds as sim content). */
27
+ export function arenaGame(): 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 arenaAdapter(runner?: RunnerOptions): SimAdapter<EngineRoomWorld, InputCommand> {
34
+ return engineSimAdapter({ name: GAME_NAME, version: GAME_VERSION, game: arenaGame(), ...(runner ? { runner } : {}) });
35
+ }
36
+
37
+ /** Cosmetic ids for store SKUs (0 is the default finish). */
38
+ export const COSMETICS: Readonly<Record<string, number>> = { skin_neon: 1, skin_chrome: 2 };
package/src/store.ts ADDED
@@ -0,0 +1,109 @@
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. VOLTYARD sells three round upgrades (a store command
5
+ * named after the upgrade grants one level for the rest of the round) and
6
+ * two cosmetic finishes (permanent), all priced in coins.
7
+ *
8
+ * `KitStoreBridge` is the slice of @netforge/kit's `Kit` this game
9
+ * calls, with the Kit's own names (`kit.coins.*`, `kit.store.owns/buy`), so
10
+ * the real Kit drops in unchanged. Until a game's token is live `mockKit()`
11
+ * stands in, in memory. Earned coins are spent first (off chain, no wallet
12
+ * prompt), then the wallet, exactly as the Kit does.
13
+ */
14
+ export interface KitStoreBridge {
15
+ readonly store: {
16
+ owns(id: string): boolean;
17
+ count(id: string): number;
18
+ buy(id: string, opts?: { pay?: "usdg" | "token" | "coins" }): Promise<unknown>;
19
+ };
20
+ readonly coins: {
21
+ /** The token's own name, symbol and logo (its metadata). Never a generic "COIN". */
22
+ name(): string;
23
+ symbol(): string;
24
+ logo(): string | null;
25
+ /** Whole coins: earned (unclaimed) plus the wallet. */
26
+ balance(): number;
27
+ /** Earned first, then the wallet. `key` proves the paid room command (sent as the command's `proof`). */
28
+ spend(amount: number, reason: string): Promise<{ key: string | null }>;
29
+ };
30
+ }
31
+
32
+ /** Coin prices the client is wired to (they match store.sku(...) in game.ts; the generator's gate keeps them). */
33
+ export const COIN_PRICES = { plating: 40, servo: 40, capacitor: 60, skin_neon: 300, skin_chrome: 500 } as const;
34
+ export type SkuId = keyof typeof COIN_PRICES;
35
+ /** Round upgrades: spent, applied by the room for the rest of the round. */
36
+ export const UPGRADE_SKUS = ["plating", "servo", "capacitor"] as const;
37
+ /** Cosmetic finishes: bought once, owned for good. */
38
+ export const COSMETIC_SKUS = ["skin_neon", "skin_chrome"] as const;
39
+
40
+ /** The template's own coin, as its token metadata names it until a creator renames it. */
41
+ export const VOLT_COIN = {
42
+ name: "Volt",
43
+ symbol: "VOLT",
44
+ 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='%2316e0ff'/%3E%3Cpath d='M18 4 8 18h7l-2 10 11-15h-7z' fill='%23062a3a'/%3E%3C/svg%3E",
45
+ } as const;
46
+
47
+ export interface SkuPrice {
48
+ readonly coins?: number;
49
+ readonly usdg?: number;
50
+ }
51
+
52
+ export interface MockKit extends KitStoreBridge {
53
+ /** Dollars left (mock only). */
54
+ usdg(): number;
55
+ /** Earned, unclaimed coins left (mock only). */
56
+ earned(): number;
57
+ }
58
+
59
+ /** An in-memory Kit for local play and tests: starting balances, prices from game.ts. */
60
+ export function mockKit(
61
+ start: { earned: number; wallet: number; usdg: number },
62
+ prices: Readonly<Record<string, SkuPrice>>,
63
+ coin: { name: string; symbol: string; logo: string | null } = VOLT_COIN,
64
+ ): MockKit {
65
+ let earned = start.earned;
66
+ let wallet = start.wallet;
67
+ let usdg = start.usdg;
68
+ const owned = new Map<string, number>();
69
+ let spends = 0;
70
+ /** Earned first, then the wallet: the Kit's order. */
71
+ const spend = (amount: number, reason: string) => {
72
+ if (!Number.isInteger(amount) || amount <= 0 || earned + wallet < amount) throw new Error(`cannot spend ${amount} for ${reason}`);
73
+ const fromEarned = Math.min(earned, amount);
74
+ earned -= fromEarned;
75
+ wallet -= amount - fromEarned;
76
+ return { fromEarned, onChain: amount - fromEarned, key: `mock-spend-${++spends}` };
77
+ };
78
+ return {
79
+ usdg: () => usdg,
80
+ earned: () => earned,
81
+ store: {
82
+ owns: (id) => (owned.get(id) ?? 0) > 0,
83
+ count: (id) => owned.get(id) ?? 0,
84
+ async buy(id, opts) {
85
+ const p = prices[id];
86
+ if (!p) throw new Error(`no SKU "${id}"`);
87
+ const payUsdg = opts?.pay === "usdg" || (opts?.pay === undefined && p.coins === undefined);
88
+ if (payUsdg) {
89
+ if (p.usdg === undefined || usdg < p.usdg) throw new Error("not enough USDG");
90
+ usdg -= p.usdg;
91
+ } else {
92
+ if (p.coins === undefined) throw new Error(`"${id}" has no coin price`);
93
+ spend(p.coins, id);
94
+ }
95
+ owned.set(id, (owned.get(id) ?? 0) + 1);
96
+ return { id };
97
+ },
98
+ },
99
+ coins: {
100
+ name: () => coin.name,
101
+ symbol: () => coin.symbol,
102
+ logo: () => coin.logo,
103
+ balance: () => earned + wallet,
104
+ async spend(amount, reason) {
105
+ return spend(amount, reason);
106
+ },
107
+ },
108
+ };
109
+ }