@homie-rocks/studio 0.24.3 → 0.24.5
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 +51 -1
- package/README.md +21 -5
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +34 -10
- package/chat/CHAT.md +38 -5
- package/lib/agent-seat.mjs +60 -2
- package/lib/brain-probe.mjs +6 -5
- package/lib/build.mjs +6 -0
- package/lib/cloudflare.mjs +2 -0
- package/lib/doctor.mjs +14 -0
- package/lib/local-ai.mjs +66 -0
- package/lib/mcp-tools.mjs +10 -2
- package/lib/scaffold.mjs +17 -4
- package/lib/servers.mjs +31 -2
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +95 -17
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- package/site/SITE.md +4 -0
- package/starters/ember-vale/game.json +1 -0
- package/starters/ember-vale/src/main.ts +80 -6
- package/worker/agents.mjs +93 -47
- package/worker/brain.mjs +356 -5
- package/worker/chat-page.mjs +173 -28
- package/worker/chat.mjs +17 -7
- package/worker/index.mjs +57 -2
- package/worker/office.mjs +63 -3
- package/worker/room.mjs +48 -3
- package/worker/version.mjs +1 -1
package/lib/local-ai.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PERSON'S OWN AI (@homie-rocks/studio 0.24.4): Cloudflare's Clef decision model on this computer, through Ollama.
|
|
3
|
+
* The same typed questions the studio's Workers AI answers, at /v1/systemone, free and offline:
|
|
4
|
+
*
|
|
5
|
+
* homie-studio dev finds it and hands it to the local Worker (HOMIE_LOCAL_AI), so AI guides, room chat's review
|
|
6
|
+
* and a game's own decisions (net.decide) need no Cloudflare while building
|
|
7
|
+
* agents sit --brain local / the MCP's agent_sit { brain: "local" }
|
|
8
|
+
* a guide's seat that thinks on this computer every few seconds, with no AI turn per decision
|
|
9
|
+
* homie-studio doctor says what is there, and what one sentence gets the rest
|
|
10
|
+
*
|
|
11
|
+
* It never downloads a model. `ollama pull clef-flash` is about 11 GB (clef, the 27B, about 18 GB): the person says yes
|
|
12
|
+
* first, every time (the studio skill asks and names the size). It needs Ollama 0.35.1 or later (the first with Clef).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const OLLAMA_BASE = 'http://127.0.0.1:11434';
|
|
16
|
+
export const OLLAMA_MIN = '0.35.1';
|
|
17
|
+
/** The models Homie uses, smallest first, and what each costs the disk (ollama.com/library, 2026-10-02). */
|
|
18
|
+
export const LOCAL_MODELS = Object.freeze([
|
|
19
|
+
Object.freeze({ name: 'clef-flash', size: 'about 11 GB', params: '9B' }),
|
|
20
|
+
Object.freeze({ name: 'clef', size: 'about 18 GB', params: '27B' }),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const newer = (a, b) => {
|
|
24
|
+
const pa = String(a).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
25
|
+
const pb = String(b).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
26
|
+
for (let i = 0; i < 3; i += 1) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Only a loopback address: Homie never sends a game's state to another machine's Ollama. */
|
|
31
|
+
export const loopbackBase = (raw) => {
|
|
32
|
+
const s = String(raw ?? '').trim().replace(/\/+$/, '');
|
|
33
|
+
return /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d{2,5}$/.test(s) ? s : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is Clef on this computer? `{ ok, base, model, version }`, or `{ ok: false, why, say, version?, base }` with why one of
|
|
38
|
+
* no-ollama, old-ollama, no-model. `HOMIE_OLLAMA` names another loopback port. Two quick local requests, nothing else.
|
|
39
|
+
*/
|
|
40
|
+
export async function detectLocalAi({ env = process.env, fetchFn = globalThis.fetch, ms = 1500 } = {}) {
|
|
41
|
+
const base = loopbackBase(env.HOMIE_OLLAMA) ?? OLLAMA_BASE;
|
|
42
|
+
const get = async (path) => {
|
|
43
|
+
const res = await fetchFn(`${base}${path}`, { signal: AbortSignal.timeout(ms) });
|
|
44
|
+
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
|
|
45
|
+
return res.json();
|
|
46
|
+
};
|
|
47
|
+
let version = null;
|
|
48
|
+
try { version = String((await get('/api/version'))?.version ?? ''); } catch {
|
|
49
|
+
return { ok: false, base, why: 'no-ollama', say: `Ollama is not running on this computer (${base}). With it and clef-flash, AI guides, chat review and game decisions run here for free while building; without it they play from the game's script under dev.` };
|
|
50
|
+
}
|
|
51
|
+
if (!newer(version, OLLAMA_MIN)) return { ok: false, base, version, why: 'old-ollama', say: `Ollama ${version} is here; Clef needs ${OLLAMA_MIN} or later. Updating Ollama is the person's to do (its own app updates itself, or ollama.com/download).` };
|
|
52
|
+
let names = [];
|
|
53
|
+
try { names = ((await get('/api/tags'))?.models ?? []).map((m) => String(m?.name ?? m?.model ?? '')).filter(Boolean); } catch { names = []; }
|
|
54
|
+
for (const want of LOCAL_MODELS) {
|
|
55
|
+
const hit = names.find((n) => n === want.name || n.startsWith(`${want.name}:`));
|
|
56
|
+
if (hit) return { ok: true, base, version, model: hit };
|
|
57
|
+
}
|
|
58
|
+
const flash = LOCAL_MODELS[0];
|
|
59
|
+
return {
|
|
60
|
+
ok: false, base, version, why: 'no-model', models: names.length,
|
|
61
|
+
say: `Ollama ${version} is here without Clef. Downloading it is the person's choice: ask first. \`ollama pull ${flash.name}\` downloads ${flash.size} (the ${flash.params} model; ${LOCAL_MODELS[1].name} is ${LOCAL_MODELS[1].size}). Then run dev again.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The Worker variables that hand a found model to `wrangler dev` (`--var` pairs), or none. */
|
|
66
|
+
export const localAiVars = (found) => (found?.ok ? ['--var', `HOMIE_LOCAL_AI:${found.base}`, '--var', `HOMIE_LOCAL_AI_MODEL:${found.model}`] : []);
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { STUDIO_VERSION } from './version.mjs';
|
|
|
33
33
|
import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
|
|
34
34
|
import { pinnedVersion } from './upgrade.mjs';
|
|
35
35
|
import { AgentSeat } from './agent-seat.mjs';
|
|
36
|
+
import { detectLocalAi } from './local-ai.mjs';
|
|
36
37
|
import { LAB_PORT, runningLab } from './lab.mjs';
|
|
37
38
|
import { pictureFor } from './pictures.mjs';
|
|
38
39
|
import { ART_UI, artToolDefs } from './art-tools.mjs';
|
|
@@ -1050,17 +1051,24 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1050
1051
|
{
|
|
1051
1052
|
name: 'agent_sit', title: 'Sit in a game as an AI guide',
|
|
1052
1053
|
description: 'Take a guide\'s seat in a live room of one of the studio\'s games as an AI ("Claude · AI": always marked AI, never on a humans-only server, only in a room with people in it). The game\'s own bot code moves the body every frame; you choose its goal and, when the server lets its AI talk, one of the game\'s own lines (agents.json; never free text). Returns your seat, what you see (view), the asks players made of you and your choices. Then agent_look and agent_do every 20 to 40 seconds, and agent_stand when done. Uses the site running here (preview_run) or the live site; with no pass, makes a one-day guide pass with the owner\'s office key and revokes it when you stand.',
|
|
1053
|
-
inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), ...STUDIO_ARG }, required: ['game'] },
|
|
1054
|
+
inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), brain: { type: 'string', enum: ['local'], description: 'Optional: "local" lets Cloudflare\'s Clef decision model on this computer (Ollama with clef-flash) choose every few seconds instead of you, free; agent_look shows what it chose and agent_do still overrides it. Never downloads a model: if Ollama has no clef-flash this says what the download costs, and the person decides.' }, ...STUDIO_ARG }, required: ['game'] },
|
|
1054
1055
|
annotations: { title: 'Sit in a game as an AI guide', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1055
1056
|
run: async (a) => {
|
|
1056
1057
|
const root = ctx.root(a.studio);
|
|
1057
1058
|
if (ctx.seat && !ctx.seat.closed) await ctx.seat.stand();
|
|
1058
1059
|
const site = a.url ? String(a.url) : devOf(root)?.url ?? siteUrl(root);
|
|
1059
1060
|
if (!site) return fail('the studio is not running here and has no live site: preview_run first, or studio_deploy');
|
|
1060
|
-
|
|
1061
|
+
let local = null;
|
|
1062
|
+
if (a.brain === 'local') {
|
|
1063
|
+
const found = await detectLocalAi();
|
|
1064
|
+
if (!found.ok) return fail(`No local brain: ${found.say}`, { kind: 'agent', ok: false, error: found.why });
|
|
1065
|
+
local = found;
|
|
1066
|
+
}
|
|
1067
|
+
const seat = new AgentSeat({ site, game: String(a.game ?? ''), server: a.server ?? null, pass: a.pass ?? null, label: a.label ? String(a.label).slice(0, 16) : local ? 'Clef' : 'Claude', root, brain: local ? 'local' : null, local });
|
|
1061
1068
|
const r = await seat.sit();
|
|
1062
1069
|
if (!r.ok) return fail(`No seat: ${r.why}`, { kind: 'agent', ok: false, error: r.error ?? null });
|
|
1063
1070
|
ctx.seat = seat;
|
|
1071
|
+
if (local) return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}), thinking with ${local.model} on this computer every few seconds (free; nothing sent to Cloudflare). agent_look shows its last decisions; agent_do overrides; agent_stand ends it.`, { kind: 'agent', ...r });
|
|
1064
1072
|
return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). ${r.talking ? 'This server lets its AI talk: lines from look.choices.lines, at most one every 8 s.' : 'This server\'s AI does not talk: choose goals only (say null).'} Look again with agent_look; act with agent_do { goal, args, say?, sayArgs? }.\n${JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1).slice(0, 6000)}`, { kind: 'agent', ...r });
|
|
1065
1073
|
},
|
|
1066
1074
|
},
|
package/lib/scaffold.mjs
CHANGED
|
@@ -221,10 +221,16 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
221
221
|
- Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
|
|
222
222
|
- Phones and computers: touch controls on phones only, keys on computers; keep the
|
|
223
223
|
centre of the screen clear during play.
|
|
224
|
-
- The play page's small room button (Invite, Big screen, the room code) sits top right
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
224
|
+
- The play page's small room button (Invite, Big screen, the room code) sits top right, with the Chat pill
|
|
225
|
+
beside it (a round icon on a phone, as the room button is). If the game's scoreboard or a bar is there, move
|
|
226
|
+
both in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per device (\`desk\`, \`phone\`,
|
|
227
|
+
\`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep them small icons
|
|
228
|
+
(\`node_modules/@homie-rocks/studio/site/SITE.md\`). Room chat's strip of new lines shows for a moment at the
|
|
229
|
+
bottom left: if the game's HUD or controls are there, \`"screen": { "chat": … }\` puts it where the game has
|
|
230
|
+
room (\`at\`: a corner, \`top-center\` or \`bottom-center\`; \`x\` / \`y\`; per device, and \`tv\` for the big
|
|
231
|
+
screen), or \`"lines": "sheet-only"\` keeps new lines in the Chat sheet, the pill counting them, so a game with
|
|
232
|
+
a busy HUD keeps typed chat (\`node_modules/@homie-rocks/studio/chat/CHAT.md\`). Look at both on a phone and a
|
|
233
|
+
computer while a round is on and somebody says something.
|
|
228
234
|
- **Watch any player.** Every live room can be watched at \`/<id>/watch?room=<room>\` (each room on the Rooms page
|
|
229
235
|
and the landing has a Watch button): the game itself, drawn by the watcher's own browser, which never takes a
|
|
230
236
|
seat, with a strip of the players to switch between (a tap, keys 1-9, A for Auto, O for the whole room). Point the
|
|
@@ -342,6 +348,13 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
342
348
|
it only ASKS. The AI never types: it picks a goal and a line id; the relay drops anything else. With no AI, over
|
|
343
349
|
budget, or between decisions, the game's \`decide\` plays. \`agent_sit\` (the local MCP) puts the owner's own
|
|
344
350
|
Claude in a guide's seat.
|
|
351
|
+
- **Clef (0.24.4):** Workers AI's guides think with Cloudflare's decision model \`@cf/cloudflare/clef-flash\` (it
|
|
352
|
+
picks among the vocabulary's goals, values and lines; it never writes text; about 9 neurons a decision).
|
|
353
|
+
\`agents try <id> --view <file> [--ask …] [--model …]\` shows what the brain would decide in one moment. Under
|
|
354
|
+
\`npm run dev\`, Clef on this computer (Ollama with \`clef-flash\`) runs the guides, chat review and game
|
|
355
|
+
decisions for free when it is here; **never download it without the person's yes** (\`ollama pull clef-flash\` is
|
|
356
|
+
about 11 GB). A game whose game.json says \`"decide": true\` may ask \`net.decide(state, questions, { floor })\`
|
|
357
|
+
on the host (\`NETPLAY.md\` section 20): per beat or per turn, never per frame.
|
|
345
358
|
|
|
346
359
|
## Selling things (the shop)
|
|
347
360
|
|
package/lib/servers.mjs
CHANGED
|
@@ -18,8 +18,14 @@
|
|
|
18
18
|
* homie-studio agents brain key [--remove] the owner's own AI key (owner-key brains), from a page on this
|
|
19
19
|
* computer only: it goes straight to the Worker secret
|
|
20
20
|
* HOMIE_BRAIN_KEY, never through a chat, a file or a log
|
|
21
|
-
* homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude]
|
|
22
|
-
* sit in a guide's seat from this terminal (the local MCP's agent_sit)
|
|
21
|
+
* homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude] [--brain local]
|
|
22
|
+
* sit in a guide's seat from this terminal (the local MCP's agent_sit);
|
|
23
|
+
* --brain local: Clef on this computer (Ollama) decides, free
|
|
24
|
+
* homie-studio agents try <game> --view <file.json> [--ask <ask>[:<arg>=<value>,…] --from <seat>] [--model <id>]
|
|
25
|
+
* [--mode workers-ai|owner-key|script] [--quiet] [--avoid <seat>,…]
|
|
26
|
+
* what the studio's guide brain would decide in that moment, with
|
|
27
|
+
* no seat taken: the decision, the brain's own pick, how sure, how
|
|
28
|
+
* long and what it cost (spent from the guides' day)
|
|
23
29
|
*
|
|
24
30
|
* The proof of ownership is the studio's own Cloudflare login, as for the office: a ten-minute office key is minted
|
|
25
31
|
* and dropped around each call. Making a server, a widening change, a pass and a room's level happen at once. A
|
|
@@ -128,6 +134,29 @@ export async function agentsBrain(root, game, server, mode, { url, budget } = {}
|
|
|
128
134
|
return askedFor(root, url, r, 'agents brain');
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
/**
|
|
138
|
+
* `homie-studio agents try`: the studio's guide brain on one moment (a view file, asks), from its live Worker (or
|
|
139
|
+
* `homie-studio dev`). The answer is the office's (worker/office.mjs agentsTry).
|
|
140
|
+
*/
|
|
141
|
+
export async function agentsTry(root, game, { url, view, ask, from, model, mode, quiet, avoid, players, readFile } = {}) {
|
|
142
|
+
const usage = 'usage: homie-studio agents try <game> --view <file.json> [--ask <ask>[:<arg>=<value>,…] --from <seat>] [--model <id>] [--mode workers-ai|owner-key|script] [--quiet] [--avoid <seat>,…]';
|
|
143
|
+
if (!GAME.test(String(game ?? '')) || typeof view !== 'string') return { ok: false, command: 'agents try', why: usage };
|
|
144
|
+
let v;
|
|
145
|
+
try { v = JSON.parse(readFile(view)); } catch (error) { return { ok: false, command: 'agents try', why: `--view is a JSON file of the game state a guide sees (${String(error?.message ?? error).slice(0, 80)})` }; }
|
|
146
|
+
const seats = (x) => (x === undefined || x === true ? undefined : String(x).split(',').map(Number).filter((n) => Number.isInteger(n) && n >= 0));
|
|
147
|
+
const asks = [];
|
|
148
|
+
if (typeof ask === 'string') {
|
|
149
|
+
const [k, rest = ''] = ask.split(':');
|
|
150
|
+
const args = Object.fromEntries(rest.split(',').filter(Boolean).map((kv) => { const [a, val = ''] = kv.split('='); return [a, /^\d+$/.test(val) ? Number(val) : val]; }));
|
|
151
|
+
asks.push({ k, args, from: Number.isInteger(Number(from)) ? Number(from) : 0 });
|
|
152
|
+
}
|
|
153
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/agents/try', {
|
|
154
|
+
game, view: v, asks, ...(typeof model === 'string' ? { model } : {}), ...(typeof mode === 'string' ? { mode } : {}),
|
|
155
|
+
...(quiet ? { quiet: true } : {}), ...(seats(avoid) ? { avoid: seats(avoid) } : {}), ...(seats(players) ? { players: seats(players) } : {}),
|
|
156
|
+
}));
|
|
157
|
+
return r.ok ? { ok: true, command: 'agents try', ...r } : { ok: false, command: 'agents try', why: r.message ?? r.why };
|
|
158
|
+
}
|
|
159
|
+
|
|
131
160
|
/**
|
|
132
161
|
* `homie-studio agents brain key`: the owner's own AI provider key (owner-key brains: claude-haiku-4-5), typed once
|
|
133
162
|
* into a page on this computer (127.0.0.1, one use, ten minutes) and handed straight to `wrangler secret put
|
|
@@ -1117,6 +1117,84 @@
|
|
|
1117
1117
|
}
|
|
1118
1118
|
}
|
|
1119
1119
|
}
|
|
1120
|
+
},
|
|
1121
|
+
"0.24.4": {
|
|
1122
|
+
"files": {
|
|
1123
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1124
|
+
"README.md": "684b3cca8001673c",
|
|
1125
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1126
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1127
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1128
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1129
|
+
"posts/README.md": "37841aae476bd540",
|
|
1130
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1131
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1132
|
+
},
|
|
1133
|
+
"sections": {
|
|
1134
|
+
"AGENTS.md": {
|
|
1135
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1136
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1137
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1138
|
+
"## The site": "ab922bb6bde23293",
|
|
1139
|
+
"## Making games": "a8a893eeada33bf5",
|
|
1140
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1141
|
+
"## Art direction and models": "124772c374de7390",
|
|
1142
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1143
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1144
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1145
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1146
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1147
|
+
"## Beta": "9d02638207a26e73"
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
"named": {
|
|
1151
|
+
"Homie Arcade": {
|
|
1152
|
+
"sections": {
|
|
1153
|
+
"AGENTS.md": {
|
|
1154
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
},
|
|
1160
|
+
"0.24.5": {
|
|
1161
|
+
"files": {
|
|
1162
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1163
|
+
"README.md": "684b3cca8001673c",
|
|
1164
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1165
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1166
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1167
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1168
|
+
"posts/README.md": "37841aae476bd540",
|
|
1169
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1170
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1171
|
+
},
|
|
1172
|
+
"sections": {
|
|
1173
|
+
"AGENTS.md": {
|
|
1174
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1175
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1176
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1177
|
+
"## The site": "ab922bb6bde23293",
|
|
1178
|
+
"## Making games": "abf2f0d59341512d",
|
|
1179
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1180
|
+
"## Art direction and models": "124772c374de7390",
|
|
1181
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1182
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1183
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1184
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1185
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1186
|
+
"## Beta": "9d02638207a26e73"
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1189
|
+
"named": {
|
|
1190
|
+
"Homie Arcade": {
|
|
1191
|
+
"sections": {
|
|
1192
|
+
"AGENTS.md": {
|
|
1193
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1120
1198
|
}
|
|
1121
1199
|
}
|
|
1122
1200
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -309,6 +309,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
309
309
|
| `roster` | host | `slots` | Labels it (section 17: a host cannot hide an AI), stores it, forwards it, and tells the shell's watchers. |
|
|
310
310
|
| `caps` | host (revision 6) | `caps: ('skill'\|'agents')[]` | What the host's game does with servers: its bots read the dial, and it moves AI bodies. Stored as the room's (the play page offers the vote; a lite agent may sit) and in the watch feed. |
|
|
311
311
|
| `vote` | a seated person, or the host (revision 6) | `of: 'skill'`, `n?: 1..levelMax`, `open?: true`, `reason?` | `open` starts the party's vote on the dial (a player may open one every 2 minutes); `n` casts or changes this seat's vote. Agents and watchers never vote. The shell's watch socket may send it too, counted as the seat of the client with the same browser key. |
|
|
312
|
+
| `decide` | host (0.24.4) | `n`, `state`, `questions` | A game's own decision (section 20): checked, paced (one every 3 s and 20 a minute per room), handed to the Table's decision model; answered with `decided` to that socket only. |
|
|
312
313
|
| `ping` | everyone | `c`, `hid` | Replies `pong`. A host whose `hid` turns true yields. |
|
|
313
314
|
| `yield` / `bye` | host / anyone | — | `yield` elects another host if one is available. `bye` is a close. |
|
|
314
315
|
|
|
@@ -328,6 +329,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
328
329
|
| `round` / `roster` | everyone but the host | `round` / `slots` |
|
|
329
330
|
| `pong` | the pinger | `c`, `st` |
|
|
330
331
|
| `error` | the offender | `code`, `message`, `of?`, `until?` |
|
|
332
|
+
| `decided` | the host that asked (0.24.4) | `n`, `ok`, and `by`, `picks`, `p`, `ms`; or `why` (`off`, `no-ai`, `budget`, `pace` and `retryMs`, `busy`, `bad`, `slow`, `error`, `not-host`) |
|
|
331
333
|
| `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
|
|
332
334
|
| `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
|
|
333
335
|
| `watch` | one watcher | `follow`, `why?` (`overview`, `seated-here`): whether it may follow one player now (section 16) |
|
|
@@ -396,6 +398,7 @@ so `ev.origin` is `"null"`.
|
|
|
396
398
|
| `ckpt` | < 32 KB | 64 KB (128 KB for 17–32 seats) | 1 Hz | 4 |
|
|
397
399
|
| `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB (16 / 8 KB for 17–32 seats) | on change | 4 / 8 |
|
|
398
400
|
| `ping` | — | 256 B | every 2 s | 8 |
|
|
401
|
+
| `decide` | < 2 KB | 6 KB (state 2 KB) | per beat (section 20) | 2; and per room one every 3 s, 20 a minute |
|
|
399
402
|
|
|
400
403
|
**Sockets.**
|
|
401
404
|
- At most `maxPlayers + 16` per room, and per client address per room **12, or every seat
|
|
@@ -1057,12 +1060,29 @@ brain never runs in the frame loop, and it can only choose: goals and lines are
|
|
|
1057
1060
|
game's own vocabulary, never free text.
|
|
1058
1061
|
|
|
1059
1062
|
**Where a brain runs.** In the room's own Table, on the studio owner's Worker (a *house
|
|
1060
|
-
guide*): Workers AI through the `AI` binding (model var `HOMIE_BRAIN_MODEL`, default
|
|
1061
|
-
`@cf/
|
|
1062
|
-
|
|
1063
|
-
owner's own
|
|
1064
|
-
|
|
1065
|
-
|
|
1063
|
+
guide*): Workers AI through the `AI` binding (model var `HOMIE_BRAIN_MODEL`, default since
|
|
1064
|
+
0.24.4 `@cf/cloudflare/clef-flash`, Cloudflare's **Clef** decision model; `@cf/cloudflare/clef`,
|
|
1065
|
+
the 27B, or a chat model such as `@cf/meta/llama-3.1-8b-instruct-fp8-fast` also work), or the
|
|
1066
|
+
owner's own key (Worker secret `HOMIE_BRAIN_KEY`, `claude-haiku-4-5` through the official
|
|
1067
|
+
`@anthropic-ai/sdk`). Under `homie-studio dev` with no binding: Clef on the person's own computer
|
|
1068
|
+
(Ollama with `clef-flash`, which dev finds; free). Or in the owner's own AI through the local MCP
|
|
1069
|
+
(`agent_sit`, about 30 s a decision), or a seat that thinks with Clef on the owner's computer
|
|
1070
|
+
(`agent_sit { brain: "local" }`). With none of them, over the day's budget, or between decisions:
|
|
1071
|
+
the game's scripted floor (`decide`). homie.rocks runs nothing and stores nothing.
|
|
1072
|
+
|
|
1073
|
+
**Clef asks the decision as the questions it is.** A decision model never writes text: it takes a
|
|
1074
|
+
state and typed questions and returns a probability for every allowed answer, in one pass. A guide's
|
|
1075
|
+
decision is a Choice of goal (only goals whose every argument has a value to take), a Choice per set
|
|
1076
|
+
of argument values (one question for a goal's quest and a line's quest, so what the guide does and
|
|
1077
|
+
says agree; a seat that said "no thanks" and the guide's own seat are never options), a Choice of
|
|
1078
|
+
line or none (only when the guide may speak), and, with no ask open, a yes/no "anything to say at
|
|
1079
|
+
all?" that a line also needs. The newest ask is in the goal question itself, with the answer
|
|
1080
|
+
agents.json gives it. The state is the sanitized view, the open asks, who said no thanks, the
|
|
1081
|
+
guide's own last lines and who is new to it. The answers are composed back into one decision, which
|
|
1082
|
+
parseDecision and every fixed rule still check. Measured 2026-10-03 on 64 recorded Ember Vale
|
|
1083
|
+
moments (`homie-studio agents try`, a throwaway studio): Clef answered 36 of 40 open asks itself
|
|
1084
|
+
(Llama 3.1 8B: 0 of 40), blind judges preferred its decisions in 51 of 64 (Llama in none), model time
|
|
1085
|
+
p50 259 ms, p90 433 ms (Llama 303 / 364 ms).
|
|
1066
1086
|
|
|
1067
1087
|
### The vocabulary: `games/<id>/agents.json`
|
|
1068
1088
|
|
|
@@ -1161,14 +1181,16 @@ with a pass (the owner's own Claude) takes a guide's seat from a house guide (`a
|
|
|
1161
1181
|
| Pace | At least 3 s between AI calls per guide, at most 10 a minute; one alarm per room at a time, at most one every 3 s. Never `setInterval`. |
|
|
1162
1182
|
| Prompt | System: the persona, the goals, the lines, the asks (about 400 tokens, stable). User: the view (sanitized: no key that names a person, an account, a ticket, an address or an age; nothing that looks like a secret; seats, never names), the asks, and the last three party lines as ids and arguments (free text only on a `speech: game` server: quoted, 120 characters, labelled as data). |
|
|
1163
1183
|
| Output | One JSON object `{ goal, args, say, sayArgs }` (a JSON schema where the provider takes one). Anything else (prose, a code fence, an unknown id, a wrong or extra argument, a player who said no thanks) is no decision: the scripted floor answers instead. |
|
|
1164
|
-
| Fixed rules | A person's ask is answered the way agents.json says (its `goal
|
|
1184
|
+
| Fixed rules | A person's ask is answered the way agents.json says (its `goal`, with the values the person asked for): a model's other goal, or the asked goal with another value, is overruled, and the asked-for goal is carried through until it is done (or 60 s) with no model call meanwhile but for a new ask. A line at most every 8 s. "No thanks" holds the guide off that player for 10 minutes. No goal aims at a player. |
|
|
1165
1185
|
| Budget | A day, for the whole studio (meta `brain_budget`): 8,000 Workers AI neurons (the free allocation is 10,000 an account), $1 of the owner's key. Counted in `stats_daily` (`brain-calls`, `brain-neurons`, `brain-microdollars`), written at most once a minute. Over it: the scripted floor until 00:00 UTC. |
|
|
1166
|
-
| Log | The last 50 decisions per room (seat, goal, line, provider, time, why), in memory, in the office. |
|
|
1186
|
+
| Log | The last 50 decisions per room (seat, goal, line, provider, time, why; the model, Clef's probability for the goal and line, and the brain's own pick when a rule overruled it), in memory, in the office. |
|
|
1187
|
+
| Try | `homie-studio agents try <game> --view <file> [--ask …] [--model …]` (office API `/_studio/api/agents/try`): the same brain and rules on one moment, no seat taken, spent from the same day. |
|
|
1167
1188
|
|
|
1168
1189
|
**Cost.** A house guide costs no request of its own beyond the alarm: one per room per due
|
|
1169
|
-
decision, at most one every 3 s. A decision is about
|
|
1170
|
-
|
|
1171
|
-
key.
|
|
1190
|
+
decision, at most one every 3 s. A Clef decision is about 9 Workers AI neurons (clef-flash: about
|
|
1191
|
+
1,000 tokens in at $0.09 a million, nothing for output), so the default day of 8,000 is about 900
|
|
1192
|
+
decisions; a Llama decision is about 4.1 (700 tokens in, 40 out); the owner's key about $0.0009.
|
|
1193
|
+
Under dev, Clef on the person's own computer costs nothing.
|
|
1172
1194
|
|
|
1173
1195
|
**Kids and safety.** The AI never types: it picks ids, the game renders the creator's text.
|
|
1174
1196
|
A beginner server's chat is quick lines only; on a kids server names are handles and no free
|
|
@@ -1270,7 +1292,7 @@ and reach every screen at once.
|
|
|
1270
1292
|
| Review | |
|
|
1271
1293
|
|---|---|
|
|
1272
1294
|
| Budget | A day, for the whole studio (meta `chat_budget`): 2,000 neurons by default. With the AI guides' 8,000 that is the 10,000 Workers AI gives an account free a day. A short message measured about 2.3 neurons (clef-flash: $0.09 per million input tokens, no output charge; the question and its answers are most of the input), so about 850 reviewed messages a day. Counted in `stats_daily` (`chat-reviews`, `chat-neurons`). |
|
|
1273
|
-
| No review | No `AI` binding (`homie-studio dev` without `--remote-ai
|
|
1295
|
+
| No review | No `AI` binding (`homie-studio dev` without `--remote-ai`, unless Clef is on the person's own computer: then it reviews there, free), the day's budget used, the model failing or slower than 1.5 s, more than 8 messages waiting: the floor alone decides, the message goes out, and the office counts it. |
|
|
1274
1296
|
| The owner | The studio's owner is never reviewed or held by slow mode (their lines are marked `owner`). |
|
|
1275
1297
|
|
|
1276
1298
|
### The owner's tools
|
|
@@ -1318,16 +1340,20 @@ ticker.
|
|
|
1318
1340
|
### What the page does (worker/chat-page.mjs)
|
|
1319
1341
|
|
|
1320
1342
|
- **Play:** a Chat pill in the room button's band (game.json `screen.share` already keeps that
|
|
1321
|
-
corner clear of the game's HUD
|
|
1343
|
+
corner clear of the game's HUD; a round icon on a phone and beside a room button kept an icon,
|
|
1344
|
+
and on a computer wherever its word would reach the middle third of the top), with a count of
|
|
1345
|
+
unread lines; its sheet: the room's last
|
|
1322
1346
|
minutes, a row of reactions (2.5 rem circles that pop), the quick lines, typing where the rules
|
|
1323
1347
|
allow it (and why not where they do not: "Sign in to type here. Emoji are open to everyone."),
|
|
1324
1348
|
Report on a line, and two switches kept in this browser: **Show my messages over my character**
|
|
1325
|
-
and **Show chat on this screen** (off: no
|
|
1326
|
-
show for a moment where the status chip sits
|
|
1327
|
-
|
|
1349
|
+
and **Show chat on this screen** (off: no strip, no float, no bubbles in this game). New lines
|
|
1350
|
+
show for a moment where the status chip sits; game.json `"screen": { "chat": … }` moves that
|
|
1351
|
+
strip per device (`at`, `x` / `y`, `desk`, `phone`, `sideways`, `tv`) or keeps new lines in the
|
|
1352
|
+
sheet (`"lines": "sheet-only"`, or `false`: only the pill's count). chat/CHAT.md has the fields.
|
|
1328
1353
|
- **Float:** every reaction rises up every screen in the room, as on the television: at most six
|
|
1329
1354
|
a second and eighteen at once, 2.4 s each with a little sway. The sender's own floats at once.
|
|
1330
|
-
- **TV** (`/<game>/tv`): the room's lines in the corner the QR card does not use
|
|
1355
|
+
- **TV** (`/<game>/tv`): the room's lines in the corner the QR card does not use (or game.json
|
|
1356
|
+
`screen.chat`'s corner), no typing.
|
|
1331
1357
|
- **Watch:** a Chat button in the band; its panel over the stage (a bottom sheet on a phone).
|
|
1332
1358
|
|
|
1333
1359
|
**Privacy.** Nothing a person says is stored: the room keeps the last 50 lines of the last 15
|
|
@@ -1344,3 +1370,55 @@ about 2.3 neurons a typed line, inside the free allocation by default.
|
|
|
1344
1370
|
**Old games and old relays.** A game built before revision 8 has the panel, the float, the ticker
|
|
1345
1371
|
and the TV corner; it draws no bubbles and its own UI cannot send. A revision-8 helper on an older
|
|
1346
1372
|
relay never hears a `line` and has `chatRules` null.
|
|
1373
|
+
|
|
1374
|
+
---
|
|
1375
|
+
|
|
1376
|
+
## 20. A game's own decisions (0.24.4)
|
|
1377
|
+
|
|
1378
|
+
Clef answers in about a tenth of a second and never writes text, so a game can ask it what to do
|
|
1379
|
+
next about its own state: a tactic for its opponents, an NPC's reaction from a fixed set, a
|
|
1380
|
+
director's call (a wave now? push harder?), a turn-based move. The host asks; the room's own
|
|
1381
|
+
Durable Object asks the studio's Workers AI (or Clef on the person's own computer under `dev`);
|
|
1382
|
+
the answer comes back as option ids, yes or no, and numbers. Nothing a player reads comes from a
|
|
1383
|
+
model.
|
|
1384
|
+
|
|
1385
|
+
```ts
|
|
1386
|
+
const d = await net.decide(
|
|
1387
|
+
{ heroes: [{ hp: 40 }, { hp: 90 }], slimes: { count: 9 } }, // the game's state (sanitized; seats, never names)
|
|
1388
|
+
{
|
|
1389
|
+
tactic: { type: 'choice', instructions: 'How should the slimes hunt?', criteria: { chase: 'Rush the nearest hero', surround: 'Close in from every side' } },
|
|
1390
|
+
wave: { type: 'noul', instructions: 'Should a wave come now?' },
|
|
1391
|
+
pressure: { type: 'score', instructions: 'How hard should the vale push?', criteria: ['A breather', 'Steady', 'Fierce'] },
|
|
1392
|
+
},
|
|
1393
|
+
{ floor: () => ({ tactic: 'chase', wave: false, pressure: 1 }) }, // the game's own answer, synchronous
|
|
1394
|
+
);
|
|
1395
|
+
// d.by: 'ai' | 'local' | 'floor'; d.picks: { tactic: 'surround', wave: false, pressure: 1.4 }; d.p: probabilities
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
| | |
|
|
1399
|
+
|---|---|
|
|
1400
|
+
| Opt-in | game.json `"decide": true` (the build carries it into games.json; deploy binds Workers AI for it). Without it the room answers `off` and the floor plays. |
|
|
1401
|
+
| Questions | 1 to 8: Choice (2 to 26 option ids), yes/no (`noul`), Score (2 to 10 levels, lowest first; the pick is the probability-weighted level). Instructions and descriptions at most 160 characters. The state at most 2 KB after the same sanitizing as a guide's view. |
|
|
1402
|
+
| Frames | Host to relay `{ t: 'decide', n, state, questions }`; relay to that host only `{ t: 'decided', n, ok, by, picks, p, ms }` or `{ t: 'decided', n, ok: false, why }` (`off`, `no-ai`, `budget`, `pace` with `retryMs`, `busy`, `bad`, `slow`, `error`, `not-host`). |
|
|
1403
|
+
| Pace | One ask every 3 s and 20 a minute per room, two at a time; the helper waits 2.5 s (at most 5) and then answers from the floor. After `off`, `no-ai` or `budget` it asks again only after a minute. |
|
|
1404
|
+
| Budget | The AI brains' day (meta `brain_budget`, 8,000 neurons by default): decisions and guides share it, counted as `brain-calls` source `decide`. About 4 neurons for three short questions. |
|
|
1405
|
+
| Office | Each room's decisions: how many, who answered, the model's median time, the neurons. Never a state. |
|
|
1406
|
+
|
|
1407
|
+
**What rate is real.** Measured 2026-10-03 on a throwaway studio (Ember Vale's slimes' director,
|
|
1408
|
+
three questions every 6 s): the host's round trip p50 160 to 253 ms, p90 347 to 497 ms; the model's
|
|
1409
|
+
time in the room p50 115 ms; about 4.1 neurons a decision. So never per frame; per second is fast
|
|
1410
|
+
enough but one room asking every second would spend about 15,000 neurons an hour, more than the free
|
|
1411
|
+
allocation's whole day; **per beat (every 5 to 10 s), per turn, or on an event** is the rate that fits
|
|
1412
|
+
the free plan (a room asking every 6 s: about 2,500 neurons an hour). Clef on the person's own
|
|
1413
|
+
computer costs nothing; its time depends on the computer.
|
|
1414
|
+
|
|
1415
|
+
**Kids and the dial.** A decision moves the game's own world (its opponents, its director), never a
|
|
1416
|
+
person, and never the party's skill dial: the bots' level stays the room's. On a kids server the game
|
|
1417
|
+
keeps its decisions gentle (Ember Vale's slimes never gang up on the most hurt hero there, and the vale
|
|
1418
|
+
never pushes past steady). The state follows a view's rules: game state and seats, never a name, an
|
|
1419
|
+
account or typed text.
|
|
1420
|
+
|
|
1421
|
+
**Ember Vale** (the starter, opt-in: its game.json ships `"decide": false`): every 6 s of a live night
|
|
1422
|
+
the host asks how the slimes hunt (chase, surround, gang up on the most hurt, regroup at the King),
|
|
1423
|
+
whether a wave comes, and how hard to push; every screen shows what the slimes are up to when a model
|
|
1424
|
+
chose it. The floor is the vale as it always played.
|
package/netplay/netplay.ts
CHANGED
|
@@ -402,6 +402,8 @@ export interface NetStats {
|
|
|
402
402
|
reconnects: number;
|
|
403
403
|
promotions: number;
|
|
404
404
|
round: number | null;
|
|
405
|
+
/** Section 20: the host's decisions: asked, answered by the studio's model ('ai'), the person's own Ollama ('local'), or the floor; median ms. */
|
|
406
|
+
decides?: { asked: number; ai: number; local: number; floor: number; msP50: number | null };
|
|
405
407
|
}
|
|
406
408
|
|
|
407
409
|
export interface Vec { x: number; y: number; z?: number }
|
|
@@ -497,6 +499,29 @@ export interface NetHandlers<S, A, C> {
|
|
|
497
499
|
held: (h: ChatHeld) => void;
|
|
498
500
|
}
|
|
499
501
|
|
|
502
|
+
/**
|
|
503
|
+
* A GAME'S OWN DECISIONS (section 20): typed questions about the game's state that the studio's decision model (Clef)
|
|
504
|
+
* answers with a probability for every option, never with text. A Choice picks one option id (2 to 26), a yes/no
|
|
505
|
+
* (`noul`) is true or false, a Score is a level (2 to 10, lowest first; the answer is the probability-weighted level).
|
|
506
|
+
*/
|
|
507
|
+
export type DecideQuestion =
|
|
508
|
+
| { type: 'choice'; instructions: string; criteria: Record<string, string | null> }
|
|
509
|
+
| { type: 'noul'; instructions: string; criteria?: { true?: string; false?: string } }
|
|
510
|
+
| { type: 'score'; instructions: string; criteria: string[] };
|
|
511
|
+
export type DecidePicks = Record<string, string | boolean | number>;
|
|
512
|
+
/**
|
|
513
|
+
* What `decide` resolves to, always: `by` 'ai' (the studio's Workers AI) or 'local' (the person's own Ollama under dev),
|
|
514
|
+
* or 'floor' (the game's own answer: no AI, not opted in, over budget, paced, slow, not the host); `why` says which.
|
|
515
|
+
* `p`: the model's probability for each option (a choice), `{ yes }` (a yes/no), or each level (a score).
|
|
516
|
+
*/
|
|
517
|
+
export interface Decided { by: 'ai' | 'local' | 'floor'; picks: DecidePicks; p?: Record<string, Record<string, number>>; ms: number; why?: string }
|
|
518
|
+
export interface DecideOptions {
|
|
519
|
+
/** The game's own answer, synchronous: used whenever the model's is not there in time. Missing picks fall back to it one by one. */
|
|
520
|
+
floor?: (state: unknown) => DecidePicks;
|
|
521
|
+
/** How long to wait for the model (default 2500 ms; at most 5000). */
|
|
522
|
+
ms?: number;
|
|
523
|
+
}
|
|
524
|
+
|
|
500
525
|
export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
501
526
|
readonly role: Role;
|
|
502
527
|
readonly isHost: boolean;
|
|
@@ -640,6 +665,13 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
640
665
|
/** Interpolation pair for rendering remote entities. */
|
|
641
666
|
sample(delayMs?: number): Sample<S> | null;
|
|
642
667
|
latest(): Snapshot<S> | null;
|
|
668
|
+
/**
|
|
669
|
+
* Host: ask the studio's decision model (Clef) a few typed questions about the game's state, for a game whose
|
|
670
|
+
* game.json says "decide": true. Always resolves: with the model's picks, or the floor's when it cannot answer in
|
|
671
|
+
* time (no AI, over the day's budget, at most one ask every 3 s a room, a replica, offline). Never per frame: per
|
|
672
|
+
* beat (every few seconds), and the game keeps playing while it waits.
|
|
673
|
+
*/
|
|
674
|
+
decide(state: unknown, questions: Record<string, DecideQuestion>, opts?: DecideOptions): Promise<Decided>;
|
|
643
675
|
stats(): NetStats;
|
|
644
676
|
expose(probes: Probes): void;
|
|
645
677
|
close(): void;
|
|
@@ -1069,6 +1101,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1069
1101
|
/** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
|
|
1070
1102
|
const inSent: number[] = [];
|
|
1071
1103
|
let inSlowUntil = 0;
|
|
1104
|
+
/** Section 20: decisions waiting for the room's answer (by request id), and how long the room said it is off. */
|
|
1105
|
+
const deciding = new Map<string, { done: (d: Decided) => void; timer: ReturnType<typeof setTimeout>; at: number; floor: () => Decided }>();
|
|
1106
|
+
let decideSeq = 0;
|
|
1107
|
+
let decideOffUntil = 0;
|
|
1108
|
+
let decideNextAt = 0;
|
|
1109
|
+
const decideStats = { asked: 0, ai: 0, local: 0, floor: 0, ms: [] as number[] };
|
|
1072
1110
|
let idleSkipped = 0;
|
|
1073
1111
|
/** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
|
|
1074
1112
|
let closedWhy: string | null = null;
|
|
@@ -1460,6 +1498,29 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1460
1498
|
resolveView('policy');
|
|
1461
1499
|
return;
|
|
1462
1500
|
}
|
|
1501
|
+
case 'decided': {
|
|
1502
|
+
const n = String(m['n'] ?? '');
|
|
1503
|
+
const w = deciding.get(n);
|
|
1504
|
+
if (!w) return;
|
|
1505
|
+
deciding.delete(n);
|
|
1506
|
+
clearTimeout(w.timer);
|
|
1507
|
+
const ms = Math.round(wall() - w.at);
|
|
1508
|
+
if (m['ok'] === true && m['picks'] && typeof m['picks'] === 'object') {
|
|
1509
|
+
const base = w.floor();
|
|
1510
|
+
const by = m['by'] === 'local' ? 'local' : 'ai';
|
|
1511
|
+
decideStats[by] += 1;
|
|
1512
|
+
decideStats.ms.push(ms); if (decideStats.ms.length > 40) decideStats.ms.shift();
|
|
1513
|
+
w.done({ by, picks: { ...base.picks, ...(m['picks'] as DecidePicks) }, p: (m['p'] as Decided['p']) ?? {}, ms });
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
const why = String(m['why'] ?? 'error');
|
|
1517
|
+
// Not opted in, no AI or out of budget: ask again only after a minute. Paced: at the room's next slot.
|
|
1518
|
+
if (why === 'off' || why === 'no-ai' || why === 'budget') decideOffUntil = wall() + 60_000;
|
|
1519
|
+
if (why === 'pace' && Number.isFinite(Number(m['retryMs']))) decideNextAt = wall() + Number(m['retryMs']);
|
|
1520
|
+
decideStats.floor += 1;
|
|
1521
|
+
w.done({ ...w.floor(), why, ms });
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1463
1524
|
case 'error': {
|
|
1464
1525
|
const code = String(m['code'] ?? '');
|
|
1465
1526
|
// Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
|
|
@@ -1748,6 +1809,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1748
1809
|
interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
|
|
1749
1810
|
owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
|
|
1750
1811
|
peers: peers.size, reconnects, promotions, round: roundInfo ? roundInfo.n : null,
|
|
1812
|
+
decides: { asked: decideStats.asked, ai: decideStats.ai, local: decideStats.local, floor: decideStats.floor, msP50: [...decideStats.ms].sort((x, y) => x - y)[Math.floor(decideStats.ms.length / 2)] ?? null },
|
|
1751
1813
|
};
|
|
1752
1814
|
}
|
|
1753
1815
|
function notifyStats(force: boolean): void {
|
|
@@ -2185,6 +2247,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2185
2247
|
return { a: newest, b: newest, alpha: 1, renderT, starved: 0 };
|
|
2186
2248
|
},
|
|
2187
2249
|
latest() { return buf.length ? buf[buf.length - 1] as Snapshot<S> : null; },
|
|
2250
|
+
decide(state, questions, o = {}) {
|
|
2251
|
+
const t0 = wall();
|
|
2252
|
+
const floor = (): Decided => {
|
|
2253
|
+
let picks: DecidePicks = {};
|
|
2254
|
+
try { picks = o.floor ? o.floor(state) ?? {} : {}; } catch { picks = {}; }
|
|
2255
|
+
return { by: 'floor', picks, ms: Math.round(wall() - t0) };
|
|
2256
|
+
};
|
|
2257
|
+
decideStats.asked += 1;
|
|
2258
|
+
const now0 = wall();
|
|
2259
|
+
const why = role !== 'host' ? 'not-host' : offline || !connected ? 'offline' : now0 < decideOffUntil ? 'off' : now0 < decideNextAt ? 'pace' : null;
|
|
2260
|
+
if (why) { decideStats.floor += 1; return Promise.resolve({ ...floor(), why }); }
|
|
2261
|
+
decideNextAt = now0 + 3000;
|
|
2262
|
+
const n = `d${(decideSeq += 1).toString(36)}`;
|
|
2263
|
+
return new Promise<Decided>((done) => {
|
|
2264
|
+
const ms = Math.max(200, Math.min(5000, Number(o.ms) || 2500));
|
|
2265
|
+
const timer = setTimeout(() => { if (deciding.delete(n)) { decideStats.floor += 1; done({ ...floor(), why: 'slow' }); } }, ms);
|
|
2266
|
+
deciding.set(n, { done, timer, at: t0, floor });
|
|
2267
|
+
if (!raw({ t: 'decide', n, state, questions })) { deciding.delete(n); clearTimeout(timer); decideStats.floor += 1; done({ ...floor(), why: 'offline' }); }
|
|
2268
|
+
});
|
|
2269
|
+
},
|
|
2188
2270
|
stats: statsNow,
|
|
2189
2271
|
expose(p) { probes = { ...probes, ...p }; },
|
|
2190
2272
|
close() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.5",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/site/SITE.md
CHANGED
|
@@ -169,6 +169,10 @@ where the game has room, per device:
|
|
|
169
169
|
- A button at the bottom left sends the "finding a room" chip to the bottom right.
|
|
170
170
|
- On a server, the server's pill sits beside the button, on its inner side in the same band, never under it: on a
|
|
171
171
|
phone held upright it is one more dot; elsewhere its name shows for a few seconds, then it is a dot too.
|
|
172
|
+
- Room chat's Chat pill sits in the same band: a round icon on a phone (either way up) and beside a button kept an
|
|
173
|
+
icon; on a computer it says "Chat" unless the word would take the band off the screen or into the middle third of
|
|
174
|
+
its width, where games keep a clock or a title. Its strip of new lines (bottom left by default) moves with
|
|
175
|
+
`"screen": { "chat": … }`, per device, or stays in the Chat sheet (`"lines": "sheet-only"`): chat/CHAT.md.
|
|
172
176
|
|
|
173
177
|
Look at the play page on a computer and a phone (both ways up) once a round is on: the button must not sit on a
|
|
174
178
|
score, a timer or a bar. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
|