@netforge/template-racer 0.0.0-stage → 0.2.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/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
+ }