@spawndotfamily/sdk 0.2.7
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/AGENTS.md +55 -0
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/cli/api.d.ts +19 -0
- package/dist/cli/api.js +187 -0
- package/dist/cli/files.d.ts +4 -0
- package/dist/cli/files.js +40 -0
- package/dist/cli/index.d.ts +61 -0
- package/dist/cli/index.js +503 -0
- package/dist/cli/listing.d.ts +14 -0
- package/dist/cli/listing.js +155 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +18 -0
- package/dist/cli/upload-client.d.ts +84 -0
- package/dist/cli/upload-client.js +737 -0
- package/dist/dev/economy.d.ts +29 -0
- package/dist/dev/economy.js +49 -0
- package/dist/dev/host.d.ts +1 -0
- package/dist/dev/host.js +225 -0
- package/dist/dev/panel.d.ts +6 -0
- package/dist/dev/panel.js +63 -0
- package/dist/dev/run.d.ts +2 -0
- package/dist/dev/run.js +19 -0
- package/dist/dev/server.d.ts +5 -0
- package/dist/dev/server.js +188 -0
- package/dist/dev/shell.d.ts +1 -0
- package/dist/dev/shell.js +18 -0
- package/dist/dev/state.d.ts +33 -0
- package/dist/dev/state.js +77 -0
- package/dist/dev/styles.d.ts +1 -0
- package/dist/dev/styles.js +25 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +403 -0
- package/dist/multiplayer.d.ts +17 -0
- package/dist/multiplayer.js +174 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +112 -0
- package/dist/startup.d.ts +21 -0
- package/dist/startup.js +85 -0
- package/docs/creator-checklist.md +62 -0
- package/docs/integration.md +69 -0
- package/docs/multiplayer.md +89 -0
- package/docs/publishing.md +115 -0
- package/docs/security.md +83 -0
- package/docs/startup.md +35 -0
- package/docs/testing.md +90 -0
- package/examples/creator-server.js +16 -0
- package/examples/github-browser-build.yml +28 -0
- package/examples/multiplayer-game.js +38 -0
- package/examples/preview-game.js +13 -0
- package/package.json +69 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type LocalPlayer = 'alice' | 'bob' | 'empty';
|
|
2
|
+
type Account = LocalPlayer | 'creator' | 'pool' | 'platform';
|
|
3
|
+
export type LocalTransfer = {
|
|
4
|
+
id: string;
|
|
5
|
+
kind: 'entry' | 'funding' | 'reward' | 'withdrawal';
|
|
6
|
+
from: Account;
|
|
7
|
+
to: Account;
|
|
8
|
+
amount: number;
|
|
9
|
+
platformFee: number;
|
|
10
|
+
netAmount: number;
|
|
11
|
+
feeBps: number;
|
|
12
|
+
};
|
|
13
|
+
export declare function localPlayer(value: string): asserts value is LocalPlayer;
|
|
14
|
+
/** Launcher-owned fake ledger. Never exposed through the game's MessageChannel. */
|
|
15
|
+
export declare class LocalTestEconomy {
|
|
16
|
+
private balances;
|
|
17
|
+
private transfers;
|
|
18
|
+
readonly feeBps: number;
|
|
19
|
+
constructor(feeBps?: number);
|
|
20
|
+
balance(account: string): number;
|
|
21
|
+
get history(): LocalTransfer[];
|
|
22
|
+
private transfer;
|
|
23
|
+
/** Called only after LocalTestState has checked the pending payment quote. */
|
|
24
|
+
entry(player: string, receiptId: string): LocalTransfer;
|
|
25
|
+
fund(amount: number): LocalTransfer;
|
|
26
|
+
withdraw(amount: number): LocalTransfer;
|
|
27
|
+
reward(player: string, amount: number): LocalTransfer;
|
|
28
|
+
}
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function localPlayer(value) {
|
|
2
|
+
if (!['alice', 'bob', 'empty'].includes(value))
|
|
3
|
+
throw new Error('Unknown local test player.');
|
|
4
|
+
}
|
|
5
|
+
/** Launcher-owned fake ledger. Never exposed through the game's MessageChannel. */
|
|
6
|
+
export class LocalTestEconomy {
|
|
7
|
+
balances = { alice: 10000, bob: 10000, empty: 0, creator: 100000, pool: 0, platform: 0 };
|
|
8
|
+
transfers = [];
|
|
9
|
+
feeBps;
|
|
10
|
+
constructor(feeBps = 500) {
|
|
11
|
+
if (!Number.isSafeInteger(feeBps) || feeBps < 0 || feeBps > 10000)
|
|
12
|
+
throw new Error("Invalid platform fee rate.");
|
|
13
|
+
this.feeBps = feeBps;
|
|
14
|
+
}
|
|
15
|
+
balance(account) {
|
|
16
|
+
if (!Object.hasOwn(this.balances, account))
|
|
17
|
+
throw new Error('Unknown local test account.');
|
|
18
|
+
return this.balances[account] / 100;
|
|
19
|
+
}
|
|
20
|
+
get history() { return structuredClone(this.transfers); }
|
|
21
|
+
transfer(from, to, amount, kind, id = 'local_' + crypto.randomUUID()) {
|
|
22
|
+
const minor = Math.round(amount * 100);
|
|
23
|
+
if (!Number.isFinite(amount) || amount <= 0 || Number(amount.toFixed(2)) !== amount || !Number.isSafeInteger(minor))
|
|
24
|
+
throw new Error('Use positive TEST amounts with at most two decimal places.');
|
|
25
|
+
if (this.balances[from] < minor)
|
|
26
|
+
throw new Error('Insufficient local test balance.');
|
|
27
|
+
const fee = to === 'pool' ? Number(BigInt(minor) * BigInt(this.feeBps) / 10000n) : 0;
|
|
28
|
+
if (!Number.isSafeInteger(this.balances[to] + minor - fee) || !Number.isSafeInteger(this.balances.platform + fee))
|
|
29
|
+
throw new Error('Local test balance limit reached.');
|
|
30
|
+
const receipt = { id, kind, from, to, amount, platformFee: fee / 100, netAmount: (minor - fee) / 100, feeBps: to === 'pool' ? this.feeBps : 0 };
|
|
31
|
+
this.balances[from] -= minor;
|
|
32
|
+
this.balances[to] += minor - fee;
|
|
33
|
+
this.balances.platform += fee;
|
|
34
|
+
this.transfers.unshift(receipt);
|
|
35
|
+
this.transfers.splice(100);
|
|
36
|
+
return structuredClone(receipt);
|
|
37
|
+
}
|
|
38
|
+
/** Called only after LocalTestState has checked the pending payment quote. */
|
|
39
|
+
entry(player, receiptId) {
|
|
40
|
+
localPlayer(player);
|
|
41
|
+
return this.transfer(player, 'pool', 10, 'entry', receiptId);
|
|
42
|
+
}
|
|
43
|
+
fund(amount) { return this.transfer('creator', 'pool', amount, 'funding'); }
|
|
44
|
+
withdraw(amount) { return this.transfer('pool', 'creator', amount, 'withdrawal'); }
|
|
45
|
+
reward(player, amount) {
|
|
46
|
+
localPlayer(player);
|
|
47
|
+
return this.transfer('pool', player, amount, 'reward');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/dev/host.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { LocalTestState } from "./state.js";
|
|
2
|
+
import { createCreatorPanel } from "./panel.js";
|
|
3
|
+
const element = (id) => document.getElementById(id);
|
|
4
|
+
const player = { value: 'alice' };
|
|
5
|
+
const playerButtons = Array.from(document.querySelectorAll('[data-player]'));
|
|
6
|
+
const status = element('status');
|
|
7
|
+
const dialog = element('payment');
|
|
8
|
+
const confirm = element('confirm'), cancel = element('cancel'), next = element('continue');
|
|
9
|
+
let connectedState = false;
|
|
10
|
+
let receiptStatus = 'idle';
|
|
11
|
+
let lastReceipt = null;
|
|
12
|
+
let state = new LocalTestState(), dispose;
|
|
13
|
+
let payment = null;
|
|
14
|
+
const devSnapshot = () => structuredClone({
|
|
15
|
+
environment: 'local-test', connected: connectedState, player: state.identity(player.value),
|
|
16
|
+
lastScore: state.scores[0] ?? null, receiptStatus, lastReceipt,
|
|
17
|
+
balances: { player: state.balance(player.value), pool: state.economy.balance('pool'), platform: state.economy.balance('platform') }
|
|
18
|
+
});
|
|
19
|
+
Object.defineProperty(window, '__SPAWN_DEV_STATE__', { get: devSnapshot });
|
|
20
|
+
const updateDiagnostics = () => { element('spawn-dev-state').textContent = JSON.stringify(devSnapshot()); };
|
|
21
|
+
const panel = createCreatorPanel(() => state, () => player.value, updateDiagnostics);
|
|
22
|
+
function refresh() { panel.refresh(); }
|
|
23
|
+
function closePayment() {
|
|
24
|
+
if (payment) {
|
|
25
|
+
if (receiptStatus !== 'paid')
|
|
26
|
+
receiptStatus = 'cancelled';
|
|
27
|
+
state.cancel(payment.quote.id);
|
|
28
|
+
payment.reject(new Error('Local payment cancelled.'));
|
|
29
|
+
}
|
|
30
|
+
payment = null;
|
|
31
|
+
dialog.close();
|
|
32
|
+
refresh();
|
|
33
|
+
}
|
|
34
|
+
function requestPayment(launch, product) {
|
|
35
|
+
if (payment)
|
|
36
|
+
throw new Error('A payment confirmation is already open.');
|
|
37
|
+
const quote = state.quote(player.value, launch, product);
|
|
38
|
+
if (quote.receipt)
|
|
39
|
+
return Promise.resolve(quote.receipt);
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
payment = { quote, resolve, reject };
|
|
42
|
+
receiptStatus = 'pending';
|
|
43
|
+
refresh();
|
|
44
|
+
element('payment-title').textContent = 'Confirm test payment';
|
|
45
|
+
element('payment-title').className = '';
|
|
46
|
+
element('payment-copy').textContent = 'Pay 10 TEST? The game pool receives 9.5 TEST; Spawn receives 0.5 TEST (5%, included).';
|
|
47
|
+
confirm.hidden = cancel.hidden = false;
|
|
48
|
+
next.hidden = true;
|
|
49
|
+
confirm.disabled = false;
|
|
50
|
+
document.exitPointerLock?.();
|
|
51
|
+
dialog.showModal();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
cancel.onclick = closePayment;
|
|
55
|
+
dialog.addEventListener('cancel', event => { event.preventDefault(); if (!next.hidden)
|
|
56
|
+
return; closePayment(); });
|
|
57
|
+
confirm.onclick = () => {
|
|
58
|
+
if (!payment)
|
|
59
|
+
return;
|
|
60
|
+
const current = payment;
|
|
61
|
+
confirm.disabled = true;
|
|
62
|
+
element('payment-copy').textContent = 'Processing local test payment…';
|
|
63
|
+
// Let the processing state paint; there is no remote settlement in this launcher.
|
|
64
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
65
|
+
if (payment !== current)
|
|
66
|
+
return;
|
|
67
|
+
try {
|
|
68
|
+
lastReceipt = state.confirm(current.quote.id);
|
|
69
|
+
receiptStatus = 'paid';
|
|
70
|
+
refresh();
|
|
71
|
+
element('payment-title').textContent = 'Paid';
|
|
72
|
+
element('payment-title').className = 'paid';
|
|
73
|
+
element('payment-copy').textContent = 'Paid 10 TEST: 9.5 to the game pool and 0.5 to Spawn.';
|
|
74
|
+
confirm.hidden = cancel.hidden = true;
|
|
75
|
+
next.hidden = false;
|
|
76
|
+
next.focus();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
receiptStatus = 'failed';
|
|
80
|
+
current.reject(error instanceof Error ? error : new Error('Local payment failed.'));
|
|
81
|
+
payment = null;
|
|
82
|
+
dialog.close();
|
|
83
|
+
refresh();
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
};
|
|
87
|
+
next.onclick = () => {
|
|
88
|
+
if (!payment)
|
|
89
|
+
return;
|
|
90
|
+
const current = payment;
|
|
91
|
+
payment = null;
|
|
92
|
+
dialog.close();
|
|
93
|
+
refresh();
|
|
94
|
+
current.resolve(state.confirm(current.quote.id));
|
|
95
|
+
};
|
|
96
|
+
function openGame() {
|
|
97
|
+
dispose?.();
|
|
98
|
+
closePayment();
|
|
99
|
+
receiptStatus = 'idle';
|
|
100
|
+
lastReceipt = null;
|
|
101
|
+
refresh();
|
|
102
|
+
const token = document.body.dataset.documentToken;
|
|
103
|
+
const launch = crypto.randomUUID(), identity = player.value;
|
|
104
|
+
const frame = document.createElement('iframe');
|
|
105
|
+
frame.title = 'Local test game';
|
|
106
|
+
frame.sandbox.add('allow-scripts', 'allow-pointer-lock');
|
|
107
|
+
frame.allow = 'autoplay; fullscreen; gamepad';
|
|
108
|
+
frame.referrerPolicy = 'no-referrer';
|
|
109
|
+
let active = true, connected = false, ready = false, loads = 0, nonce = '', requests = 0;
|
|
110
|
+
let port = null;
|
|
111
|
+
const queued = [], pending = new Set();
|
|
112
|
+
let timer;
|
|
113
|
+
const reset = setInterval(() => { requests = 0; }, 60000);
|
|
114
|
+
const stop = () => { connectedState = false; active = false; clearTimeout(timer); clearInterval(reset); port?.close(); window.removeEventListener('message', receive); closePayment(); status.textContent = 'Disconnected. Reopen the game to reconnect.'; refresh(); };
|
|
115
|
+
async function dispatch(raw) {
|
|
116
|
+
const data = raw;
|
|
117
|
+
if (!active || !ready || !data || data.type !== 'spawn:request' || data.version !== 1 || typeof data.id !== 'string' || data.id.length > 80 || pending.has(data.id))
|
|
118
|
+
return;
|
|
119
|
+
const send = (ok, value) => { if (active)
|
|
120
|
+
port?.postMessage({ type: 'spawn:response', version: 1, id: data.id, ok, ...(ok ? { value } : { error: value }) }); };
|
|
121
|
+
if (++requests > 100 || pending.size >= 20) {
|
|
122
|
+
send(false, 'Too many local test requests.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
pending.add(data.id);
|
|
126
|
+
try {
|
|
127
|
+
const payload = data.payload ?? {};
|
|
128
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || JSON.stringify(payload).length > 16000)
|
|
129
|
+
throw new Error('Invalid request payload.');
|
|
130
|
+
let value;
|
|
131
|
+
if (data.method === 'identity') {
|
|
132
|
+
value = state.identity(identity);
|
|
133
|
+
status.textContent = `Connected as ${state.identity(identity).displayName}`;
|
|
134
|
+
}
|
|
135
|
+
else if (data.method === 'load')
|
|
136
|
+
value = state.load(identity, String(payload.key));
|
|
137
|
+
else if (data.method === 'save')
|
|
138
|
+
value = state.save(identity, String(payload.key), payload.value, payload.expectedVersion);
|
|
139
|
+
else if (data.method === 'submitScore') {
|
|
140
|
+
value = state.score(identity, payload.score, payload.details);
|
|
141
|
+
refresh();
|
|
142
|
+
}
|
|
143
|
+
else if (data.method === 'requestPayment')
|
|
144
|
+
value = await requestPayment(launch, String(payload.productId));
|
|
145
|
+
else
|
|
146
|
+
throw new Error('Unsupported Spawn operation.');
|
|
147
|
+
send(true, value);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
send(false, error instanceof Error ? error.message : 'Local test request failed.');
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
pending.delete(data.id);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const probe = () => { if (!active || !port || loads !== 1 || nonce || ready)
|
|
157
|
+
return; nonce = crypto.randomUUID(); port.postMessage({ type: 'spawn:ready', version: 1, nonce }); timer = setTimeout(stop, 10000); };
|
|
158
|
+
function receive(event) {
|
|
159
|
+
if (!active || connected || event.source !== frame.contentWindow || event.origin !== 'null' || event.data?.type !== 'spawn:connect' || event.data?.version !== 1 || event.data?.documentToken !== token)
|
|
160
|
+
return;
|
|
161
|
+
connected = true;
|
|
162
|
+
const channel = new MessageChannel();
|
|
163
|
+
port = channel.port1;
|
|
164
|
+
port.onmessage = ({ data }) => {
|
|
165
|
+
if (!active)
|
|
166
|
+
return;
|
|
167
|
+
if (data?.type === 'spawn:ready-ack') {
|
|
168
|
+
if (!ready && nonce && data.nonce === nonce && data.version === 1 && loads === 1) {
|
|
169
|
+
ready = true;
|
|
170
|
+
connectedState = true;
|
|
171
|
+
refresh();
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
for (const request of queued.splice(0))
|
|
174
|
+
void dispatch(request);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (ready)
|
|
178
|
+
void dispatch(data);
|
|
179
|
+
else if (queued.length < 20)
|
|
180
|
+
queued.push(data);
|
|
181
|
+
else
|
|
182
|
+
stop();
|
|
183
|
+
};
|
|
184
|
+
frame.contentWindow?.postMessage({ type: 'spawn:connected', version: 1 }, '*', [channel.port2]);
|
|
185
|
+
probe();
|
|
186
|
+
}
|
|
187
|
+
window.addEventListener('message', receive);
|
|
188
|
+
frame.onload = () => { if (++loads > 1)
|
|
189
|
+
stop();
|
|
190
|
+
else
|
|
191
|
+
probe(); };
|
|
192
|
+
frame.src = `/build/${token}/index.html`;
|
|
193
|
+
status.textContent = 'Waiting for the SDK…';
|
|
194
|
+
element('frame-slot').replaceChildren(frame);
|
|
195
|
+
dispose = stop;
|
|
196
|
+
}
|
|
197
|
+
element('reopen').onclick = async () => {
|
|
198
|
+
const button = element('reopen');
|
|
199
|
+
button.disabled = true;
|
|
200
|
+
dispose?.();
|
|
201
|
+
status.textContent = 'Checking rebuilt files…';
|
|
202
|
+
try {
|
|
203
|
+
const response = await fetch('/__spawn/rescan', { method: 'POST' });
|
|
204
|
+
if (!response.ok)
|
|
205
|
+
throw new Error('Build not ready. Finish rebuilding and try again.');
|
|
206
|
+
const value = await response.json();
|
|
207
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(value.documentToken ?? ''))
|
|
208
|
+
throw new Error('Invalid rescan response.');
|
|
209
|
+
document.body.dataset.documentToken = value.documentToken;
|
|
210
|
+
openGame();
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
status.textContent = error instanceof Error ? error.message : 'Unable to rescan the build.';
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
button.disabled = false;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
element('disconnect').onclick = () => dispose?.();
|
|
220
|
+
element('reset').onclick = () => { dispose?.(); state = new LocalTestState(); receiptStatus = 'idle'; lastReceipt = null; panel.reset(); openGame(); };
|
|
221
|
+
for (const button of playerButtons)
|
|
222
|
+
button.onclick = () => { player.value = button.dataset.player; for (const item of playerButtons)
|
|
223
|
+
item.setAttribute('aria-pressed', String(item === button)); openGame(); };
|
|
224
|
+
window.addEventListener('pagehide', () => dispose?.());
|
|
225
|
+
openGame();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LocalTestState } from './state.ts';
|
|
2
|
+
/** Operator controls belong to the launcher, not to the untrusted game frame. */
|
|
3
|
+
export declare function createCreatorPanel(getState: () => LocalTestState, getPlayer: () => string, onRefresh?: () => void): {
|
|
4
|
+
refresh: () => void;
|
|
5
|
+
reset: () => void;
|
|
6
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const element = (id) => document.getElementById(id);
|
|
2
|
+
const names = { alice: 'Alice', bob: 'Bob', empty: 'Empty balance', creator: 'Creator wallet', pool: 'Game pool', platform: 'Spawn platform' };
|
|
3
|
+
/** Operator controls belong to the launcher, not to the untrusted game frame. */
|
|
4
|
+
export function createCreatorPanel(getState, getPlayer, onRefresh = () => { }) {
|
|
5
|
+
const amount = element('transfer-amount');
|
|
6
|
+
const feedback = element('transfer-feedback');
|
|
7
|
+
function refresh() {
|
|
8
|
+
const state = getState(), player = getPlayer();
|
|
9
|
+
element('balance').textContent = `${names[player]} · ${state.balance(player)} TEST`;
|
|
10
|
+
element('platform-balance').textContent = `${state.economy.balance('platform')} TEST`;
|
|
11
|
+
element('pool-balance').textContent = `${state.economy.balance('pool')} TEST`;
|
|
12
|
+
element('creator-balance').textContent = `${state.economy.balance('creator')} TEST`;
|
|
13
|
+
element('reward-player').textContent = `Reward ${names[player]}`;
|
|
14
|
+
const history = element('history');
|
|
15
|
+
history.replaceChildren();
|
|
16
|
+
for (const item of state.economy.history.slice(0, 12)) {
|
|
17
|
+
const row = document.createElement('li');
|
|
18
|
+
const title = document.createElement('strong');
|
|
19
|
+
title.textContent = `${item.amount} TEST · ${item.kind}`;
|
|
20
|
+
const route = document.createElement('span');
|
|
21
|
+
route.textContent = `${names[item.from]} → ${names[item.to]} · ${item.netAmount} TEST received · ${item.platformFee} TEST Spawn fee`;
|
|
22
|
+
const details = document.createElement('details'), summary = document.createElement('summary'), id = document.createElement('code');
|
|
23
|
+
summary.textContent = 'Local receipt';
|
|
24
|
+
id.textContent = item.id;
|
|
25
|
+
details.append(summary, id);
|
|
26
|
+
row.append(title, route, details);
|
|
27
|
+
history.append(row);
|
|
28
|
+
}
|
|
29
|
+
element('no-transactions').hidden = state.economy.history.length !== 0;
|
|
30
|
+
const scores = element('scores');
|
|
31
|
+
scores.replaceChildren();
|
|
32
|
+
for (const item of state.scores.slice(0, 5)) {
|
|
33
|
+
const row = document.createElement('li');
|
|
34
|
+
row.textContent = `${names[item.player]} · ${item.score} · unverified`;
|
|
35
|
+
scores.append(row);
|
|
36
|
+
}
|
|
37
|
+
element('no-scores').hidden = state.scores.length !== 0;
|
|
38
|
+
onRefresh();
|
|
39
|
+
}
|
|
40
|
+
function transfer(kind) {
|
|
41
|
+
try {
|
|
42
|
+
const state = getState(), quantity = amount.valueAsNumber;
|
|
43
|
+
const receipt = kind === 'reward' ? state.economy.reward(getPlayer(), quantity) : state.economy[kind](quantity);
|
|
44
|
+
feedback.textContent = `${receipt.amount} TEST paid; ${receipt.netAmount} to ${names[receipt.to]}, ${receipt.platformFee} Spawn fee.`;
|
|
45
|
+
feedback.dataset.error = 'false';
|
|
46
|
+
refresh();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
feedback.textContent = error instanceof Error ? error.message : 'Local transfer failed.';
|
|
50
|
+
feedback.dataset.error = 'true';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
element('fund-pool').onclick = () => transfer('fund');
|
|
54
|
+
element('withdraw-pool').onclick = () => transfer('withdraw');
|
|
55
|
+
element('reward-player').onclick = () => transfer('reward');
|
|
56
|
+
function reset() {
|
|
57
|
+
amount.value = '10';
|
|
58
|
+
feedback.textContent = 'Rewards go to the selected test player.';
|
|
59
|
+
feedback.dataset.error = 'false';
|
|
60
|
+
refresh();
|
|
61
|
+
}
|
|
62
|
+
return { refresh, reset };
|
|
63
|
+
}
|
package/dist/dev/run.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startLocalLauncher } from "./server.js";
|
|
3
|
+
const runtime = globalThis.process;
|
|
4
|
+
const args = runtime.argv.slice(2);
|
|
5
|
+
if (!args.length || args.includes('--help')) {
|
|
6
|
+
console.log('Usage: spawn-dev <prebuilt-browser-directory> [--port 4174]\nLocal fake accounts only. No credentials or platform connection required.');
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
try {
|
|
10
|
+
if (args.length !== 1 && !(args.length === 3 && args[1] === '--port' && /^\d+$/.test(args[2])))
|
|
11
|
+
throw new Error('Use spawn-dev <directory> [--port <number>].');
|
|
12
|
+
const { origin } = await startLocalLauncher(args[0], args[2] ? Number(args[2]) : 4174);
|
|
13
|
+
console.log(`Spawn LOCAL TESTING: ${origin}\nFake accounts and TEST balances. No platform data is accessed. Stop with Ctrl+C.`);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
console.error(error instanceof Error ? error.message : 'Local launcher failed.');
|
|
17
|
+
runtime.exitCode = 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @ts-ignore Node built-ins are provided by the CLI runtime.
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
// @ts-ignore Node built-ins are provided by the CLI runtime.
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
// @ts-ignore Node built-ins are provided by the CLI runtime.
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
// @ts-ignore Node built-ins are provided by the CLI runtime.
|
|
8
|
+
import { createReadStream } from 'node:fs';
|
|
9
|
+
import { inspectBrowserBuildForLocal, openValidatedBuildFile } from "../cli/upload-client.js";
|
|
10
|
+
import { launcherHtml } from "./shell.js";
|
|
11
|
+
import { launcherCss } from "./styles.js";
|
|
12
|
+
const MIME = { html: 'text/html; charset=utf-8', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', json: 'application/json', wasm: 'application/wasm', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', ico: 'image/x-icon', avif: 'image/avif', mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', mp4: 'video/mp4', webm: 'video/webm', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf' };
|
|
13
|
+
async function readValidatedEntry(handle, bytes) {
|
|
14
|
+
const buffer = new Uint8Array(bytes);
|
|
15
|
+
let offset = 0;
|
|
16
|
+
while (offset < bytes) {
|
|
17
|
+
const result = await handle.read(buffer, offset, bytes - offset, offset);
|
|
18
|
+
if (result.bytesRead <= 0)
|
|
19
|
+
throw new Error('The inspected entry file ended while it was being served.');
|
|
20
|
+
offset += result.bytesRead;
|
|
21
|
+
}
|
|
22
|
+
return new TextDecoder().decode(buffer);
|
|
23
|
+
}
|
|
24
|
+
export async function startLocalLauncher(directory, port = 4174) {
|
|
25
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
26
|
+
throw new Error('Invalid local launcher port.');
|
|
27
|
+
let prepared = await inspectBrowserBuildForLocal(directory);
|
|
28
|
+
let token = randomBytes(32).toString('base64url');
|
|
29
|
+
let files = new Map(prepared.preparedFiles.map(file => [file.path, file]));
|
|
30
|
+
const validationCache = new Map();
|
|
31
|
+
const modules = new Map();
|
|
32
|
+
for (const name of ['host', 'state', 'economy', 'panel']) {
|
|
33
|
+
modules.set('/__spawn/' + name + '.js', await readFile(new URL('./' + name + '.js', import.meta.url), 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
let origin = '';
|
|
36
|
+
let rescanning = false;
|
|
37
|
+
const handleRequest = async (request, response) => {
|
|
38
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
39
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
40
|
+
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
41
|
+
if (origin && request.headers.host === new URL(origin).host && request.url === '/__spawn/rescan' && request.method === 'POST') {
|
|
42
|
+
if (request.headers.origin !== origin) {
|
|
43
|
+
response.writeHead(403).end();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (rescanning) {
|
|
47
|
+
response.writeHead(409).end('A rescan is already running.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
rescanning = true;
|
|
51
|
+
try {
|
|
52
|
+
const next = await inspectBrowserBuildForLocal(directory);
|
|
53
|
+
prepared = next;
|
|
54
|
+
files = new Map(next.preparedFiles.map(file => [file.path, file]));
|
|
55
|
+
validationCache.clear();
|
|
56
|
+
token = randomBytes(32).toString('base64url');
|
|
57
|
+
response.setHeader('Content-Type', 'application/json');
|
|
58
|
+
response.writeHead(200).end(JSON.stringify({ documentToken: token }));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
response.writeHead(422).end('Build not ready. Finish rebuilding, then retry Rebuild / reload.');
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
rescanning = false;
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!origin || request.headers.host !== new URL(origin).host || !['GET', 'HEAD'].includes(request.method)) {
|
|
69
|
+
response.writeHead(404).end();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const path = request.url.split('?')[0];
|
|
73
|
+
let body, type = 'text/html; charset=utf-8';
|
|
74
|
+
if (path === '/')
|
|
75
|
+
body = launcherHtml(token);
|
|
76
|
+
else if (modules.has(path)) {
|
|
77
|
+
body = modules.get(path);
|
|
78
|
+
type = 'text/javascript';
|
|
79
|
+
}
|
|
80
|
+
else if (path === '/__spawn/style.css') {
|
|
81
|
+
body = launcherCss;
|
|
82
|
+
type = 'text/css';
|
|
83
|
+
}
|
|
84
|
+
else if (path.startsWith('/build/' + token + '/')) {
|
|
85
|
+
let name = '';
|
|
86
|
+
try {
|
|
87
|
+
name = decodeURIComponent(path.slice(token.length + 8));
|
|
88
|
+
}
|
|
89
|
+
catch { /* Invalid path stays unavailable. */ }
|
|
90
|
+
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
91
|
+
response.setHeader('Content-Security-Policy', `sandbox allow-scripts allow-pointer-lock; default-src 'self' blob: data:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'self'; frame-ancestors ${origin}`);
|
|
92
|
+
const file = files.get(name);
|
|
93
|
+
if (!file) {
|
|
94
|
+
response.writeHead(404).end('File unavailable.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
type = MIME[name.split('.').at(-1).toLowerCase()] ?? 'application/octet-stream';
|
|
98
|
+
const cache = validationCache.get(name) ?? {};
|
|
99
|
+
validationCache.set(name, cache);
|
|
100
|
+
const handle = await openValidatedBuildFile(prepared, file, cache);
|
|
101
|
+
response.setHeader('Content-Type', type);
|
|
102
|
+
if (request.method === 'HEAD') {
|
|
103
|
+
await handle.close();
|
|
104
|
+
response.writeHead(200).end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (name === 'index.html') {
|
|
108
|
+
try {
|
|
109
|
+
const html = await readValidatedEntry(handle, file.bytes);
|
|
110
|
+
const doctype = html.match(/^\s*<!doctype[^>]*>/i)?.[0] ?? '';
|
|
111
|
+
const config = `<script>Object.defineProperty(globalThis,"__SPAWN_LAUNCH__",{value:Object.freeze({platformOrigin:${JSON.stringify(origin)}})});</script>`;
|
|
112
|
+
await handle.close();
|
|
113
|
+
response.writeHead(200).end(doctype + config + html.slice(doctype.length));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
await handle.close().catch(() => undefined);
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
if (file.bytes === 0) {
|
|
123
|
+
await handle.close();
|
|
124
|
+
response.setHeader('Content-Length', '0');
|
|
125
|
+
response.writeHead(200).end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const stream = createReadStream(null, { fd: handle.fd, start: 0, end: file.bytes - 1, autoClose: false });
|
|
129
|
+
response.setHeader('Content-Length', String(file.bytes));
|
|
130
|
+
let closed = false;
|
|
131
|
+
const closeHandle = () => {
|
|
132
|
+
if (closed)
|
|
133
|
+
return;
|
|
134
|
+
closed = true;
|
|
135
|
+
void handle.close().catch(() => undefined);
|
|
136
|
+
};
|
|
137
|
+
const abortStream = () => {
|
|
138
|
+
closeHandle();
|
|
139
|
+
try {
|
|
140
|
+
stream.destroy();
|
|
141
|
+
}
|
|
142
|
+
catch { /* stream already closed */ }
|
|
143
|
+
};
|
|
144
|
+
stream.on('end', closeHandle);
|
|
145
|
+
stream.on('close', closeHandle);
|
|
146
|
+
response.on('close', abortStream);
|
|
147
|
+
response.on('error', abortStream);
|
|
148
|
+
response.writeHead(200);
|
|
149
|
+
stream.on('error', () => { closeHandle(); try {
|
|
150
|
+
response.end();
|
|
151
|
+
}
|
|
152
|
+
catch { /* response already closed */ } });
|
|
153
|
+
stream.pipe(response);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
await handle.close().catch(() => undefined);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (body === undefined && !path.startsWith('/build/')) {
|
|
162
|
+
response.writeHead(404).end('File unavailable.');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (body === undefined) {
|
|
166
|
+
response.writeHead(404).end('File unavailable.');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!path.startsWith('/build/'))
|
|
170
|
+
response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; frame-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
|
171
|
+
response.setHeader('Content-Type', type);
|
|
172
|
+
response.writeHead(200).end(request.method === 'HEAD' ? undefined : body);
|
|
173
|
+
};
|
|
174
|
+
const server = createServer({ maxHeaderSize: 8192 }, (request, response) => {
|
|
175
|
+
void handleRequest(request, response).catch(() => {
|
|
176
|
+
try {
|
|
177
|
+
response.writeHead(409).end('Build changed. Finish rebuilding, then use Rebuild / reload in the Spawn launcher.');
|
|
178
|
+
}
|
|
179
|
+
catch { /* response already closed */ }
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
server.maxConnections = 32;
|
|
183
|
+
server.requestTimeout = 10000;
|
|
184
|
+
server.headersTimeout = 10000;
|
|
185
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
186
|
+
origin = 'http://127.0.0.1:' + server.address().port;
|
|
187
|
+
return { server, origin, files: prepared.files.length };
|
|
188
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function launcherHtml(token: string): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function launcherHtml(token) {
|
|
2
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Spawn · Local testing</title><link rel="stylesheet" href="/__spawn/style.css"></head>
|
|
3
|
+
<body data-document-token="${token}">
|
|
4
|
+
<header><strong>SPAWN <span>LOCAL TESTING</span></strong><div class="players" aria-label="Test player"><button data-player="alice" aria-pressed="true">Alice</button><button data-player="bob" aria-pressed="false">Bob</button><button data-player="empty" aria-pressed="false">Empty balance</button></div><button id="reopen">Rebuild / reload</button><button id="disconnect">Disconnect</button><button id="reset">Reset testing</button></header>
|
|
5
|
+
<div class="notice">Fake accounts, fake tokens. Nothing is sent to Spawn. Reloading clears this session. Test your private Spawn preview before publishing.</div>
|
|
6
|
+
<output id="spawn-dev-state" hidden aria-hidden="true"></output>
|
|
7
|
+
<main><section id="game" aria-label="Game preview"><div id="frame-slot"></div></section><aside>
|
|
8
|
+
<section class="panel-section"><p class="eyebrow">Player</p><h1 id="balance"></h1><p id="status" role="status">Waiting for the SDK…</p></section>
|
|
9
|
+
<section class="panel-section"><h2>Creator test panel</h2><div class="balances"><div><span>Game pool</span><strong id="pool-balance"></strong></div><div><span>Creator wallet</span><strong id="creator-balance"></strong></div><div><span>Spawn fees</span><strong id="platform-balance"></strong></div></div>
|
|
10
|
+
<label for="transfer-amount">Amount <span class="hint">TEST tokens</span></label><input id="transfer-amount" type="number" min="1" step="1" value="10" inputmode="numeric">
|
|
11
|
+
<div class="pool-actions"><button id="fund-pool">Top up pool</button><button id="withdraw-pool">Withdraw</button><button id="reward-player" class="primary">Reward Alice</button></div>
|
|
12
|
+
<p id="transfer-feedback" class="hint" role="status" aria-live="polite">Rewards go to the selected test player.</p></section>
|
|
13
|
+
<section class="panel-section"><h2>Submitted scores</h2><p class="hint">Unverified game results. Review manually; submitting a score never pays a reward.</p><p id="no-scores" class="empty">No scores yet.</p><ol id="scores"></ol></section>
|
|
14
|
+
<section class="panel-section"><h2>Transactions <span class="hint">Latest 12</span></h2><p id="no-transactions" class="empty">Confirm a payment in your game or try a pool transfer.</p><ol id="history" class="transactions"></ol></section>
|
|
15
|
+
</aside></main>
|
|
16
|
+
<dialog id="payment"><h2 id="payment-title">Confirm test payment</h2><p id="payment-copy">Pay 10 TEST to this local test game?</p><p class="hint">This is a simulation. No real tokens move.</p><div class="actions"><button id="cancel">Cancel</button><button id="confirm" class="primary">Confirm 10 TEST</button><button id="continue" class="primary" hidden>Continue to game</button></div></dialog>
|
|
17
|
+
<script type="module" src="/__spawn/host.js"></script></body></html>`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Save, SpawnGameIdentity, SpawnScoreSubmission, SpawnTestPayment } from '../index.ts';
|
|
2
|
+
import { LocalTestEconomy, type LocalPlayer as Player } from './economy.ts';
|
|
3
|
+
type LocalScore = SpawnScoreSubmission & {
|
|
4
|
+
player: Player;
|
|
5
|
+
score: number;
|
|
6
|
+
details: unknown;
|
|
7
|
+
};
|
|
8
|
+
export type LocalQuote = {
|
|
9
|
+
id: string;
|
|
10
|
+
player: Player;
|
|
11
|
+
launch: string;
|
|
12
|
+
status: 'pending' | 'paid' | 'cancelled';
|
|
13
|
+
receipt?: SpawnTestPayment;
|
|
14
|
+
};
|
|
15
|
+
/** In-memory fixtures only. This module never calls a platform API. */
|
|
16
|
+
export declare class LocalTestState {
|
|
17
|
+
readonly economy: LocalTestEconomy;
|
|
18
|
+
private submissions;
|
|
19
|
+
get scores(): LocalScore[];
|
|
20
|
+
private saves;
|
|
21
|
+
private quotes;
|
|
22
|
+
identity(player: string): SpawnGameIdentity;
|
|
23
|
+
private player;
|
|
24
|
+
private recordKey;
|
|
25
|
+
balance(player: string): number;
|
|
26
|
+
load(player: string, key: string): Save<unknown> | null;
|
|
27
|
+
save(player: string, key: string, value: unknown, version: number): Save<unknown>;
|
|
28
|
+
score(player: string, score: number, details: unknown): SpawnScoreSubmission;
|
|
29
|
+
quote(player: string, launch: string, product: string): LocalQuote;
|
|
30
|
+
cancel(id: string): void;
|
|
31
|
+
confirm(id: string): SpawnTestPayment;
|
|
32
|
+
}
|
|
33
|
+
export {};
|