@netforge/tabletop 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/host.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Net Forge as host. Framed by Net Forge, the page URL carries
3
+ * the Kit flags and the host posts one message; played on its own, none of
4
+ * this applies and the game behaves exactly as before.
5
+ *
6
+ * ?host=netnet the host draws Invite and sign-in; the game hides its own
7
+ * ?coin=TICKER the coin the HUD names (2 to 6 capitals or digits)
8
+ * ?title=NAME the game's own name for the wordmark and the tab title
9
+ * ?player=guest no wallet is connected: play pays nothing, and the HUD
10
+ * says "not earning" instead of a balance
11
+ * ?player=play the token has not launched: coins are play money
12
+ *
13
+ * The player flag changes while the game runs (a wallet connects), so the
14
+ * host also posts { kind: "netnet-kit", type: "player", mode }. Only the
15
+ * parent window is listened to, and only when the URL says it is the host.
16
+ */
17
+
18
+ export type PlayerMode = "earning" | "guest" | "play";
19
+
20
+ export interface HostFlags {
21
+ /** Framed by Net Forge. */
22
+ readonly framed: boolean;
23
+ /** The coin's ticker, when the host names one. */
24
+ readonly coin: string | null;
25
+ /** The game's own name, when the host names one. */
26
+ readonly title: string | null;
27
+ /** What play pays the person at the screen. Standalone play is "earning" (the game's own kit decides). */
28
+ readonly player: PlayerMode;
29
+ }
30
+
31
+ const TICKER_RE = /^[A-Z0-9]{2,6}$/;
32
+
33
+ export function hostFlags(params: URLSearchParams): HostFlags {
34
+ const framed = params.get("host") === "netnet";
35
+ const coin = params.get("coin");
36
+ // eslint-disable-next-line no-control-regex
37
+ const title = (params.get("title") ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 40);
38
+ const player = params.get("player");
39
+ return {
40
+ framed,
41
+ coin: coin && TICKER_RE.test(coin) ? coin : null,
42
+ title: title || null,
43
+ player: framed && (player === "guest" || player === "play") ? player : "earning",
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The player mode the host named on the URL, or null when it named none (a
49
+ * connected player on a live game gets no flag, only the posted message) or
50
+ * the page is not framed by Net Forge.
51
+ */
52
+ export function namedPlayer(params: URLSearchParams): PlayerMode | null {
53
+ if (params.get("host") !== "netnet") return null;
54
+ const player = params.get("player");
55
+ return player === "earning" || player === "guest" || player === "play" ? player : null;
56
+ }
57
+
58
+ /**
59
+ * The store's note under its prices, from one source of truth. Once the host
60
+ * has named the player mode (on the URL or in a message), that mode decides,
61
+ * exactly as it does for the coin box: a live game never calls its coins play
62
+ * money. Standalone play, or a host that names no mode (the studio's drafts),
63
+ * keeps the room's own word (`welcome.earnLive`).
64
+ */
65
+ export function storeNote(hostMode: PlayerMode | null, earnLive: boolean, symbol: string): string {
66
+ if (hostMode === "play") return "Play money until launch";
67
+ if (hostMode === "guest") return `Connect a wallet to earn ${symbol}`;
68
+ if (hostMode === "earning") return "";
69
+ return earnLive ? "" : "Play money until launch";
70
+ }
71
+
72
+ /** A host message, checked: anything else is ignored. */
73
+ export function playerFrom(data: unknown): PlayerMode | null {
74
+ if (typeof data !== "object" || data === null) return null;
75
+ const m = data as Record<string, unknown>;
76
+ if (m["kind"] !== "netnet-kit" || m["type"] !== "player") return null;
77
+ const mode = m["mode"];
78
+ return mode === "earning" || mode === "guest" || mode === "play" ? mode : null;
79
+ }
80
+
81
+ /** Calls `cb` with each player mode the host posts. Framed pages only. */
82
+ export function onHostPlayer(flags: HostFlags, cb: (mode: PlayerMode) => void): void {
83
+ if (!flags.framed || window.parent === window) return;
84
+ window.addEventListener("message", (e: MessageEvent<unknown>) => {
85
+ if (e.source !== window.parent) return;
86
+ const mode = playerFrom(e.data);
87
+ if (mode) cb(mode);
88
+ });
89
+ }
90
+
91
+ /** Tells the host the game is up, so it can post the current player mode. */
92
+ export function postReady(flags: HostFlags): void {
93
+ if (!flags.framed || window.parent === window) return;
94
+ // The host's origin is not known to an opaque-origin frame; the message is one public flag.
95
+ window.parent.postMessage({ kind: "netnet-kit", type: "ready" }, "*");
96
+ }
package/src/hud.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * The HUD shell the board templates share: plain DOM over the canvas.
3
+ * The coin box, Invite, Store and Sound, toasts, the
4
+ * end screen with what the run paid, the store sheet (run upgrades,
5
+ * finishes, losable gear and the stake), the earn goals, the stats overlay
6
+ * and the loading banner. Each template themes it with its own colours and
7
+ * type and adds its own panels. Copy is labels, not sentences.
8
+ */
9
+ import type { GameManifest } from "@netforge/engine";
10
+ import type { RoomConnection } from "@netforge/engine/net";
11
+ import type { KitStoreBridge, SkuPrice } from "./kit.js";
12
+ import { gearOf, wireGear, wireStake } from "./stake.js";
13
+ import { storeNote, type PlayerMode } from "./host.js";
14
+
15
+ export interface HudTheme {
16
+ /** Main light (titles, focus). */
17
+ readonly accent: string;
18
+ /** Second light (money, rewards). */
19
+ readonly gold: string;
20
+ readonly ink: string;
21
+ readonly dim: string;
22
+ /** Panel fill. */
23
+ readonly panel: string;
24
+ /** Page background behind the canvas. */
25
+ readonly bg: string;
26
+ /** Font stack. */
27
+ readonly font: string;
28
+ /** Display font for titles (defaults to font). */
29
+ readonly display?: string;
30
+ /** Corner radius, px. */
31
+ readonly radius?: number;
32
+ }
33
+
34
+ export interface HudLabels {
35
+ readonly title: string;
36
+ readonly rules: Readonly<Record<string, string>>;
37
+ readonly skus: Readonly<Record<string, string>>;
38
+ readonly upgrades: Readonly<Record<string, { name: string; line: string }>>;
39
+ /** Shown under the banner while joining. */
40
+ readonly loading: string;
41
+ /** Help line at the bottom left (desktop only). */
42
+ readonly help: string;
43
+ }
44
+
45
+ const CSS = (t: HudTheme) => `
46
+ :root { color-scheme: dark; --ink: ${t.ink}; --dim: ${t.dim}; --accent: ${t.accent}; --gold: ${t.gold}; --panel: ${t.panel}; --line: color-mix(in srgb, ${t.accent} 30%, transparent); --r: ${t.radius ?? 8}px; font-family: ${t.font}; }
47
+ html, body { margin: 0; height: 100%; background: ${t.bg}; color: var(--ink); overflow: hidden; touch-action: none; -webkit-user-select: none; user-select: none; }
48
+ #game { position: fixed; inset: 0; }
49
+ #game canvas { display: block; width: 100%; height: 100%; }
50
+ .hud { position: fixed; z-index: 5; pointer-events: none; font-variant-numeric: tabular-nums; }
51
+ .hud button, .hud input, .hud .tap { pointer-events: auto; }
52
+ button { font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); padding: 7px 11px; cursor: pointer; }
53
+ button:disabled { opacity: 0.45; cursor: default; }
54
+ button:active { transform: scale(0.97); }
55
+ button.go { background: var(--accent); color: ${t.bg}; border-color: transparent; font-weight: 800; }
56
+ #top-left { top: max(10px, env(safe-area-inset-top)); left: 12px; max-width: min(360px, 52vw); }
57
+ #title { font: 900 13px ${t.display ?? t.font}; letter-spacing: 0.26em; color: var(--accent); text-shadow: 0 1px 0 rgba(0,0,0,0.6); }
58
+ #status { margin-top: 6px; display: grid; gap: 4px; }
59
+ .chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: var(--r); background: var(--panel); border: 1px solid rgba(255,255,255,0.08); font-weight: 700; font-size: 13px; width: fit-content; }
60
+ .chip small { color: var(--dim); font-weight: 600; letter-spacing: 0.08em; }
61
+ #top-right { top: max(10px, env(safe-area-inset-top)); right: 12px; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; width: min(260px, 46vw); }
62
+ #top-right .row { display: flex; gap: 6px; }
63
+ #coin-box { display: flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r); background: var(--panel); border: 1px solid var(--line); font-weight: 700; white-space: nowrap; }
64
+ #coin-box.guest #coin-balance { color: var(--dim); font-weight: 600; }
65
+ #coin-box.play { display: block; text-align: right; }
66
+ #coin-box.play #coin-logo { vertical-align: middle; margin-right: 6px; }
67
+ #coin-box.play::after { content: "play money"; display: block; margin-top: 1px; font-size: 10px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--dim); }
68
+ #coin-logo { border-radius: 50%; }
69
+ #goals { list-style: none; margin: 0; padding: 6px 9px; background: var(--panel); border-radius: var(--r); font-size: 11px; display: grid; gap: 3px; min-width: 170px; }
70
+ #goals:empty { display: none; }
71
+ #goals li { display: flex; justify-content: space-between; gap: 10px; color: var(--dim); }
72
+ #goals li b { color: var(--ink); font-weight: 700; }
73
+ #goals li.paid { color: var(--gold); }
74
+ #goals li.paid b { color: var(--gold); }
75
+ #toast { left: 50%; top: 70px; transform: translate(-50%, -10px); padding: 8px 14px; border-radius: 999px; background: var(--panel); border: 1px solid var(--gold); font-weight: 700; opacity: 0; transition: opacity 200ms, transform 200ms; white-space: nowrap; z-index: 9; }
76
+ #toast.show { opacity: 1; transform: translate(-50%, 0); }
77
+ #banner { left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; font-size: 14px; color: var(--dim); letter-spacing: 0.12em; }
78
+ #banner b { display: block; font: 900 44px ${t.display ?? t.font}; letter-spacing: 0.24em; color: var(--accent); margin-bottom: 8px; }
79
+ #end { inset: 0; display: none; place-items: center; background: rgba(0, 0, 0, 0.55); z-index: 8; }
80
+ #end.show { display: grid; }
81
+ #end .box { width: min(420px, 90vw); padding: 22px; border-radius: calc(var(--r) * 1.6); background: var(--panel); border: 1px solid var(--line); text-align: center; backdrop-filter: blur(6px); }
82
+ #end-title { font: 900 26px ${t.display ?? t.font}; letter-spacing: 0.14em; color: var(--accent); }
83
+ #end-sub { color: var(--dim); margin-top: 4px; font-size: 13px; }
84
+ #end-earned { list-style: none; padding: 0; margin: 14px 0 12px; text-align: left; font-size: 14px; }
85
+ #end-earned li { display: flex; justify-content: space-between; padding: 6px 0; border-top: 1px solid var(--line); }
86
+ #end-earned b { color: var(--gold); }
87
+ #end-earned li.none { color: var(--dim); justify-content: center; }
88
+ #end-next { color: var(--dim); font-size: 12px; letter-spacing: 0.12em; }
89
+ #store { top: 0; right: 0; bottom: 0; width: min(330px, 88vw); background: var(--panel); backdrop-filter: blur(8px); border-left: 1px solid var(--line); padding: 18px; box-sizing: border-box; transform: translateX(100%); transition: transform 220ms ease; pointer-events: auto; overflow-y: auto; z-index: 8; }
90
+ #store.show { transform: none; }
91
+ #store h2 { margin: 0 0 2px; font-size: 15px; letter-spacing: 0.2em; }
92
+ #store h3 { margin: 16px 0 0; font-size: 10px; letter-spacing: 0.24em; color: var(--dim); font-weight: 600; }
93
+ #store .item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 0; border-top: 1px solid rgba(255, 255, 255, 0.08); }
94
+ #store .item > div { display: flex; align-items: center; gap: 10px; }
95
+ #store .swatch { width: 26px; height: 26px; border-radius: 7px; flex: none; }
96
+ #store small { color: var(--dim); }
97
+ #store button { min-width: 88px; }
98
+ #store-close { margin-top: 16px; }
99
+ #stats { left: 12px; bottom: 120px; font: 11px/1.4 ui-monospace, monospace; color: var(--dim); white-space: pre; display: none; background: rgba(0,0,0,0.5); padding: 6px; border-radius: 6px; }
100
+ #stats.show { display: block; }
101
+ #help { left: 12px; bottom: max(10px, env(safe-area-inset-bottom)); font-size: 10px; letter-spacing: 0.08em; color: var(--dim); }
102
+ html[data-host="netnet"] #invite { display: none; }
103
+ html[data-preview="1"] .hud { display: none !important; }
104
+ @media (pointer: coarse) { #help { display: none; } }
105
+ @media (max-width: 560px) {
106
+ #top-right { width: 58vw; }
107
+ #top-right button { padding: 5px 8px; font-size: 12px; }
108
+ #goals { display: none; }
109
+ }
110
+ `;
111
+
112
+ const SHELL = (l: HudLabels) => `
113
+ <div id="game"></div>
114
+ <div id="top-left" class="hud"><div id="title"></div><div id="status"></div></div>
115
+ <div id="top-right" class="hud">
116
+ <div id="coin-box"><img id="coin-logo" alt="" width="18" height="18" hidden /><span id="coin-balance"></span></div>
117
+ <div class="row"><button id="invite">Invite</button><button id="store-btn">Store</button><button id="sound">Sound</button></div>
118
+ <ul id="goals"></ul>
119
+ </div>
120
+ <div id="end" class="hud"><div class="box"><div id="end-title"></div><div id="end-sub"></div><ul id="end-earned"></ul><div id="end-next"></div></div></div>
121
+ <aside id="store" class="hud">
122
+ <h2>STORE</h2>
123
+ <small id="coin-name"></small>
124
+ <h3>THIS RUN</h3>
125
+ <div id="store-upgrades"></div>
126
+ <h3>FINISHES</h3>
127
+ <div id="store-finishes"></div>
128
+ <div class="item" id="gear-row"><div style="display: block"><span id="gear-name"></span><br /><small id="gear-line"></small></div><button id="gear-buy" style="white-space: nowrap"></button></div>
129
+ <div class="item" id="stake-row"><div style="display: block">Stake a run<br /><small id="stake-line"></small></div><span style="white-space: nowrap"><input id="stake-amount" type="number" aria-label="Stake" style="width: 56px" /> <button id="stake-go" style="min-width: 0">Stake</button></span></div>
130
+ <small id="store-note"></small>
131
+ <br /><button id="store-close">Close</button>
132
+ </aside>
133
+ <div id="toast" class="hud"></div>
134
+ <div id="stats" class="hud"></div>
135
+ <div id="help" class="hud">${l.help}</div>
136
+ <div id="banner" class="hud"><b>${l.title.toUpperCase()}</b><span id="banner-text">${l.loading}</span></div>
137
+ `;
138
+
139
+ export const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
140
+ const e = document.getElementById(id);
141
+ if (!e) throw new Error(`#${id} missing`);
142
+ return e as T;
143
+ };
144
+
145
+ /** A swatch colour as CSS. */
146
+ export const cssColor = (c: number): string => `#${c.toString(16).padStart(6, "0")}`;
147
+
148
+ export interface EarnProgress {
149
+ readonly id: string;
150
+ readonly when: string;
151
+ readonly n: number;
152
+ readonly have: number;
153
+ readonly paid: boolean;
154
+ }
155
+
156
+ export class Hud {
157
+ private toastTimer = 0;
158
+ private symbol = "";
159
+ mode: PlayerMode = "earning";
160
+ private goalsKey = "";
161
+ private ended = false;
162
+ private earnedRun = new Map<string, number>();
163
+
164
+ constructor(
165
+ private readonly labels: HudLabels,
166
+ theme: HudTheme,
167
+ private readonly manifest: GameManifest,
168
+ ) {
169
+ const style = document.createElement("style");
170
+ style.textContent = CSS(theme);
171
+ document.head.append(style);
172
+ document.body.insertAdjacentHTML("afterbegin", SHELL(labels));
173
+ $("title").textContent = labels.title.toUpperCase();
174
+ document.title = labels.title;
175
+ }
176
+
177
+ /** The wordmark (Net Forge names the game). */
178
+ title(name: string): void {
179
+ $("title").textContent = name.toUpperCase();
180
+ document.title = name;
181
+ const b = document.querySelector("#banner b");
182
+ if (b) b.textContent = name.toUpperCase();
183
+ }
184
+
185
+ coin(name: string, symbol: string, logo: string | null): void {
186
+ this.symbol = symbol;
187
+ const img = $<HTMLImageElement>("coin-logo");
188
+ if (logo) {
189
+ img.src = logo;
190
+ img.alt = name;
191
+ img.hidden = false;
192
+ } else img.hidden = true;
193
+ $("coin-name").textContent = name;
194
+ }
195
+
196
+ get coinSymbol(): string {
197
+ return this.symbol;
198
+ }
199
+
200
+ /** What play pays the person at the screen (Net Forge's flag). */
201
+ player(mode: PlayerMode): void {
202
+ this.mode = mode;
203
+ const box = $("coin-box");
204
+ box.classList.toggle("guest", mode === "guest");
205
+ box.classList.toggle("play", mode === "play");
206
+ box.title = mode === "guest" ? `Connect a wallet to earn ${this.symbol}` : mode === "play" ? `${this.symbol} has not launched: coins are play money` : "";
207
+ }
208
+
209
+ balance(n: number): void {
210
+ $("coin-balance").textContent = this.mode === "guest" ? `${this.symbol} · not earning` : `${Math.floor(n).toLocaleString("en-US")} ${this.symbol}`;
211
+ }
212
+
213
+ toast(text: string): void {
214
+ const t = $("toast");
215
+ t.textContent = text;
216
+ t.classList.add("show");
217
+ clearTimeout(this.toastTimer);
218
+ this.toastTimer = window.setTimeout(() => t.classList.remove("show"), 2400);
219
+ }
220
+
221
+ /** The room credited an earn rule. */
222
+ earned(rule: string, units: number): void {
223
+ this.earnedRun.set(rule, (this.earnedRun.get(rule) ?? 0) + units);
224
+ const pay = (this.manifest.earnRules.get(rule)?.pay ?? 0) * units;
225
+ const name = this.labels.rules[rule] ?? rule;
226
+ this.toast(this.mode === "guest" ? `${name} Connect to earn ${pay} ${this.symbol}` : `${name} +${pay} ${this.symbol}`);
227
+ }
228
+
229
+ /** The run's goals with this player's progress (the room's counts). */
230
+ goals(list: readonly EarnProgress[] | undefined): void {
231
+ if (!list) return;
232
+ const key = list.map((g) => `${g.id}:${g.have}:${g.paid}`).join("|");
233
+ if (key === this.goalsKey) return;
234
+ this.goalsKey = key;
235
+ const el = $("goals");
236
+ el.innerHTML = "";
237
+ for (const g of list) {
238
+ const li = document.createElement("li");
239
+ if (g.paid) li.className = "paid";
240
+ const pay = this.manifest.earnRules.get(g.id)?.pay ?? 0;
241
+ li.innerHTML = `<span></span><b></b>`;
242
+ (li.firstChild as HTMLElement).textContent = this.labels.rules[g.id] ?? g.id;
243
+ (li.lastChild as HTMLElement).textContent = g.paid ? `+${pay}` : g.n > 1 ? `${g.have}/${g.n}` : `${pay}`;
244
+ el.append(li);
245
+ }
246
+ }
247
+
248
+ /** Status chips at the top left: [label, value] pairs. */
249
+ status(chips: readonly (readonly [string, string])[]): void {
250
+ const el = $("status");
251
+ const want = chips.map(([a, b]) => `${a}\u0000${b}`).join("\u0001");
252
+ if (el.dataset.k === want) return;
253
+ el.dataset.k = want;
254
+ el.innerHTML = "";
255
+ for (const [label, value] of chips) {
256
+ const c = document.createElement("div");
257
+ c.className = "chip";
258
+ c.innerHTML = `<small></small><span></span>`;
259
+ (c.firstChild as HTMLElement).textContent = label;
260
+ (c.lastChild as HTMLElement).textContent = value;
261
+ el.append(c);
262
+ }
263
+ }
264
+
265
+ /** The end screen while the room shows it. */
266
+ end(show: boolean, title: string, sub: string, endsIn: number | null): void {
267
+ const el = $("end");
268
+ if (show && !this.ended) {
269
+ const list = $("end-earned");
270
+ list.innerHTML = "";
271
+ for (const [rule, units] of this.earnedRun) {
272
+ const li = document.createElement("li");
273
+ li.className = this.mode === "guest" ? "guest" : "";
274
+ li.innerHTML = `<span></span><b></b>`;
275
+ (li.firstChild as HTMLElement).textContent = this.labels.rules[rule] ?? rule;
276
+ (li.lastChild as HTMLElement).textContent = `+${(this.manifest.earnRules.get(rule)?.pay ?? 0) * units} ${this.symbol}`;
277
+ list.append(li);
278
+ }
279
+ if (this.earnedRun.size === 0) list.innerHTML = `<li class="none">No goals this run</li>`;
280
+ }
281
+ if (!show && this.ended) this.earnedRun.clear();
282
+ this.ended = show;
283
+ el.classList.toggle("show", show);
284
+ if (show) {
285
+ $("end-title").textContent = title;
286
+ $("end-sub").textContent = sub;
287
+ $("end-next").textContent = endsIn !== null ? `NEXT RUN IN ${Math.ceil(endsIn)}` : "";
288
+ }
289
+ }
290
+
291
+ stats(text: string): void {
292
+ $("stats").textContent = text;
293
+ }
294
+
295
+ ready(): void {
296
+ document.getElementById("banner")?.remove();
297
+ }
298
+
299
+ failed(message: string): void {
300
+ const t = document.getElementById("banner-text");
301
+ if (t) t.textContent = message;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * The store: run upgrades (a SKU named after a declared upgrade: spent, then
307
+ * the room applies one level for this run), finishes (owned for good,
308
+ * equipped by a room command), the first losable gear, and the stake.
309
+ */
310
+ export function wireStore(opts: {
311
+ readonly hud: Hud;
312
+ readonly manifest: GameManifest;
313
+ readonly kit: KitStoreBridge;
314
+ readonly conn: RoomConnection<unknown, unknown>;
315
+ readonly labels: HudLabels;
316
+ readonly cosmetics: Readonly<Record<string, number>>;
317
+ readonly swatch: (cosmetic: number) => string;
318
+ readonly hostMode: () => PlayerMode | null;
319
+ readonly unlock?: () => void;
320
+ }): { toggle(): void } {
321
+ const { hud, manifest, kit, conn, labels } = opts;
322
+ const sym = kit.coins.symbol();
323
+ const prices: Record<string, SkuPrice> = Object.fromEntries(manifest.skus);
324
+ const upgradesEl = $("store-upgrades");
325
+ for (const [id, s] of manifest.skus) {
326
+ if (s.grants || s.coins === undefined || !manifest.upgrades.has(id)) continue;
327
+ const price = s.coins;
328
+ const row = document.createElement("div");
329
+ row.className = "item";
330
+ row.innerHTML = `<div><div><span class="name"></span><br /><small></small></div></div><button></button>`;
331
+ (row.querySelector(".name") as HTMLElement).textContent = labels.skus[id] ?? id;
332
+ (row.querySelector("small") as HTMLElement).textContent = labels.upgrades[id]?.line ?? "";
333
+ const btn = row.querySelector("button") as HTMLButtonElement;
334
+ btn.textContent = `${price} ${sym}`;
335
+ btn.addEventListener("click", async () => {
336
+ opts.unlock?.();
337
+ let proof: string | null;
338
+ try {
339
+ proof = (await kit.coins.spend(price, id)).key;
340
+ } catch {
341
+ hud.toast(`Not enough ${sym}`);
342
+ return;
343
+ }
344
+ const r = await conn.command({ name: id, ...(proof ? { proof } : {}) });
345
+ hud.toast(r.ok ? `${labels.skus[id] ?? id} on` : r.reason === "store not live" ? "Store opens at launch" : "Already on this run");
346
+ });
347
+ upgradesEl.append(row);
348
+ }
349
+ const finishesEl = $("store-finishes");
350
+ for (const [id, cosmetic] of Object.entries(opts.cosmetics)) {
351
+ const price = prices[id]?.coins;
352
+ if (price === undefined) continue;
353
+ const row = document.createElement("div");
354
+ row.className = "item";
355
+ row.innerHTML = `<div><span class="swatch"></span><div><span class="name"></span><br /><small>Finish</small></div></div><button></button>`;
356
+ (row.querySelector(".swatch") as HTMLElement).style.background = opts.swatch(cosmetic);
357
+ (row.querySelector(".name") as HTMLElement).textContent = labels.skus[id] ?? id;
358
+ const btn = row.querySelector("button") as HTMLButtonElement;
359
+ btn.textContent = `${price} ${sym}`;
360
+ btn.addEventListener("click", async () => {
361
+ opts.unlock?.();
362
+ if (!kit.store.owns(id)) {
363
+ try {
364
+ await kit.store.buy(id, { pay: "coins" });
365
+ } catch {
366
+ hud.toast(`Not enough ${sym}`);
367
+ return;
368
+ }
369
+ }
370
+ const r = await conn.command({ name: "cosmetic", value: cosmetic });
371
+ if (!r.ok) return hud.toast("Finishes open at launch");
372
+ for (const b of finishesEl.querySelectorAll("button")) if (b !== btn && b.textContent === "Equipped") b.textContent = "Equip";
373
+ btn.textContent = "Equipped";
374
+ });
375
+ finishesEl.append(row);
376
+ }
377
+ wireStake(conn as never, kit, (t) => hud.toast(t));
378
+ wireGear(gearOf(manifest.skus, labels.skus), kit, (t) => hud.toast(t));
379
+ const note = $("store-note");
380
+ const showNote = () => (note.textContent = storeNote(opts.hostMode(), conn.welcome.earnLive, sym));
381
+ showNote();
382
+ let open = false;
383
+ const toggle = () => {
384
+ open = !open;
385
+ $("store").classList.toggle("show", open);
386
+ showNote();
387
+ };
388
+ $("store-btn").addEventListener("click", toggle);
389
+ $("store-close").addEventListener("click", toggle);
390
+ return { toggle };
391
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @netforge/tabletop: the client kit the board templates share
3
+ * (COGSPIRE and the other rules-genre templates). Templates import it from
4
+ * their client; generated game code never does.
5
+ */
6
+ export * from "./boot.js";
7
+ export * from "./connect.js";
8
+ export * from "./host.js";
9
+ export * from "./hud.js";
10
+ export * from "./kit.js";
11
+ export * from "./stage.js";
12
+ export * from "./stake.js";
package/src/kit.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The Kit's store and coins as a board template's client calls them
3
+ * (the Coins model): every game has coins,
4
+ * 1 coin = 1 game token, shown under the token's own name, symbol and logo.
5
+ *
6
+ * `KitStoreBridge` is the slice of @netforge/kit's `Kit` the clients
7
+ * call, with the Kit's own names (`kit.coins.*`, `kit.store.owns/buy`), so
8
+ * the real Kit drops in unchanged. Until a game's token is live `mockKit()`
9
+ * stands in, in memory. Earned coins are spent first (off chain, no wallet
10
+ * prompt), then the wallet, exactly as the Kit does.
11
+ */
12
+ export interface KitStoreBridge {
13
+ readonly store: {
14
+ owns(id: string): boolean;
15
+ count(id: string): number;
16
+ buy(id: string, opts?: { pay?: "usdg" | "token" | "coins" }): Promise<unknown>;
17
+ };
18
+ readonly coins: {
19
+ /** The token's own name, symbol and logo (its metadata). Never a generic "COIN". */
20
+ name(): string;
21
+ symbol(): string;
22
+ logo(): string | null;
23
+ /** Whole coins: earned (unclaimed) plus the wallet. */
24
+ balance(): number;
25
+ /** Earned first, then the wallet. `key` proves the paid room command (sent as the command's `proof`). */
26
+ spend(amount: number, reason: string): Promise<{ key: string | null }>;
27
+ };
28
+ }
29
+
30
+ export interface SkuPrice {
31
+ readonly coins?: number;
32
+ readonly usdg?: number;
33
+ }
34
+
35
+ export interface MockKit extends KitStoreBridge {
36
+ /** Dollars left (mock only). */
37
+ usdg(): number;
38
+ /** Earned, unclaimed coins left (mock only). */
39
+ earned(): number;
40
+ }
41
+
42
+ /** An in-memory Kit for local play and tests: starting balances, prices from game.ts. */
43
+ export function mockKit(
44
+ start: { earned: number; wallet: number; usdg: number },
45
+ prices: Readonly<Record<string, SkuPrice>>,
46
+ coin: { name: string; symbol: string; logo: string | null },
47
+ ): MockKit {
48
+ let earned = start.earned;
49
+ let wallet = start.wallet;
50
+ let usdg = start.usdg;
51
+ const owned = new Map<string, number>();
52
+ let spends = 0;
53
+ /** Earned first, then the wallet: the Kit's order. */
54
+ const spend = (amount: number, reason: string) => {
55
+ if (!Number.isInteger(amount) || amount <= 0 || earned + wallet < amount) throw new Error(`cannot spend ${amount} for ${reason}`);
56
+ const fromEarned = Math.min(earned, amount);
57
+ earned -= fromEarned;
58
+ wallet -= amount - fromEarned;
59
+ return { fromEarned, onChain: amount - fromEarned, key: `mock-spend-${++spends}` };
60
+ };
61
+ return {
62
+ usdg: () => usdg,
63
+ earned: () => earned,
64
+ store: {
65
+ owns: (id) => (owned.get(id) ?? 0) > 0,
66
+ count: (id) => owned.get(id) ?? 0,
67
+ async buy(id, opts) {
68
+ const p = prices[id];
69
+ if (!p) throw new Error(`no SKU "${id}"`);
70
+ const payUsdg = opts?.pay === "usdg" || (opts?.pay === undefined && p.coins === undefined);
71
+ if (payUsdg) {
72
+ if (p.usdg === undefined || usdg < p.usdg) throw new Error("not enough USDG");
73
+ usdg -= p.usdg;
74
+ } else {
75
+ if (p.coins === undefined) throw new Error(`"${id}" has no coin price`);
76
+ spend(p.coins, id);
77
+ }
78
+ owned.set(id, (owned.get(id) ?? 0) + 1);
79
+ return { id };
80
+ },
81
+ },
82
+ coins: {
83
+ name: () => coin.name,
84
+ symbol: () => coin.symbol,
85
+ logo: () => coin.logo,
86
+ balance: () => earned + wallet,
87
+ async spend(amount, reason) {
88
+ return spend(amount, reason);
89
+ },
90
+ },
91
+ };
92
+ }