@netforge/template-racer 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,125 @@
1
+ # TORQUEBAY: the racer template (NetNet game runtime, play style "racer")
2
+
3
+ > Kart races for up to 8 players on harbour circuits, with seat bots filling
4
+ > the grid. A round is one race of a few laps; the next round runs another
5
+ > circuit. You change the game by editing src/game.ts only. The template
6
+ > already has the kart physics (throttle, brakes, steering, grip, drift
7
+ > mini-turbos in three tiers, boost pads, coins that raise top speed, grass,
8
+ > barriers, kart bumps), laps through ordered checkpoints, the race clock and
9
+ > places, bots that drive the racing line, netcode with client prediction at
10
+ > 60 Hz, a streamed track (only the road near you is built), rendering in
11
+ > every art style, keyboard, gamepad and touch controls, earn and the store.
12
+ > Do not write renderers, netcode, physics or bot AI.
13
+
14
+ ## Files
15
+
16
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
17
+ game(), upgrade(), system(), earn.rule(), earn.stake(), store.sku().
18
+ - src/names.json: the title and every label players read.
19
+ - Everything else is template machinery.
20
+
21
+ ## Rules the build enforces
22
+
23
+ - game({ template: "racer", style: "lowpoly", tracks: [...] }) with 1 to 8 tracks.
24
+ - No weapon(), director(), keeper() or enemy prefabs.
25
+ - A track has 4 to 64 control points at least 6 m apart and is at least 200 m round.
26
+ - Asset ids look like "style.lowpoly.kart_a", never URLs or paths, and must be in the asset library.
27
+ - game({ style }) is the art style the creator picked: lowpoly, toon, ps1, pixel, voxel or handdrawn. Keep it.
28
+ - Ids are lower_snake_case. Every game declares at least one earn.rule and one earn.stake (the buy-in run).
29
+ - Upgrades raise one of: speed (top speed), haste (drift charge rate), might
30
+ (boost length), armor (grass and wall losses), magnet (coin reach, m).
31
+ - 4 players plus seat bots must all finish a race, and an earn rule must
32
+ fire, in a 4-minute playtest.
33
+
34
+ ## Units
35
+
36
+ Seconds, metres, metres per second. The ground plane: x and z in metres.
37
+ Tick rate 60 Hz. Default top speed 26 m/s; a kart is about 1.8 m long.
38
+
39
+ ## The loop
40
+
41
+ Everyone lines up on a staggered grid behind the line. A 3 s countdown, then
42
+ the race: W/Up accelerate, S/Down brake, A/D steer, hold Space or Shift
43
+ while steering through a bend to drift (sparks turn blue, orange, then
44
+ purple as the mini-turbo charges; release for a 0.5, 0.9 or 1.4 s boost).
45
+ Boost pads boost; each coin held adds 1% top speed, up to 10. Grass slows,
46
+ barriers bounce. Laps count through four quarter checkpoints in order. The
47
+ round closes 25 s after the first person finishes (or at round.seconds);
48
+ the results show, then the next circuit.
49
+
50
+ ## Building a track
51
+
52
+ tracks: [{
53
+ name: "Harbour Loop",
54
+ laps: 3,
55
+ width: 14, // asphalt, m (8 to 30)
56
+ points: [[0, 0], [90, 0], [150, 15], ...], // the closed centreline in driving order; a smooth curve runs through them
57
+ boosts: [[0.12, 0], [0.47, -0.4]], // [lap fraction 0..1, lateral -1 (left) .. 1 (right)]
58
+ coins: [[0.04, 0.5], [0.046, 0.5]], // same coordinates; each kart collects its own (at most 62)
59
+ verge: 4, // optional grass before the barrier, m
60
+ }]
61
+ Rules of thumb: 500 to 900 m a lap at 14 m wide runs 25 to 40 s a lap; keep
62
+ neighbouring points 20 m or more apart for smooth bends; a hairpin needs
63
+ about 25 m of radius; put boosts on the exits of bends and coin rows on the
64
+ racing line or slightly off it (a choice). Two to four laps a race.
65
+
66
+ ## Common changes
67
+
68
+ ### A new circuit
69
+ Add a track to tracks. Circuits come one a round (the room draws one per round).
70
+
71
+ ### Tune the handling
72
+ kart: { topSpeed: 30, turn: 2.6, grip: 10, driftGrip: 1.5 } (topSpeed 10-45,
73
+ accel 8-80, turn 1-4, grip 3-20, driftGrip 0.5-6, grass 0.2-1, boostSpeed 1-1.8).
74
+
75
+ ### Round length and bots
76
+ round: { seconds: 240 } (60 to 600). players.bots: 6 fills the grid up to 6
77
+ with seat bots (0 turns them off). Bots drive the racing line, shaded by
78
+ skill, never earn, and a win over a field of bots pays nothing extra.
79
+
80
+ ### Add an earn rule
81
+ earn.rule("lap_leader", { when: "lap.completed", n: 2, pay: 3, oncePer: "run" });
82
+ Events: player.finished (you finished the race), race.won (first, paid only
83
+ with another person in the round), race.podium (top three, same),
84
+ lap.completed, coin.collected, drift.boosted (a mini-turbo fired),
85
+ drift.ultra (a full-charge one), kart.bumped (a hard wall hit),
86
+ round.finished, run.started (unpaid), and anything you emit(). Each rule
87
+ pays once per race per player, in coins; the room server is the referee.
88
+
89
+ ### Track something with a system
90
+ system("clean_line", ({ on, emit }) => {
91
+ const scrapes = new Map<number, number>();
92
+ on("kart.bumped", (ids) => { for (const id of ids) scrapes.set(id, (scrapes.get(id) ?? 0) + 1); });
93
+ on("player.finished", (ids) => { const clean = ids.filter((id) => !scrapes.get(id)); if (clean.length) emit("race.clean", clean); });
94
+ on("run.started", (ids) => { for (const id of ids) scrapes.delete(id); });
95
+ });
96
+ Systems also have every, after, onTick, players(), random(), announce(text),
97
+ upcoming(label, seconds) and end(). spawn and enemies are not part of this template.
98
+
99
+ ### The buy-in run (required)
100
+ earn.stake({ min: 10, max: 100, multiple: 2 }); one top-level line. "stake" is reserved.
101
+
102
+ ### Losable gear (optional)
103
+ store.sku("bumper_kit", { coins: 120, grants: { gear: "bumpers", loseBelow: 0.25 } });
104
+ Owned in the profile, coins only; one level of its upgrade in every staked
105
+ race; lost when that race makes less than loseBelow of its most earn.
106
+
107
+ ### Coins: earn and the store
108
+ store.sku("turbo", { coins: 40 }); // one level of the "turbo" upgrade for the race
109
+ store.sku("skin_flame", { coins: 300, grants: { cosmetic: 1 } }); // a paint job, owned for good
110
+ Keep the five SKUs the client is wired to (turbo, tuned_engine, soft_tyres,
111
+ skin_flame, skin_chrome) at their prices, with the paints' grants (flame 1,
112
+ chrome 2). PvP pots are not part of this template.
113
+
114
+ ### Change the karts (the asset library)
115
+ players: { prefab: "style.lowpoly.race_car", ... } (any vehicle: kart_a..e,
116
+ race_car, race_car_future, buggy-like speeder_a..c). Seat bots drive
117
+ kart_b..e; the pixel style draws its own kart sprites.
118
+
119
+ ## Test
120
+
121
+ engine.test.run({ ticks: 60 * 240, bots: 4, assert: [...] }) runs races on
122
+ the server sim with 4 playtest drivers plus seat bots and reports rounds
123
+ (wave), runs.won (races every playtest driver finished), scorers (drivers
124
+ that finished at least once), deaths (hard wall hits), earned by rule,
125
+ softlock and ms per tick.
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@netforge/template-racer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "TORQUEBAY: a racer template of the NetNet game runtime, kart races on harbour circuits with drift mini-turbos, boost pads, coins and laps at 60 Hz, a streamed track 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-racer"
27
+ },
28
+ "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-racer#readme",
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "dependencies": {
33
+ "three": "0.186.1",
34
+ "@netforge/asset-library": "0.1.0",
35
+ "@netforge/engine": "0.1.0",
36
+ "@netforge/engine-sim": "0.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "22.20.1",
40
+ "@types/three": "0.186.0",
41
+ "tsx": "4.23.0",
42
+ "typescript": "5.8.3",
43
+ "vite": "6.4.3",
44
+ "vitest": "3.2.7"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/netnetdev/net-forge/issues"
48
+ },
49
+ "scripts": {
50
+ "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
51
+ "typecheck": "tsc -p tsconfig.json",
52
+ "test": "vitest run",
53
+ "dev": "vite --config client/vite.config.ts",
54
+ "preview": "vite preview --config client/vite.config.ts"
55
+ }
6
56
  }
package/src/game.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * TORQUEBAY: the racer template (play style "racer").
3
+ * Up to eight karts race laps around harbour circuits: hold the throttle,
4
+ * drift through the bends to charge a mini-turbo (release for the boost),
5
+ * hit the boost pads, collect coins (every coin you hold adds 1% top speed,
6
+ * up to ten) and keep off the grass. A round is one race; the next round runs
7
+ * another circuit. Seat bots fill the grid so a race always has a field.
8
+ *
9
+ * This file is the whole game as the model writes it: declarations only,
10
+ * against the typed API in @netforge/engine. Everything else (the kart
11
+ * physics, netcode, renderer, track streaming, bots, referee) is the
12
+ * template's machinery.
13
+ *
14
+ * Track coordinates are metres on the ground (x right, z down the screen's
15
+ * map). points is the closed centreline in driving order; boosts and coins
16
+ * are [lap fraction 0..1, lateral -1..1 of the half width].
17
+ */
18
+ import { earn, game, store, system, upgrade } from "@netforge/engine";
19
+
20
+ /** Coins in a row: n coins from lap fraction f, `gap` of a lap apart, at one lateral. */
21
+ const coinRow = (f: number, lateral: number, n: number, gap = 0.006): [number, number][] => Array.from({ length: n }, (_, i) => [f + i * gap, lateral]);
22
+
23
+ export default game({
24
+ template: "racer",
25
+ style: "lowpoly",
26
+ tickRate: 60,
27
+ places: { harbour: { chunks: "places/harbour", spawn: [0, 0, 0] } },
28
+ players: { prefab: "style.lowpoly.kart_a", max: 8, bots: 6 },
29
+ round: { seconds: 240 },
30
+ tracks: [
31
+ {
32
+ name: "Harbour Loop",
33
+ laps: 3,
34
+ width: 14,
35
+ points: [[0, 0], [90, 0], [150, 15], [175, 65], [150, 120], [90, 135], [50, 175], [-20, 190], [-85, 160], [-110, 100], [-90, 35]],
36
+ boosts: [[0.12, 0], [0.47, -0.4], [0.78, 0.4]],
37
+ coins: [...coinRow(0.04, 0.5, 5), ...coinRow(0.3, -0.5, 5), ...coinRow(0.6, 0, 5), ...coinRow(0.88, -0.3, 5)],
38
+ },
39
+ {
40
+ name: "Crane Yard",
41
+ laps: 3,
42
+ width: 13,
43
+ points: [[0, 0], [70, -10], [120, 10], [130, 60], [95, 85], [60, 70], [30, 95], [45, 140], [5, 175], [-55, 160], [-75, 105], [-50, 60], [-75, 20]],
44
+ boosts: [[0.08, 0], [0.38, 0.3], [0.7, -0.3]],
45
+ coins: [...coinRow(0.15, 0, 5), ...coinRow(0.45, 0.4, 5), ...coinRow(0.62, -0.4, 5), ...coinRow(0.85, 0, 5)],
46
+ },
47
+ {
48
+ name: "Lighthouse Run",
49
+ laps: 2,
50
+ width: 15,
51
+ points: [[0, 0], [120, -15], [200, 10], [235, 70], [210, 140], [140, 170], [80, 150], [40, 190], [-30, 210], [-95, 175], [-115, 105], [-80, 40]],
52
+ boosts: [[0.05, 0], [0.25, 0.35], [0.5, -0.35], [0.8, 0]],
53
+ coins: [...coinRow(0.1, -0.4, 6), ...coinRow(0.35, 0.4, 6), ...coinRow(0.66, 0, 6), ...coinRow(0.9, 0.3, 6)],
54
+ },
55
+ ],
56
+ });
57
+
58
+ // Round upgrades sold in the store (one level each, for the race). Racer upgrades raise speed
59
+ // (top speed), haste (drift charge), might (boost length), armor (grass and wall losses) or magnet (coin reach).
60
+ upgrade("turbo", { stat: "might", step: 0.25, maxLevel: 1 });
61
+ upgrade("tuned_engine", { stat: "speed", step: 0.05, maxLevel: 1 });
62
+ upgrade("soft_tyres", { stat: "haste", step: 0.3, maxLevel: 1 });
63
+ upgrade("bumpers", { stat: "armor", step: 0.3, maxLevel: 1 });
64
+
65
+ // A clean race: finish without scraping a wall hard.
66
+ system("clean_line", ({ on, emit }) => {
67
+ const scrapes = new Map<number, number>();
68
+ on("kart.bumped", (ids) => {
69
+ for (const id of ids) scrapes.set(id, (scrapes.get(id) ?? 0) + 1);
70
+ });
71
+ on("player.finished", (ids) => {
72
+ const clean = ids.filter((id) => (scrapes.get(id) ?? 0) === 0);
73
+ if (clean.length > 0) emit("race.clean", clean);
74
+ });
75
+ on("run.started", (ids) => {
76
+ for (const id of ids) scrapes.delete(id);
77
+ });
78
+ });
79
+
80
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per race each.
81
+ // Seat bots never earn, and winning a field of bots pays nothing extra. Values are PLACEHOLDER.
82
+ earn.rule("hot_lap", { when: "drift.boosted", n: 3, pay: 4, oncePer: "run" });
83
+ earn.rule("chequered", { when: "player.finished", n: 1, pay: 10, oncePer: "run" });
84
+ earn.rule("clean_line", { when: "race.clean", n: 1, pay: 12, oncePer: "run" });
85
+ earn.rule("pole_to_flag", { when: "race.won", n: 1, pay: 25, oncePer: "run" });
86
+ // Buy-in run (the mandatory sink loop). PLACEHOLDER.
87
+ earn.stake({ min: 10, max: 100, multiple: 2 });
88
+
89
+ // Store (the Kit), priced in coins: round upgrades and two paint jobs owned for good.
90
+ store.sku("turbo", { coins: 40 });
91
+ store.sku("tuned_engine", { coins: 40 });
92
+ store.sku("soft_tyres", { coins: 60 });
93
+ store.sku("skin_flame", { coins: 300, grants: { cosmetic: 1 } });
94
+ store.sku("skin_chrome", { coins: 500, grants: { cosmetic: 2 } });
95
+ // Losable gear: bumpers in every staked race, lost when that race makes less than loseBelow of its most earn. PLACEHOLDER.
96
+ store.sku("bumper_kit", { coins: 120, grants: { gear: "bumpers", loseBelow: 0.25 } });
@@ -0,0 +1,44 @@
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, kartAdapter } from "@netforge/engine/game";
6
+ import type { CompiledGame, CompiledRacer, KartRoomWorld, RaceRunnerOptions } 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 = "torquebay-0.1.0";
13
+ export const GAME_NAME = "torquebay";
14
+ /** Room template (as the Net Forge room server names it) and place used by the client and the bots. */
15
+ export const TEMPLATE = "racer";
16
+ export const PLACE = "harbour";
17
+
18
+ const MANIFEST = collectManifest();
19
+ let compiled: CompiledGame | null = null;
20
+
21
+ /** The compiled game (tracks, handling, upgrades). */
22
+ export function racerGame(): CompiledGame & { readonly racer: CompiledRacer } {
23
+ compiled ??= compileGame(MANIFEST);
24
+ if (!compiled.racer) throw new Error('TORQUEBAY: game.ts must declare template "racer"');
25
+ return compiled as CompiledGame & { readonly racer: CompiledRacer };
26
+ }
27
+
28
+ /** The SimAdapter for this game. `runner` options are server-side timing overrides (tests). */
29
+ export function racerAdapter(runner?: RaceRunnerOptions): SimAdapter<KartRoomWorld, InputCommand> {
30
+ const g = racerGame();
31
+ return kartAdapter({
32
+ name: GAME_NAME,
33
+ version: GAME_VERSION,
34
+ manifest: g.manifest,
35
+ compiled: g.racer,
36
+ tickRate: g.tickRate,
37
+ bots: g.bots,
38
+ roundSeconds: g.round?.seconds ?? null,
39
+ ...(runner ? { runner } : {}),
40
+ });
41
+ }
42
+
43
+ /** Cosmetic ids for store SKUs (0 is the kart's own paint). */
44
+ export const COSMETICS: Readonly<Record<string, number>> = { skin_flame: 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. TORQUEBAY 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 = { turbo: 40, tuned_engine: 40, soft_tyres: 60, skin_flame: 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 = ["turbo", "tuned_engine", "soft_tyres"] as const;
37
+ /** Cosmetic finishes: bought once, owned for good. */
38
+ export const COSMETIC_SKUS = ["skin_flame", "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 TORQ_COIN = {
42
+ name: "Torq",
43
+ symbol: "TORQ",
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='%23ff5a1f'/%3E%3Ccircle cx='16' cy='16' r='9' fill='none' stroke='%23fff3d6' stroke-width='3'/%3E%3Cpath d='M16 7v6M16 19v6M7 16h6M19 16h6' stroke='%23fff3d6' stroke-width='3'/%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 } = TORQ_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
+ }