@homie-rocks/studio 0.24.2 → 0.24.4
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 +56 -1
- package/README.md +51 -5
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +77 -15
- package/chat/CHAT.md +2 -0
- package/lib/agent-seat.mjs +60 -2
- package/lib/brain-probe.mjs +6 -5
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +13 -3
- package/lib/doctor.mjs +64 -5
- package/lib/local-ai.mjs +66 -0
- package/lib/mcp-tools.mjs +10 -2
- package/lib/projects-env.mjs +68 -0
- package/lib/projects.mjs +311 -0
- package/lib/scaffold.mjs +20 -2
- package/lib/servers.mjs +31 -2
- package/lib/shop-catalog.mjs +163 -0
- package/lib/shop.mjs +151 -60
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +86 -12
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- package/shop/SELLING.md +16 -0
- package/shop/SHOP.md +65 -16
- 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.mjs +13 -6
- package/worker/index.mjs +57 -2
- package/worker/office.mjs +65 -4
- package/worker/room.mjs +48 -3
- package/worker/shop-rules.mjs +17 -1
- package/worker/shop.mjs +32 -9
- package/worker/stripe.mjs +30 -0
- package/worker/version.mjs +1 -1
package/lib/shop.mjs
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
* writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
|
|
8
8
|
* homie-studio shop check shop.json against the kit's rules (no randomness, real money, the cap…)
|
|
9
9
|
* homie-studio shop connect [--managed] [--live] a page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
|
|
10
|
-
* pastes the studio's restricted Stripe key
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* pastes the studio's restricted Stripe key (the one thing only they can make);
|
|
11
|
+
* with it this process makes the webhook (0.24.3), and the key and the
|
|
12
|
+
* webhook's signing secret go straight to the Worker secrets STRIPE_KEY and
|
|
13
|
+
* STRIPE_WEBHOOK_SECRET on Wrangler's standard input: never a chat, a file,
|
|
14
|
+
* an argument or a log. The page also offers Stripe Managed Payments in plain
|
|
15
|
+
* words (and in test mode tries one test checkout with it). Test keys only,
|
|
16
|
+
* unless --live (then live keys only).
|
|
17
|
+
* homie-studio shop catalog [--have <file>|-] [--mode test|live] the items as Products in Stripe, made through
|
|
18
|
+
* Stripe's own MCP (lib/shop-catalog.mjs): the read to make, then the writes
|
|
15
19
|
* homie-studio shop disconnect removes both secrets (the shop closes at once)
|
|
16
20
|
* homie-studio shop orders the latest orders (never a card or an email)
|
|
17
21
|
* homie-studio shop refund <order> [--reason requested_by_customer|duplicate|fraudulent] [--note "<why>"]
|
|
@@ -28,8 +32,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
32
|
import { askedFor, withKey } from './office.mjs';
|
|
29
33
|
import { runner } from './cloudflare.mjs';
|
|
30
34
|
import { listGames, readStudio, siteUrl } from './studio.mjs';
|
|
31
|
-
import { SHOP_FILE, audienceOf, checkShop, money } from '../worker/shop-rules.mjs';
|
|
32
|
-
import {
|
|
35
|
+
import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
|
|
36
|
+
import {
|
|
37
|
+
KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
|
|
38
|
+
stripeCall, updateWebhookEndpoint,
|
|
39
|
+
} from '../worker/stripe.mjs';
|
|
33
40
|
|
|
34
41
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
35
42
|
const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
|
|
@@ -44,6 +51,7 @@ export const KEY_PERMISSIONS = Object.freeze([
|
|
|
44
51
|
['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
|
|
45
52
|
['PaymentIntents', 'Read', 'which payment an order was'],
|
|
46
53
|
['Disputes', 'Read', 'see a dispute; you answer it in Stripe'],
|
|
54
|
+
['Webhook Endpoints', 'Write', 'only so this page makes the webhook for you; you can set it back to None afterwards'],
|
|
47
55
|
]);
|
|
48
56
|
|
|
49
57
|
/** shop.json read and checked with the studio's games and audience: { ok, shop, errors, warnings, file }. */
|
|
@@ -126,46 +134,105 @@ export async function shopStatements(root, { url, period, send = false } = {}) {
|
|
|
126
134
|
|
|
127
135
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
128
136
|
|
|
129
|
-
/**
|
|
137
|
+
/** Where Stripe sends the shop's events, on the studio's live site. */
|
|
138
|
+
export const hookUrlOf = (site) => `${String(site).replace(/\/+$/, '')}/api/shop/hook`;
|
|
139
|
+
const HOOK_MARK = 'shop-v1';
|
|
140
|
+
|
|
141
|
+
/** The one page the owner uses: what to make in Stripe, what Managed Payments costs and does, one field, one button. */
|
|
130
142
|
export function connectPage({ nonce, site, studio, till = 'stripe', test = true }) {
|
|
131
143
|
const dash = `https://dashboard.stripe.com${test ? '/test' : ''}`;
|
|
132
144
|
const perms = KEY_PERMISSIONS.map(([n, level, why]) => `<li><b>${esc(n)}</b>: ${esc(level)} <small>(${esc(why)})</small></li>`).join('');
|
|
133
145
|
const events = HOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ');
|
|
134
146
|
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Connect your shop to Stripe</title>
|
|
135
|
-
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2.5rem auto;padding:0 1rem;color:#1b1b1b}h1{font-size:1.6rem}h2{font-size:1.1rem;margin-top:1.8rem}input[type=password]{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}label{display:block;margin:.8rem 0 .3rem;font-weight:600}fieldset{border:1px solid #ccc;border-radius:10px;padding:.6rem 1rem}fieldset label{font-weight:400;display:flex;gap:.6rem;align-items:flex-start}button{margin-top:1.2rem;font:inherit;font-weight:700;padding:.7rem 1.3rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}code{font-size:.85em;background:#f2f2f2;padding:0 .25em;border-radius:4px}ol li,ul li{margin:.25rem 0}.box{background:#f6f6f2;border-radius:10px;padding:.8rem 1rem}</style>
|
|
147
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2.5rem auto;padding:0 1rem;color:#1b1b1b}h1{font-size:1.6rem}h2{font-size:1.1rem;margin-top:1.8rem}input[type=password]{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}label{display:block;margin:.8rem 0 .3rem;font-weight:600}fieldset{border:1px solid #ccc;border-radius:10px;padding:.6rem 1rem}fieldset label{font-weight:400;display:flex;gap:.6rem;align-items:flex-start}button{margin-top:1.2rem;font:inherit;font-weight:700;padding:.7rem 1.3rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}code{font-size:.85em;background:#f2f2f2;padding:0 .25em;border-radius:4px}ol li,ul li{margin:.25rem 0}.box{background:#f6f6f2;border-radius:10px;padding:.8rem 1rem}details{margin-top:1rem}summary{cursor:pointer}</style>
|
|
136
148
|
<h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
|
|
137
|
-
<p>Money goes straight from players to <b>your own Stripe account</b>. Homie never sees it and takes no cut. What you paste here goes from this page to your studio's Worker as
|
|
138
|
-
<p class="box">${test ? '<b>Test mode first.</b> Use your Stripe
|
|
139
|
-
<h2>1. In Stripe:
|
|
140
|
-
<ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b
|
|
149
|
+
<p>Money goes straight from players to <b>your own Stripe account</b>. Homie never sees it and takes no cut. What you paste here goes from this page to Stripe (one check) and to your studio's Worker as a secret, and nowhere else: not to the chat, not to a file, not to a log.</p>
|
|
150
|
+
<p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code>): nothing real is charged, and test cards work. Live keys only when you say the shop is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
|
|
151
|
+
<h2>1. In Stripe: one restricted key</h2>
|
|
152
|
+
<ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b>. If Stripe asks what the key is for, choose your own integration, <b>not</b> "Authorizing agent access" (Stripe holds an agent key's refunds for a second approval).</li>
|
|
141
153
|
<li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
|
|
142
154
|
<li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
|
|
143
|
-
<h2>2.
|
|
144
|
-
<
|
|
145
|
-
<li>Endpoint URL: <code>${esc(site)}/api/shop/hook</code></li>
|
|
146
|
-
<li>Events: ${events}</li>
|
|
147
|
-
<li>Create it, press <b>Reveal</b> on the signing secret, and copy it (it starts with <code>whsec_</code>).</li></ol>
|
|
155
|
+
<h2>2. The webhook: this page makes it</h2>
|
|
156
|
+
<p>With your key, this page makes the endpoint where Stripe tells your shop about payments (<code>${esc(hookUrlOf(site))}</code>, for ${HOOK_EVENTS.length} events), and its signing secret goes straight to your Worker. Nobody sees it. An older one this page made for the same address is turned off, never deleted.</p>
|
|
148
157
|
<form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
|
|
149
|
-
<h2>3. Paste
|
|
158
|
+
<h2>3. Paste the key here</h2>
|
|
150
159
|
<label for="key">Restricted key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
|
|
151
|
-
<
|
|
160
|
+
<details><summary>I made the webhook myself (or my key has no Webhook Endpoints permission)</summary>
|
|
161
|
+
<ol><li>Open <a href="${dash}/workbench/webhooks" target="_blank" rel="noopener">Webhooks</a> and press <b>Create an event destination</b> (Your account, a Webhook endpoint).</li>
|
|
162
|
+
<li>Endpoint URL: <code>${esc(hookUrlOf(site))}</code></li><li>Events: ${events}</li>
|
|
163
|
+
<li>Create it, press <b>Reveal</b> on the signing secret, and paste it here (it starts with <code>whsec_</code>).</li></ol>
|
|
164
|
+
<label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…"></details>
|
|
152
165
|
<h2>4. Who is the seller?</h2>
|
|
153
166
|
<fieldset>
|
|
154
167
|
<label><input type="radio" name="till" value="stripe"${till !== 'stripe-managed' ? ' checked' : ''}> <span><b>You are</b> (standard Stripe). Stripe takes its usual card fee (in the US 2.9% + 30¢ a sale; in Canada 2.9% + CA$0.30). Stripe Tax is on: it works out and collects sales tax and VAT where you have told Stripe you are registered (0.5% a sale there). Registering and filing are yours, and some countries (the EU, the UK) expect a foreign seller to register from the first sale.</span></label>
|
|
155
|
-
<label><input type="radio" name="till" value="stripe-managed"${till === 'stripe-managed' ? ' checked' : ''}> <span><b>Stripe is</b> (Stripe Managed Payments). <b>3.5% more</b> a sale, on top of the card fee. Stripe becomes the seller of record: it registers for, collects, files and pays sales tax and VAT in 80+ countries, runs fraud checks, answers card disputes for you and handles buyers' payment questions. Statements read <code>LINK.COM*</code>. You still cover the money of a lost dispute, and Stripe may refund a buyer within 60 days. Turn it on in Stripe first (<a href="${dash}/settings/managed-payments" target="_blank" rel="noopener">Managed Payments</a>, after Stripe's eligibility review; Canada and the US are among the countries it serves)
|
|
168
|
+
<label><input type="radio" name="till" value="stripe-managed"${till === 'stripe-managed' ? ' checked' : ''}> <span><b>Stripe is</b> (Stripe Managed Payments). <b>3.5% more</b> a sale, on top of the card fee. Stripe becomes the seller of record: it registers for, collects, files and pays sales tax and VAT in 80+ countries, runs fraud checks, answers card disputes for you and handles buyers' payment questions. Statements read <code>LINK.COM*</code>. You still cover the money of a lost dispute, and Stripe may refund a buyer within 60 days. Turn it on in Stripe first (<a href="${dash}/settings/managed-payments" target="_blank" rel="noopener">Managed Payments</a>, after Stripe's eligibility review; Canada and the US are among the countries it serves).${test ? ' In test mode this page tries one test checkout with Managed Payments (expired at once) and says whether Stripe takes it.' : ''}</span></label>
|
|
156
169
|
</fieldset>
|
|
157
170
|
<button>Save to my Worker</button></form>
|
|
158
171
|
<p><small>This page works once and closes in ten minutes. You can change the seller later in shop.json ("till"). This is not legal or tax advice: an accountant can tell you where you must register.</small></p>`;
|
|
159
172
|
}
|
|
160
173
|
|
|
174
|
+
const said = (status, text) => ({ status, text });
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The webhook, made on the owner's computer with the key they just pasted: Stripe answers the new endpoint's signing
|
|
178
|
+
* secret once, and it goes straight to the Worker secret. Never printed, never returned. Older endpoints this kit made
|
|
179
|
+
* for the same address are turned off (not deleted) once the new secret is saved. { ok, id, secret, older } or
|
|
180
|
+
* { ok: false, page } (what the page tells the owner).
|
|
181
|
+
*/
|
|
182
|
+
async function makeWebhook(env, site, { fetcher }) {
|
|
183
|
+
const url = hookUrlOf(site);
|
|
184
|
+
let older = [];
|
|
185
|
+
try {
|
|
186
|
+
const list = await listWebhookEndpoints(env, { fetcher });
|
|
187
|
+
older = (list?.data ?? []).filter((e) => e?.url === url && e?.metadata?.homie === HOOK_MARK && e?.status !== 'disabled').map((e) => e.id).filter((id) => typeof id === 'string');
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
|
|
190
|
+
return { ok: false, page: said(502, `Stripe did not answer the webhook list (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
|
|
191
|
+
}
|
|
192
|
+
let made = null;
|
|
193
|
+
try {
|
|
194
|
+
made = await createWebhookEndpoint(env, {
|
|
195
|
+
url, enabled_events: [...HOOK_EVENTS], api_version: STRIPE_VERSION,
|
|
196
|
+
description: 'Homie shop (made by homie-studio shop connect)', metadata: { homie: HOOK_MARK },
|
|
197
|
+
}, { fetcher });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
|
|
200
|
+
return { ok: false, page: said(502, `Stripe did not make the webhook (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
|
|
201
|
+
}
|
|
202
|
+
const secret = typeof made?.secret === 'string' ? made.secret : '';
|
|
203
|
+
if (!WEBHOOK_SECRET_SHAPE.test(secret) || typeof made?.id !== 'string') return { ok: false, page: said(502, 'Stripe made a webhook but did not hand back its signing secret. Nothing was saved. Make the webhook yourself and paste its secret under "I made the webhook myself".') };
|
|
204
|
+
return { ok: true, id: made.id, secret, older };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Managed Payments, tried once in TEST mode before the first buyer: a test Checkout Session with
|
|
209
|
+
* `managed_payments[enabled]`, expired at once. Stripe's answer says whether Managed Payments is on for this account
|
|
210
|
+
* (there is no API to read it). Never in live mode.
|
|
211
|
+
*/
|
|
212
|
+
async function probeManaged(env, shop, { fetcher }) {
|
|
213
|
+
const item = shop?.items?.find((i) => i.kind !== 'tip') ?? null;
|
|
214
|
+
try {
|
|
215
|
+
const s = await stripeCall(env, 'POST', '/v1/checkout/sessions', {
|
|
216
|
+
mode: 'payment', success_url: 'https://example.com/homie-shop-check', cancel_url: 'https://example.com/homie-shop-check',
|
|
217
|
+
line_items: [{ quantity: 1, price_data: { currency: shop?.currency ?? 'usd', unit_amount: item?.price ?? 500, tax_behavior: 'exclusive', product_data: { name: 'Homie shop check (test, expired at once)', tax_code: defaultTaxCode(item ?? {}) } } }],
|
|
218
|
+
managed_payments: { enabled: true }, metadata: { homie: 'shop-check' },
|
|
219
|
+
}, { fetcher, idempotencyKey: `homie-check-${randomBytes(8).toString('hex')}` });
|
|
220
|
+
if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch { /* it expires by itself */ } }
|
|
221
|
+
return { ok: true, words: 'Stripe took a test checkout with Managed Payments: it is on for this account (test mode).' };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return { ok: false, words: `Stripe refused a test checkout with Managed Payments (${error instanceof StripeError ? error.message : 'no answer'}). Finish Managed Payments in Stripe (Settings → Managed Payments: accept the terms, pass the eligibility review), or pick "You are" the seller.` };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
161
227
|
/**
|
|
162
|
-
* `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key
|
|
163
|
-
* to `wrangler secret put` on its standard input
|
|
164
|
-
*
|
|
228
|
+
* `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key goes to Stripe once (a
|
|
229
|
+
* read, then the webhook), and to `wrangler secret put` on its standard input; so does the webhook's signing secret,
|
|
230
|
+
* which Stripe hands this process when it makes the endpoint. Nothing is printed or returned but ids and words.
|
|
165
231
|
*/
|
|
166
232
|
export async function shopConnect(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
|
|
167
233
|
const studio = readStudio(root);
|
|
168
|
-
const site = siteUrl(root)
|
|
234
|
+
const site = siteUrl(root);
|
|
235
|
+
if (!site) return { ok: false, command: 'shop connect', needs: 'deploy', why: 'the studio has no live address yet, and Stripe needs one to send payments to (the webhook): run npm run deploy first, then this' };
|
|
169
236
|
const env = studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {};
|
|
170
237
|
const w = runner(root, env);
|
|
171
238
|
const shopNow = readShop(root);
|
|
@@ -174,6 +241,7 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
|
|
|
174
241
|
const page = connectPage({ nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
|
|
175
242
|
return await new Promise((done) => {
|
|
176
243
|
let finished = false;
|
|
244
|
+
let busy = false;
|
|
177
245
|
const server = createServer((req, res) => {
|
|
178
246
|
if (req.method === 'GET' && req.url === `/${nonce}`) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(page); return; }
|
|
179
247
|
if (req.method === 'POST' && req.url === '/key') {
|
|
@@ -182,43 +250,66 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
|
|
|
182
250
|
req.on('end', async () => {
|
|
183
251
|
const form = new URLSearchParams(body);
|
|
184
252
|
const key = String(form.get('key') ?? '').trim();
|
|
185
|
-
|
|
253
|
+
let hook = String(form.get('hook') ?? '').trim();
|
|
186
254
|
const chosen = form.get('till') === 'stripe-managed' ? 'stripe-managed' : 'stripe';
|
|
187
|
-
const say = (status, text) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
|
|
188
|
-
if (form.get('n') !== nonce || finished) { say(403, 'This page was used already.'); return; }
|
|
189
|
-
if (
|
|
190
|
-
if (
|
|
255
|
+
const say = ({ status, text }) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
|
|
256
|
+
if (form.get('n') !== nonce || finished) { say(said(403, 'This page was used already.')); return; }
|
|
257
|
+
if (busy) { say(said(409, 'Still saving the last press. Wait a moment.')); return; }
|
|
258
|
+
if (/^sk_live_/.test(key)) { say(said(400, 'That is a full secret key. The shop takes only a RESTRICTED key (rk_…) with the permissions on the page. Go back and make one.')); return; }
|
|
259
|
+
if (!KEY_SHAPE.test(key) || !/^rk_/.test(key)) { say(said(400, 'That does not look like a restricted key (rk_test_… or rk_live_…). Go back and try again.')); return; }
|
|
191
260
|
// Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
|
|
192
|
-
if (modeOf(key) !== (live ? 'live' : 'test')) { say(400, live ? 'This page takes a LIVE restricted key (rk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).'); return; }
|
|
193
|
-
if (!WEBHOOK_SECRET_SHAPE.test(hook)) { say(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again.'); return; }
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
261
|
+
if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE restricted key (rk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).')); return; }
|
|
262
|
+
if (hook && !WEBHOOK_SECRET_SHAPE.test(hook)) { say(said(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again, or leave it empty and this page makes the webhook.')); return; }
|
|
263
|
+
busy = true;
|
|
264
|
+
try {
|
|
265
|
+
const sk = { STRIPE_KEY: key, STRIPE_API_BASE: process.env.STRIPE_API_BASE };
|
|
266
|
+
// One read with the key, so a typo is caught here and not at the first sale. It never leaves this computer
|
|
267
|
+
// except to Stripe itself.
|
|
268
|
+
if (verify) {
|
|
269
|
+
try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch {
|
|
270
|
+
say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.')); return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
let webhook = { made: false };
|
|
274
|
+
if (!hook) {
|
|
275
|
+
const m = await makeWebhook(sk, site, { fetcher });
|
|
276
|
+
if (!m.ok) { say(m.page); return; }
|
|
277
|
+
hook = m.secret;
|
|
278
|
+
webhook = { made: true, id: m.id, older: m.older };
|
|
279
|
+
}
|
|
280
|
+
finished = true;
|
|
281
|
+
const a = w(['secret', 'put', 'STRIPE_KEY'], { input: `${key}\n` });
|
|
282
|
+
const b = a.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
|
|
283
|
+
hook = '';
|
|
284
|
+
const okay = a.code === 0 && b.code === 0;
|
|
285
|
+
// The new secret is saved: the older endpoints this kit made for the same address stop sending (turned off,
|
|
286
|
+
// not deleted: the owner can turn one back on in Stripe).
|
|
287
|
+
let disabled = 0;
|
|
288
|
+
if (okay && webhook.made) {
|
|
289
|
+
for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch { /* left on; Stripe retries and says so */ } }
|
|
290
|
+
}
|
|
291
|
+
const check = okay && !live && chosen === 'stripe-managed' ? await probeManaged(sk, shopNow.shop, { fetcher }) : null;
|
|
292
|
+
let tillNote = '';
|
|
293
|
+
if (okay && shopNow.shop && !shopNow.absent && chosen !== shopNow.shop.till) {
|
|
294
|
+
try {
|
|
295
|
+
const raw = JSON.parse(readFileSync(shopNow.file, 'utf8'));
|
|
296
|
+
raw.till = chosen;
|
|
297
|
+
writeFileSync(shopNow.file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
298
|
+
tillNote = ` shop.json now says "till": "${chosen}" (deploy to use it).`;
|
|
299
|
+
} catch { tillNote = ''; }
|
|
300
|
+
}
|
|
301
|
+
const narrow = webhook.made ? ' Recommended, 20 seconds: in Stripe, open this key (… → Edit key) and set Webhook Endpoints back to None. The shop never needs it again.' : '';
|
|
302
|
+
say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check ? ` ${check.words}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
|
|
303
|
+
server.close();
|
|
304
|
+
done(okay
|
|
305
|
+
? {
|
|
306
|
+
ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen,
|
|
307
|
+
webhook: webhook.made ? { made: true, id: webhook.id, url: hookUrlOf(site), turnedOff: disabled } : { made: false, url: hookUrlOf(site) },
|
|
308
|
+
...(check ? { managedPayments: check } : {}),
|
|
309
|
+
message: `Saved as the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET (never shown), ${modeOf(key)} mode, seller: ${chosen === 'stripe-managed' ? 'Stripe (Managed Payments)' : 'the studio'}.${webhook.made ? ` Stripe made the webhook ${webhook.id} for ${hookUrlOf(site)}${disabled ? ` and ${disabled} older one(s) were turned off` : ''}.` : ''}${tillNote}${narrow} homie-studio shop says whether anything else is missing.`,
|
|
310
|
+
}
|
|
311
|
+
: { ok: false, command: 'shop connect', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
|
|
312
|
+
} finally { busy = false; }
|
|
222
313
|
});
|
|
223
314
|
return;
|
|
224
315
|
}
|
|
@@ -1078,6 +1078,84 @@
|
|
|
1078
1078
|
}
|
|
1079
1079
|
}
|
|
1080
1080
|
}
|
|
1081
|
+
},
|
|
1082
|
+
"0.24.3": {
|
|
1083
|
+
"files": {
|
|
1084
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1085
|
+
"README.md": "684b3cca8001673c",
|
|
1086
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1087
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1088
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1089
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1090
|
+
"posts/README.md": "37841aae476bd540",
|
|
1091
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1092
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1093
|
+
},
|
|
1094
|
+
"sections": {
|
|
1095
|
+
"AGENTS.md": {
|
|
1096
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1097
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1098
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1099
|
+
"## The site": "ab922bb6bde23293",
|
|
1100
|
+
"## Making games": "a8a893eeada33bf5",
|
|
1101
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1102
|
+
"## Art direction and models": "124772c374de7390",
|
|
1103
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1104
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
1105
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1106
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1107
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1108
|
+
"## Beta": "9d02638207a26e73"
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
"named": {
|
|
1112
|
+
"Homie Arcade": {
|
|
1113
|
+
"sections": {
|
|
1114
|
+
"AGENTS.md": {
|
|
1115
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
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
|
+
}
|
|
1081
1159
|
}
|
|
1082
1160
|
}
|
|
1083
1161
|
}
|
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
|
|
@@ -1344,3 +1366,55 @@ about 2.3 neurons a typed line, inside the free allocation by default.
|
|
|
1344
1366
|
**Old games and old relays.** A game built before revision 8 has the panel, the float, the ticker
|
|
1345
1367
|
and the TV corner; it draws no bubbles and its own UI cannot send. A revision-8 helper on an older
|
|
1346
1368
|
relay never hears a `line` and has `chatRules` null.
|
|
1369
|
+
|
|
1370
|
+
---
|
|
1371
|
+
|
|
1372
|
+
## 20. A game's own decisions (0.24.4)
|
|
1373
|
+
|
|
1374
|
+
Clef answers in about a tenth of a second and never writes text, so a game can ask it what to do
|
|
1375
|
+
next about its own state: a tactic for its opponents, an NPC's reaction from a fixed set, a
|
|
1376
|
+
director's call (a wave now? push harder?), a turn-based move. The host asks; the room's own
|
|
1377
|
+
Durable Object asks the studio's Workers AI (or Clef on the person's own computer under `dev`);
|
|
1378
|
+
the answer comes back as option ids, yes or no, and numbers. Nothing a player reads comes from a
|
|
1379
|
+
model.
|
|
1380
|
+
|
|
1381
|
+
```ts
|
|
1382
|
+
const d = await net.decide(
|
|
1383
|
+
{ heroes: [{ hp: 40 }, { hp: 90 }], slimes: { count: 9 } }, // the game's state (sanitized; seats, never names)
|
|
1384
|
+
{
|
|
1385
|
+
tactic: { type: 'choice', instructions: 'How should the slimes hunt?', criteria: { chase: 'Rush the nearest hero', surround: 'Close in from every side' } },
|
|
1386
|
+
wave: { type: 'noul', instructions: 'Should a wave come now?' },
|
|
1387
|
+
pressure: { type: 'score', instructions: 'How hard should the vale push?', criteria: ['A breather', 'Steady', 'Fierce'] },
|
|
1388
|
+
},
|
|
1389
|
+
{ floor: () => ({ tactic: 'chase', wave: false, pressure: 1 }) }, // the game's own answer, synchronous
|
|
1390
|
+
);
|
|
1391
|
+
// d.by: 'ai' | 'local' | 'floor'; d.picks: { tactic: 'surround', wave: false, pressure: 1.4 }; d.p: probabilities
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
| | |
|
|
1395
|
+
|---|---|
|
|
1396
|
+
| 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. |
|
|
1397
|
+
| 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. |
|
|
1398
|
+
| 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`). |
|
|
1399
|
+
| 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. |
|
|
1400
|
+
| 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. |
|
|
1401
|
+
| Office | Each room's decisions: how many, who answered, the model's median time, the neurons. Never a state. |
|
|
1402
|
+
|
|
1403
|
+
**What rate is real.** Measured 2026-10-03 on a throwaway studio (Ember Vale's slimes' director,
|
|
1404
|
+
three questions every 6 s): the host's round trip p50 160 to 253 ms, p90 347 to 497 ms; the model's
|
|
1405
|
+
time in the room p50 115 ms; about 4.1 neurons a decision. So never per frame; per second is fast
|
|
1406
|
+
enough but one room asking every second would spend about 15,000 neurons an hour, more than the free
|
|
1407
|
+
allocation's whole day; **per beat (every 5 to 10 s), per turn, or on an event** is the rate that fits
|
|
1408
|
+
the free plan (a room asking every 6 s: about 2,500 neurons an hour). Clef on the person's own
|
|
1409
|
+
computer costs nothing; its time depends on the computer.
|
|
1410
|
+
|
|
1411
|
+
**Kids and the dial.** A decision moves the game's own world (its opponents, its director), never a
|
|
1412
|
+
person, and never the party's skill dial: the bots' level stays the room's. On a kids server the game
|
|
1413
|
+
keeps its decisions gentle (Ember Vale's slimes never gang up on the most hurt hero there, and the vale
|
|
1414
|
+
never pushes past steady). The state follows a view's rules: game state and seats, never a name, an
|
|
1415
|
+
account or typed text.
|
|
1416
|
+
|
|
1417
|
+
**Ember Vale** (the starter, opt-in: its game.json ships `"decide": false`): every 6 s of a live night
|
|
1418
|
+
the host asks how the slimes hunt (chase, surround, gang up on the most hurt, regroup at the King),
|
|
1419
|
+
whether a wave comes, and how hard to push; every screen shows what the slimes are up to when a model
|
|
1420
|
+
chose it. The floor is the vale as it always played.
|