@homie-rocks/studio 0.32.0 → 0.33.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/CHANGELOG.md +85 -2
- package/README.md +34 -9
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +13 -4
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/standalone-cli.mjs +30 -2
- package/lib/standalone-tools.mjs +20 -2
- package/lib/standalone.mjs +215 -9
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/standalone/STANDALONE.md +61 -6
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/rules/view.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* view.ts — what a rules game's view imports: the room, as a browser sees it.
|
|
3
|
+
* =============================================================================
|
|
4
|
+
*
|
|
5
|
+
* import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
6
|
+
* import type rules from './rules';
|
|
7
|
+
* const room = openRoom<typeof rules>();
|
|
8
|
+
*
|
|
9
|
+
* A game is rules plus view. The rules run on the server; the view draws what it is told and sends what the player
|
|
10
|
+
* presses. `openRoom` starts the netplay helper (netplay/netplay.ts), joins the room the play page names and returns
|
|
11
|
+
* at once: `room.status` moves from `connecting` to `playing`. The rules are imported for their types only, so none
|
|
12
|
+
* of their code is in the view's bundle; the build hands this library their declarations as data (`setGame`), and it
|
|
13
|
+
* unpacks every frame from them. A view never packs or unpacks state.
|
|
14
|
+
*
|
|
15
|
+
* room.me the player's own body
|
|
16
|
+
* room.each(kind, fn) every entity of a kind, interpolated to render time
|
|
17
|
+
* room.on(name, fn) an effect by its name, or 'enter', 'leave', 'placed', 'round', 'status', 'say'
|
|
18
|
+
* room.input(sample) what the player holds and presses, as the kind's declared input fields
|
|
19
|
+
* room.command(name, data) a reliable one-shot request to the player's own body
|
|
20
|
+
* room.round, room.roster, room.shared, room.seat, room.status
|
|
21
|
+
*
|
|
22
|
+
* THE OWN BODY. In this release a player's body moves as it does today: this browser runs the game's `move` code
|
|
23
|
+
* (the same guarded module the server runs) on its own copy of the room's clock and says where its body is; the
|
|
24
|
+
* server holds each claim to the body's top speed (`body: { move: 'owner' }` in the rules). The stick therefore answers
|
|
25
|
+
* on the next drawn frame. Everything the rules decide (score, pickups, rounds, where a body is placed) comes from the
|
|
26
|
+
* server and cannot be changed from here. A later release has the server move every body and this library predict.
|
|
27
|
+
*
|
|
28
|
+
* THE CLOCK AND THE INPUT (rooms-milestone-1-design.md, sections 4.4 and 6.1). Input is one step a tick, stamped with
|
|
29
|
+
* its tick. This browser steps a little ahead of the server so an entry arrives before its tick runs, and holds that
|
|
30
|
+
* lead from the `lead` figure in each snapshot. A step whose sample or claim changed makes an entry; a period with no
|
|
31
|
+
* entry sends nothing; after 250 ms of nothing the held values are sent again; a hidden tab sends one neutral entry
|
|
32
|
+
* and stops stepping.
|
|
33
|
+
*
|
|
34
|
+
* Chat, votes, the arrival screen, owner controls and the page around the game stay with the helper (`room.net`) and
|
|
35
|
+
* the shell, as today.
|
|
36
|
+
* =============================================================================
|
|
37
|
+
*/
|
|
38
|
+
import type { useAgents, Vocabulary, AskButton } from '../agents/agents.ts';
|
|
39
|
+
let agentFactory: typeof useAgents | null = null;
|
|
40
|
+
export function setAgentFactory(factory: typeof useAgents): void { agentFactory = factory; }
|
|
41
|
+
import { createNetplay } from '../netplay/netplay.ts';
|
|
42
|
+
import type { Netplay, NetplayOptions, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
|
|
43
|
+
import { exposePort } from '../port/probe.ts';
|
|
44
|
+
import { BudgetError, G, brand } from './guard.ts';
|
|
45
|
+
import { math, sweepMap } from './math.ts';
|
|
46
|
+
import { coerce, coerceFields, dir, num, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
47
|
+
import type { Unpacked } from './pack.ts';
|
|
48
|
+
import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
|
|
49
|
+
|
|
50
|
+
/** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
|
|
51
|
+
export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spots?: Record<string, unknown[]> } }
|
|
52
|
+
let current: { game: GameData; move: Record<string, MoveFn> } | null = null;
|
|
53
|
+
/** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
|
|
54
|
+
export function setGame(game: GameData, move: Record<string, MoveFn> = {}): void { current = { game, move }; }
|
|
55
|
+
|
|
56
|
+
/** An entity as a view reads it: the built-in fields, the kind's declared fields by name, and its `motion`. */
|
|
57
|
+
export interface Entity {
|
|
58
|
+
readonly id: string; readonly kind: string; readonly pos: Vec3; readonly vel: Vec3; readonly heading: Vec3; readonly grounded: boolean;
|
|
59
|
+
readonly seat?: number; readonly owner?: string; readonly driver?: string; readonly away?: boolean; readonly goal?: unknown;
|
|
60
|
+
/** This is the player's own body. */
|
|
61
|
+
readonly mine?: boolean;
|
|
62
|
+
readonly motion: Readonly<Record<string, unknown>>;
|
|
63
|
+
readonly [field: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
export type RoomStatus = 'connecting' | 'playing' | 'offline' | 'closed';
|
|
66
|
+
export interface RoomRound { n: number; phase: 'live' | 'over'; endsAt: number; secondsLeft: number; results?: RoundInfo['results'] }
|
|
67
|
+
export interface RosterRow { seat: number; name: string; driver: 'person' | 'bot' | 'ai'; score: number; me: boolean }
|
|
68
|
+
export interface OpenRoomOptions { game?: GameData; move?: Record<string, MoveFn>; net?: NetplayOptions; /** Tests: a clock in ms and a way to run without timers. */ now?: () => number; timers?: boolean }
|
|
69
|
+
export interface Room<R = unknown> {
|
|
70
|
+
readonly status: RoomStatus;
|
|
71
|
+
readonly seat: number | null;
|
|
72
|
+
readonly me: Entity | null;
|
|
73
|
+
each(kind: string, fn: (e: Entity) => void): void;
|
|
74
|
+
get(id: string): Entity | null;
|
|
75
|
+
on(name: string, fn: (e: any) => void): () => void;
|
|
76
|
+
readonly shared: Readonly<Record<string, unknown>>;
|
|
77
|
+
input(sample: Record<string, number | boolean>): void;
|
|
78
|
+
command(name: string, data?: Record<string, unknown>): void;
|
|
79
|
+
readonly round: RoomRound | null;
|
|
80
|
+
readonly roster: RosterRow[];
|
|
81
|
+
/** A watcher: follow one entity's seat (or null for the whole room). */
|
|
82
|
+
follow(id: string | null): void;
|
|
83
|
+
/** Extra values for the testing tools' receipts (`window.__homiePort`). */
|
|
84
|
+
probe(extra: Record<string, () => unknown>): void;
|
|
85
|
+
/** A guide's ask buttons and a person's ask (today's relay frames; guides are driven by the server in a later release). */
|
|
86
|
+
ask(id: string, askId: string, args?: Record<string, unknown>): void;
|
|
87
|
+
askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
|
|
88
|
+
readonly tune: Readonly<Record<string, unknown>>;
|
|
89
|
+
/** The game's static map: its edges, its solid shapes and its named spots, in metres. */
|
|
90
|
+
readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
|
|
91
|
+
readonly net: Netplay;
|
|
92
|
+
/** Run the room's clock up to now (called by itself on a timer and whenever the view reads the room). */
|
|
93
|
+
pump(): void;
|
|
94
|
+
close(): void;
|
|
95
|
+
/** Present only for the types: the rules this room runs. */
|
|
96
|
+
readonly __rules?: R;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][] }
|
|
100
|
+
const frames = new WeakMap<object, Frame | null>();
|
|
101
|
+
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
|
102
|
+
|
|
103
|
+
export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
104
|
+
const game = opts.game ?? current?.game;
|
|
105
|
+
const moves = opts.move ?? current?.move ?? {};
|
|
106
|
+
if (!game) throw new Error('openRoom() found no game: a rules game\'s view is built by `homie-studio build`, which hands it the game\'s declarations');
|
|
107
|
+
const schema = game.schema;
|
|
108
|
+
const dims = schema.dims;
|
|
109
|
+
const tickHz = schema.settings.tickHz;
|
|
110
|
+
const period = 1000 / tickHz;
|
|
111
|
+
const sendEvery = Math.ceil(tickHz / Math.max(1, schema.settings.inputHz));
|
|
112
|
+
const clock = opts.now ?? ((): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
|
|
113
|
+
const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, ...opts.net, rules: true });
|
|
114
|
+
const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true }) : null;
|
|
115
|
+
const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
|
|
116
|
+
const listeners = new Map<string, Set<(e: any) => void>>();
|
|
117
|
+
const emit = (name: string, e: unknown): void => { for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
|
|
118
|
+
const vecOf = (a: unknown): Vec3 => { const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a; return vec3(p, dims); };
|
|
119
|
+
const map = { bounds: { min: vecOf(game.map.bounds.min), max: vecOf(game.map.bounds.max) }, boxes: (game.map.boxes ?? []).map((b: any) => ({ min: vecOf(b.min), max: vecOf(b.max) })), circles: (game.map.circles ?? []).map((c: any) => ({ at: vecOf(c.at), r: Number(c.r) })) };
|
|
120
|
+
const spots: Record<string, readonly Vec3[]> = {};
|
|
121
|
+
for (const [key, list] of Object.entries(game.map.spots ?? {})) spots[key] = Object.freeze(list.map(vecOf));
|
|
122
|
+
const tune = Object.freeze({ ...game.tune });
|
|
123
|
+
|
|
124
|
+
let status: RoomStatus = 'connecting';
|
|
125
|
+
let latest: Frame | null = null;
|
|
126
|
+
let epoch = 0;
|
|
127
|
+
let round: RoomRound | null = null;
|
|
128
|
+
let results: RoundInfo['results'] | undefined;
|
|
129
|
+
let closed = false;
|
|
130
|
+
const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown> }[] = [];
|
|
131
|
+
const setStatus = (s: RoomStatus): void => { if (s !== status) { status = s; emit('status', s); } };
|
|
132
|
+
|
|
133
|
+
/* ---------------------------------------------------------------- frames in */
|
|
134
|
+
|
|
135
|
+
function frameOf(s: Snapshot<unknown> | null): Frame | null {
|
|
136
|
+
if (!s || typeof s !== 'object') return null;
|
|
137
|
+
if (frames.has(s)) return frames.get(s) ?? null;
|
|
138
|
+
let f: Frame | null = null;
|
|
139
|
+
const d = s.d as [unknown, unknown] | null;
|
|
140
|
+
if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
|
|
141
|
+
const ents = new Map<string, Unpacked>();
|
|
142
|
+
for (const w of d[1] as unknown[]) { const e = unpackEntity(schema.kinds, w, dims); if (e) ents.set(e.id, e); }
|
|
143
|
+
f = { k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
|
|
144
|
+
}
|
|
145
|
+
frames.set(s, f);
|
|
146
|
+
return f;
|
|
147
|
+
}
|
|
148
|
+
const entityOf = (u: Unpacked, mine = false): Entity => Object.freeze({ id: u.id, kind: u.kind, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, ...(u.seat !== undefined ? { seat: u.seat, owner: u.owner, driver: u.driver, away: u.away, goal: u.goal } : {}), ...(mine ? { mine: true } : {}), ...u.fields, motion: Object.freeze({ ...u.motion }) }) as Entity;
|
|
149
|
+
|
|
150
|
+
/* ---------------------------------------------------------------- the own body, on this browser's copy of the clock */
|
|
151
|
+
|
|
152
|
+
interface Mine { id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }
|
|
153
|
+
let mine: Mine | null = null;
|
|
154
|
+
/** The clock: the room tick this browser is on at `at` (ms), and the newest step it has run. */
|
|
155
|
+
let base: { tick: number; at: number } | null = null;
|
|
156
|
+
let stepped = 0;
|
|
157
|
+
const leads: number[] = [];
|
|
158
|
+
/** What the player holds, the presses since the last step, what the server was last told, and the entries of this send period. */
|
|
159
|
+
let sample: Record<string, unknown> = {};
|
|
160
|
+
let pressed = new Set<string>();
|
|
161
|
+
let lastSent = '';
|
|
162
|
+
let lastSendAt = 0;
|
|
163
|
+
let entries: { t: number; row: number[] }[] = [];
|
|
164
|
+
let hiddenSent = false;
|
|
165
|
+
const TARGET_LEAD = 1;
|
|
166
|
+
const myKind = (): (typeof schema.kinds)[number] | null => (mine ? kindOf.get(mine.kind) ?? null : null);
|
|
167
|
+
const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period : 0);
|
|
168
|
+
/** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
|
|
169
|
+
let moveTick = 0;
|
|
170
|
+
const moveCtx = brand(Object.freeze({
|
|
171
|
+
get tick() { return moveTick; }, dt: 1 / tickHz, tune, math,
|
|
172
|
+
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
173
|
+
ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
|
|
174
|
+
map: brand(Object.freeze({ name: game.map.name ?? 'main', spot: (name: string) => spots[name]?.[0], spots: (name: string) => spots[name] ?? Object.freeze([]), sweep: (body: any, delta: unknown) => sweepMap(map, body, delta, myKind()?.radius ?? 0, dims) })),
|
|
175
|
+
}));
|
|
176
|
+
function rebase(k: number): void {
|
|
177
|
+
const rtt = net.stats().rtt ?? 100;
|
|
178
|
+
base = { tick: k + Math.ceil(rtt / period + TARGET_LEAD) + sendEvery - 1, at: clock() };
|
|
179
|
+
stepped = Math.floor(base.tick) - 1;
|
|
180
|
+
entries = []; lastSent = ''; leads.length = 0; pressed = new Set();
|
|
181
|
+
}
|
|
182
|
+
/** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
|
|
183
|
+
const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
|
|
184
|
+
function adopt(u: Unpacked): void {
|
|
185
|
+
mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion) };
|
|
186
|
+
lastSent = '';
|
|
187
|
+
}
|
|
188
|
+
/** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
|
|
189
|
+
function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
|
|
190
|
+
const fn = moves[kindName];
|
|
191
|
+
if (!fn) return body;
|
|
192
|
+
moveTick = t;
|
|
193
|
+
G.left = Math.max(1, Math.floor(schema.settings.budget.tick / 4));
|
|
194
|
+
try { fn(body, input, moveCtx); } catch (err) { if (!(err instanceof BudgetError)) console.warn('[room] move', err); }
|
|
195
|
+
G.left = Infinity;
|
|
196
|
+
// Rounded to 32-bit floats and held to the declared shapes, as the server does it: both hold the same numbers.
|
|
197
|
+
const list = kindOf.get(kindName)?.motion ?? [];
|
|
198
|
+
return { pos: vec3(body.pos, dims), vel: vec3(body.vel, dims), heading: dir(body.heading, dims), grounded: body.grounded === true, motion: thawFields(list, coerceFields(list, body.motion, dims)) };
|
|
199
|
+
}
|
|
200
|
+
/** The input values the last step held (a press is never held). */
|
|
201
|
+
let held: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
202
|
+
/** One step of the own body on tick `t`: the input step, `move`, and an entry when the sample or the claim changed. */
|
|
203
|
+
function step(t: number): void {
|
|
204
|
+
const kind = myKind();
|
|
205
|
+
if (!mine || !kind) return;
|
|
206
|
+
const values: number[] = [];
|
|
207
|
+
const input: Record<string, unknown> = {};
|
|
208
|
+
let press = false;
|
|
209
|
+
for (const [name, fd] of kind.input) {
|
|
210
|
+
if (fd.t === 'press') { const on = pressed.has(name); input[name] = on; values.push(on ? 1 : 0); if (on) press = true; } else { const v = coerce(fd, sample[name] ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
|
|
211
|
+
}
|
|
212
|
+
pressed = new Set();
|
|
213
|
+
held = Object.freeze({ ...input });
|
|
214
|
+
if (kind.owner) {
|
|
215
|
+
const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
|
|
216
|
+
mine.pos = body.pos; mine.vel = body.vel; mine.heading = body.heading; mine.grounded = body.grounded; mine.motion = body.motion;
|
|
217
|
+
}
|
|
218
|
+
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
219
|
+
const sig = JSON.stringify([values, claim]);
|
|
220
|
+
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); lastSent = sig; }
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The end of a send period (`sendEvery` ticks, counted from tick 0): one frame with the entries made in it, `k` the
|
|
224
|
+
* period's first tick. A period with no entry sends nothing; after 250 ms without a frame the held values are sent
|
|
225
|
+
* again as an ordinary entry on the current tick.
|
|
226
|
+
*/
|
|
227
|
+
function flush(now: number, t: number): void {
|
|
228
|
+
if (!mine) { entries = []; return; }
|
|
229
|
+
if (!entries.length && now - lastSendAt >= 250) keepalive(t);
|
|
230
|
+
if (!entries.length) return;
|
|
231
|
+
const k = Math.min(entries[0].t, t - (t % sendEvery));
|
|
232
|
+
const rows: StepEntry[] = entries.map((e) => [e.t - k, ...e.row]);
|
|
233
|
+
entries = [];
|
|
234
|
+
if (net.steps(epoch, k, rows, mine.r)) lastSendAt = now;
|
|
235
|
+
}
|
|
236
|
+
/** The held values as an entry on tick `t`, without stepping the body again. */
|
|
237
|
+
function keepalive(t: number): void {
|
|
238
|
+
const kind = myKind();
|
|
239
|
+
if (!mine || !kind) return;
|
|
240
|
+
const values = kind.input.map(([name, fd]) => (fd.t === 'press' ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
|
|
241
|
+
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
242
|
+
entries = [{ t, row: [...values, ...claim] }]; lastSent = JSON.stringify([values, claim]);
|
|
243
|
+
}
|
|
244
|
+
function pump(): void {
|
|
245
|
+
if (closed) return;
|
|
246
|
+
if (net.offline) setStatus(net.closedWhy ? 'closed' : 'offline');
|
|
247
|
+
const now = clock();
|
|
248
|
+
const hidden = typeof document !== 'undefined' && document.hidden === true;
|
|
249
|
+
// Effects play for everyone who draws the room: a watcher has no body and still sees them.
|
|
250
|
+
fire();
|
|
251
|
+
if (!mine || !base || net.seat === null || !net.connected) return;
|
|
252
|
+
if (hidden) {
|
|
253
|
+
// A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
|
|
254
|
+
// one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
|
|
255
|
+
if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Set(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
|
|
256
|
+
base = null;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
hiddenSent = false;
|
|
260
|
+
let t = Math.floor(tickAt(now));
|
|
261
|
+
// A frame longer than a quarter of a second: set the clock again from the newest snapshot and carry on from there.
|
|
262
|
+
if (t - stepped > Math.ceil(tickHz / 4) && latest) { rebase(latest.k); t = Math.floor(tickAt(now)); }
|
|
263
|
+
while (stepped < t) {
|
|
264
|
+
stepped += 1;
|
|
265
|
+
step(stepped);
|
|
266
|
+
if (stepped % sendEvery === sendEvery - 1) flush(now, stepped);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function onSnapshot(s: Snapshot<unknown>): void {
|
|
271
|
+
const f = frameOf(s);
|
|
272
|
+
if (!f) return;
|
|
273
|
+
const before = latest;
|
|
274
|
+
latest = f;
|
|
275
|
+
const changedEpoch = f.e !== epoch;
|
|
276
|
+
epoch = f.e;
|
|
277
|
+
const seat = net.seat;
|
|
278
|
+
let me: Unpacked | null = null;
|
|
279
|
+
if (seat !== null) for (const u of f.ents.values()) if (u.seat === seat && u.driver !== 'bot') { me = u; break; }
|
|
280
|
+
if (me) {
|
|
281
|
+
// The server placed the body (a spawn, a round reset, a seat taken over), or the room began again: jump there.
|
|
282
|
+
const placed = !mine || mine.id !== me.id || mine.r !== me.r || changedEpoch;
|
|
283
|
+
if (placed) { adopt(me); emit('placed', entityOf(me, true)); }
|
|
284
|
+
else if (mine) { mine.motion = motionOf(me.kind, me.motion); if (!kindOf.get(me.kind)?.owner) { mine.pos = me.pos; mine.vel = me.vel; mine.heading = me.heading; mine.grounded = me.grounded; } }
|
|
285
|
+
if (!base || changedEpoch) rebase(f.k);
|
|
286
|
+
const row = f.rows.find((x) => x[0] === seat);
|
|
287
|
+
if (row && row[3] !== -128 && base) {
|
|
288
|
+
// Hold the lead: the median of the last 8, nudged by at most a twentieth of a tick a snapshot; far off, set the clock again.
|
|
289
|
+
leads.push(row[3] / 16); if (leads.length > 8) leads.shift();
|
|
290
|
+
const med = [...leads].sort((a, b) => a - b)[Math.floor(leads.length / 2)];
|
|
291
|
+
if (Math.abs(med - TARGET_LEAD) > 4) rebase(f.k); else base.tick += Math.max(-0.05, Math.min(0.05, (TARGET_LEAD - med) * 0.1));
|
|
292
|
+
}
|
|
293
|
+
} else mine = null;
|
|
294
|
+
if (before) {
|
|
295
|
+
for (const [id, u] of f.ents) { const old = before.ents.get(id); if (!old) emit('enter', entityOf(u)); else if (old.r !== u.r && id !== mine?.id) emit('placed', entityOf(u)); }
|
|
296
|
+
for (const [id, u] of before.ents) if (!f.ents.has(id)) emit('leave', entityOf(u));
|
|
297
|
+
} else for (const u of f.ents.values()) emit('enter', entityOf(u));
|
|
298
|
+
const phase = f.round[1] === 1 ? 'live' : 'over';
|
|
299
|
+
if (!round || round.n !== f.round[0] || round.phase !== phase) {
|
|
300
|
+
if (phase === 'live') results = undefined;
|
|
301
|
+
round = { n: f.round[0], phase, endsAt: f.round[2], secondsLeft: 0, ...(results ? { results } : {}) };
|
|
302
|
+
emit('round', roundNow());
|
|
303
|
+
} else round.endsAt = f.round[2];
|
|
304
|
+
setStatus(seat === null || me ? 'playing' : status);
|
|
305
|
+
}
|
|
306
|
+
function roundNow(): RoomRound | null {
|
|
307
|
+
if (!round || !latest) return null;
|
|
308
|
+
// The room's clock now, from the newest snapshot: a view never turns a tick into time itself.
|
|
309
|
+
const serverTick = latest.k + (net.connected ? (clock() - latest.at) / period : 0);
|
|
310
|
+
return { ...round, secondsLeft: round.endsAt ? Math.max(0, (round.endsAt - serverTick) / tickHz) : Infinity, ...(results ? { results } : {}) };
|
|
311
|
+
}
|
|
312
|
+
/** Effects wait for the tick they were emitted on to be drawn (an effect on the player's own body plays at once). */
|
|
313
|
+
function fire(): void {
|
|
314
|
+
if (!fxQueue.length) return;
|
|
315
|
+
const s = net.sample();
|
|
316
|
+
const drawn = s ? lerp(s.a.k, s.b.k, s.alpha) : latest ? latest.k : 0;
|
|
317
|
+
while (fxQueue.length && (fxQueue[0].tick <= drawn || fxQueue[0].at === mine?.id || fxQueue.length > 128)) {
|
|
318
|
+
const x = fxQueue.shift() as (typeof fxQueue)[number];
|
|
319
|
+
const id = typeof x.at === 'string' ? x.at : null;
|
|
320
|
+
const where = id ? get(id)?.pos ?? null : unpackVec(x.at, dims);
|
|
321
|
+
emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick }));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
net.on('snapshot', onSnapshot);
|
|
326
|
+
net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline) setStatus(net.closedWhy ? 'closed' : 'offline'); });
|
|
327
|
+
net.on('link', (e) => {
|
|
328
|
+
if (e.state === 'online') { base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
|
|
329
|
+
});
|
|
330
|
+
net.on('round', (r) => { if (r.results) { results = r.results; if (round) { round = { ...round, results }; emit('round', roundNow()); } } });
|
|
331
|
+
net.on('event', (e) => {
|
|
332
|
+
if (e.k === 'fx' && Array.isArray(e.d)) {
|
|
333
|
+
const [tick, list] = e.d as [number, [number, unknown, unknown][]];
|
|
334
|
+
for (const [i, at, data] of Array.isArray(list) ? list : []) { const name = schema.effectNames[i]; if (name) fxQueue.push({ tick: Number(tick) || 0, name, at, data: unpackFields(schema.effects[name] as FieldList, data, dims) }); }
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// A player's speech and emotes, and a guide's lines and goals, as the relay carries them today.
|
|
338
|
+
if (/^(?:say|chat|emote)/i.test(e.k) && !(agents && (e.d as { ai?: boolean })?.ai)) emit('say', { kind: e.k, seat: e.from, ...(e.d && typeof e.d === 'object' ? e.d as object : { text: e.d }) });
|
|
339
|
+
else if (e.k === 'agent:goal' && e.from === net.host?.seat) emit('goal', e.d);
|
|
340
|
+
else if (e.k === 'agent:ask' && e.from === net.host?.seat) emit('ask', e.d);
|
|
341
|
+
else if (/^ask:/.test(e.k)) emit('ask', { ask: e.k.slice(4), ...(e.d && typeof e.d === 'object' ? e.d as object : {}) });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
agents?.on('say', (e) => emit('say', e));
|
|
345
|
+
|
|
346
|
+
/* ---------------------------------------------------------------- what the view reads */
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The own body between ticks (section 6.3 of the design): the body as of the last whole step, and `move` run once
|
|
350
|
+
* more on a copy for the whole next tick with the input as it is right now. What is drawn is the point between
|
|
351
|
+
* the two, by how much of the tick has passed, and the copy is thrown away. So the stick answers on the next
|
|
352
|
+
* drawn frame, not on the next tick.
|
|
353
|
+
*/
|
|
354
|
+
function meNow(): Entity | null {
|
|
355
|
+
if (!mine || !latest) return null;
|
|
356
|
+
const server = latest.ents.get(mine.id);
|
|
357
|
+
if (!server) return null;
|
|
358
|
+
const kind = kindOf.get(mine.kind);
|
|
359
|
+
let pos = mine.pos;
|
|
360
|
+
let heading = mine.heading;
|
|
361
|
+
if (kind?.owner && base) {
|
|
362
|
+
const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
|
|
363
|
+
const now: Record<string, unknown> = {};
|
|
364
|
+
for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
365
|
+
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
366
|
+
pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
|
|
367
|
+
heading = next.heading;
|
|
368
|
+
}
|
|
369
|
+
return entityOf({ ...server, pos, vel: mine.vel, heading, grounded: mine.grounded, motion: mine.motion }, true);
|
|
370
|
+
}
|
|
371
|
+
function drawn(id: string): Entity | null {
|
|
372
|
+
if (mine && id === mine.id) return meNow();
|
|
373
|
+
const s = net.sample();
|
|
374
|
+
const a = s ? frameOf(s.a) : latest;
|
|
375
|
+
const b = s ? frameOf(s.b) : latest;
|
|
376
|
+
const ea = a?.ents.get(id); const eb = b?.ents.get(id);
|
|
377
|
+
if (!ea || !eb) return ea ? entityOf(ea) : eb && s && s.alpha >= 1 ? entityOf(eb) : eb && !a?.ents.size ? entityOf(eb) : null;
|
|
378
|
+
// A placed body jumps: nobody's view glides across a placement.
|
|
379
|
+
if (ea.r !== eb.r || !s) return entityOf(eb);
|
|
380
|
+
const t = s.alpha;
|
|
381
|
+
const h = dir({ x: lerp(ea.heading.x, eb.heading.x, t), y: lerp(ea.heading.y, eb.heading.y, t), z: lerp(ea.heading.z, eb.heading.z, t) }, dims);
|
|
382
|
+
return entityOf({ ...eb, pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: eb.vel, heading: h });
|
|
383
|
+
}
|
|
384
|
+
function get(id: string): Entity | null { return drawn(id); }
|
|
385
|
+
function each(kind: string, fn: (e: Entity) => void): void {
|
|
386
|
+
pump();
|
|
387
|
+
const s = net.sample();
|
|
388
|
+
const a = s ? frameOf(s.a) : latest;
|
|
389
|
+
const b = s ? frameOf(s.b) : latest;
|
|
390
|
+
const ids = new Set<string>();
|
|
391
|
+
// Until the newer snapshot is reached, what the older one holds is still drawn; what only the newer one holds waits for it.
|
|
392
|
+
for (const f of [a, s && s.alpha >= 1 ? b : null, a ? null : b]) if (f) for (const u of f.ents.values()) if (u.kind === kind) ids.add(u.id);
|
|
393
|
+
if (mine && mine.kind === kind) ids.add(mine.id);
|
|
394
|
+
for (const id of ids) { const e = drawn(id); if (e) fn(e); }
|
|
395
|
+
}
|
|
396
|
+
function roster(): RosterRow[] {
|
|
397
|
+
const scores = new Map<number, number>();
|
|
398
|
+
if (latest) for (const u of latest.ents.values()) { const k = kindOf.get(u.kind); if (u.seat !== undefined && k?.score) scores.set(u.seat, Number(u.fields[k.score]) || 0); }
|
|
399
|
+
return (net.slots ?? []).map((sl) => ({ seat: sl.slot, name: sl.name, driver: sl.agent ? 'ai' as const : sl.bot ? 'bot' as const : 'person' as const, score: scores.get(sl.slot) ?? 0, me: sl.seat !== null && sl.seat === net.seat }));
|
|
400
|
+
}
|
|
401
|
+
let extra: Record<string, () => unknown> = {};
|
|
402
|
+
const myScore = (): number | null => { const k = myKind(); const u = mine && latest ? latest.ents.get(mine.id) : null; return k?.score && u ? Number(u.fields[k.score]) || 0 : null; };
|
|
403
|
+
const busy = (): boolean => status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
|
|
404
|
+
// The probes the testing tools read (`check`, `shoot`, `perf`, the playtest judge): the same two objects a game written the old way publishes.
|
|
405
|
+
net.expose({
|
|
406
|
+
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
407
|
+
peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
|
|
408
|
+
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
409
|
+
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => 'server', status: () => status,
|
|
410
|
+
});
|
|
411
|
+
if (typeof window !== 'undefined') {
|
|
412
|
+
exposePort(net as Netplay<unknown, unknown, unknown>, {
|
|
413
|
+
view: 'top', size: schema.kinds.find((k) => k.player)?.radius ?? 0.5,
|
|
414
|
+
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: -m.pos.y } : null; },
|
|
415
|
+
score: myScore, busy: () => (typeof extra.busy === 'function' ? Boolean(extra.busy()) : busy()),
|
|
416
|
+
extra: new Proxy({}, { get: (_t, key) => (typeof key === 'string' ? extra[key] : undefined), ownKeys: () => Object.keys(extra), getOwnPropertyDescriptor: (_t, key) => (typeof key === 'string' && extra[key] ? { enumerable: true, configurable: true, value: extra[key] } : undefined) }) as Record<string, () => unknown>,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
const timer = opts.timers === false ? null : setInterval(pump, Math.max(8, period / 2));
|
|
420
|
+
const onShow = (): void => { if (typeof document !== 'undefined' && !document.hidden) { base = null; if (latest) rebase(latest.k); } };
|
|
421
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
get status() { return status; },
|
|
425
|
+
get seat() { return net.seat; },
|
|
426
|
+
get me() { pump(); return meNow(); },
|
|
427
|
+
each, get,
|
|
428
|
+
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); return () => { set?.delete(fn); }; },
|
|
429
|
+
get shared() { return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
430
|
+
input(s) {
|
|
431
|
+
const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
|
|
432
|
+
const next: Record<string, unknown> = {};
|
|
433
|
+
for (const [name, fd] of kind?.input ?? []) { if (fd.t === 'press') { if (s[name] === true || s[name] === 1) pressed.add(name); } else if (s[name] !== undefined) next[name] = s[name]; }
|
|
434
|
+
sample = next;
|
|
435
|
+
},
|
|
436
|
+
command(name, data = {}) { if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
|
|
437
|
+
get round() { return roundNow(); },
|
|
438
|
+
get roster() { return roster(); },
|
|
439
|
+
follow(id) { const e = id ? latest?.ents.get(id) : null; net.follow(e && e.seat !== undefined ? e.seat : null); },
|
|
440
|
+
probe(x) { extra = { ...extra, ...x }; },
|
|
441
|
+
ask(id, askId, args = {}) { const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
|
|
442
|
+
askButtons(id, offer = {}) { const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
|
|
443
|
+
tune, map: Object.freeze({ ...map, spots: Object.freeze(spots) }), net: net as Netplay,
|
|
444
|
+
pump,
|
|
445
|
+
close() { closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
|
|
446
|
+
};
|
|
447
|
+
}
|
package/shop/SELLING.md
CHANGED
|
@@ -20,12 +20,13 @@ requests, you deal with card disputes, and the tax on your sales is yours to han
|
|
|
20
20
|
| Card disputes (chargebacks) | yes: you answer them in Stripe and carry the loss if you lose | runs the process | |
|
|
21
21
|
| Sales tax and VAT | yes, with Stripe Tax's help (or Managed Payments, below) | calculates and collects | |
|
|
22
22
|
| Your players' data | yes: you are responsible for it | keeps the card and email | stores nothing |
|
|
23
|
-
|
|
|
23
|
+
| Who may buy | your editable shop policy; protective defaults | provider terms | supplies presets |
|
|
24
24
|
|
|
25
25
|
## Refunds
|
|
26
26
|
|
|
27
|
-
- A player can refund an **unused** item themselves within
|
|
28
|
-
account page.
|
|
27
|
+
- A player can refund an **unused** item themselves within the optional refund window you set from their
|
|
28
|
+
account page. Set `policy.refundUsedItems` to include used items. Without a window, buyers ask you for a refund.
|
|
29
|
+
Fewer people turn to card disputes when a refund is easy.
|
|
29
30
|
- Anything else, you refund with **one tap** on your office's shop page. Your AI can only *ask* you to refund; it
|
|
30
31
|
can never do it by itself.
|
|
31
32
|
- Stripe does not give back its processing fee on a refund. That is the cost of a happy player.
|
|
@@ -34,7 +35,7 @@ requests, you deal with card disputes, and the tax on your sales is yours to han
|
|
|
34
35
|
|
|
35
36
|
- A buyer's bank can reverse a payment. Stripe charges a dispute fee (about US$15) whether you win or lose, and
|
|
36
37
|
you lose the sale's money if the bank decides for the buyer. On a US$5 item one dispute costs more than the
|
|
37
|
-
sale,
|
|
38
|
+
sale, so consider your costs when setting prices. Homie imposes no price or earnings ceiling.
|
|
38
39
|
- **A dispute never deletes or locks the player's account.** While it is open nothing changes. If you lose it,
|
|
39
40
|
that one item is taken back, as a refund would.
|
|
40
41
|
|
|
@@ -52,20 +53,17 @@ You choose who the seller is when you connect your Stripe (`homie-studio shop co
|
|
|
52
53
|
|
|
53
54
|
## Kids
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
Your shop starts with the `protective` preset in shop.json. It requires an account and a neutral age question,
|
|
57
|
+
blocks purchases under 13, uses a parent's checkout for teens, closes shops on kids servers and kids studios,
|
|
58
|
+
hides advantages on beginner and kids servers, refuses paid randomness, countdowns and virtual-currency items,
|
|
59
|
+
and sends TV shoppers to a phone. Existing shops without a policy keep these protections.
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- nothing is ever sold to an account that said it is under 13;
|
|
61
|
-
- a 13 to 17 year old can only pass a link to a parent, who pays in their own name on Stripe's page;
|
|
62
|
-
- no loot boxes or anything random for money, nothing that changes the game on beginner servers, no countdowns;
|
|
63
|
-
- every price is in real money, every purchase goes through Stripe's own page, one at a time, with a monthly cap;
|
|
64
|
-
- the television never sells: it shows a code to buy on a phone.
|
|
61
|
+
These are **your settings**: read and edit `policy` in shop.json. `adults-only` also blocks teens;
|
|
62
|
+
`custom` enables all policies. You can override individual rules without changing the others. SHOP.md lists
|
|
63
|
+
every setting. No spending cap or refund window applies unless you set one; existing written values stay yours.
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
on kids servers.
|
|
65
|
+
As the seller, you are responsible for the law and your payment provider's terms wherever you sell, including
|
|
66
|
+
rules protecting children. Choosing a preset does not establish compliance. This is information, not legal advice.
|
|
69
67
|
|
|
70
68
|
## Referrals
|
|
71
69
|
|