@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/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.4",
|
|
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/shop/SELLING.md
CHANGED
|
@@ -74,6 +74,22 @@ referral share you published in `shop.json` (10% if you kept the suggestion; 0 i
|
|
|
74
74
|
shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
|
|
75
75
|
yourself.** Nothing moves through Homie.
|
|
76
76
|
|
|
77
|
+
## What you do, and what your AI does
|
|
78
|
+
|
|
79
|
+
Your AI sets the shop up with Stripe's own tools for AI (Stripe's agent plugin: its MCP server and skills). It
|
|
80
|
+
makes your products in Stripe, checks your tax settings, makes the webhook, and answers "how are sales?" by reading
|
|
81
|
+
your Stripe. What stays yours, in Stripe's own pages:
|
|
82
|
+
|
|
83
|
+
- making the Stripe account, and later activating it (your business details, bank account and identity checks);
|
|
84
|
+
- one sign-in page that lets your AI use Stripe's tools: give it your **sandbox** (test) first;
|
|
85
|
+
- one restricted key you make in Stripe and paste into a page on your own computer (never into a chat);
|
|
86
|
+
- approving anything Stripe asks you to confirm (a refund your AI asked Stripe for, for example), and every refund
|
|
87
|
+
in your office.
|
|
88
|
+
|
|
89
|
+
If your AI's app cannot use Stripe's sign-in page, Stripe offers an "Agent" key for it instead. From 31 October 2026
|
|
90
|
+
Stripe's tools for AI accept only those (or the sign-in page). The shop's own key is a different, ordinary
|
|
91
|
+
restricted key; never make that one an Agent key, or Stripe will hold every refund for a second approval.
|
|
92
|
+
|
|
77
93
|
## Before the first real sale
|
|
78
94
|
|
|
79
95
|
1. Make (or pick) your Stripe account, verify it, and add a bank account.
|
package/shop/SHOP.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# The shop: selling in a studio's games with the studio's own Stripe
|
|
2
2
|
|
|
3
|
-
`@homie-rocks/studio` 0.24.0. A studio sells items for its games
|
|
3
|
+
`@homie-rocks/studio` 0.24.0, set up with Stripe's own agent tools since 0.24.3. A studio sells items for its games
|
|
4
|
+
through **its own Stripe account**. The studio
|
|
4
5
|
is the seller; homie.rocks never holds, routes or settles its money, is not a Stripe Connect platform, and takes
|
|
5
6
|
no cut. There is no shared currency between studios. `SELLING.md` beside this file is the owner's plain-words
|
|
6
7
|
note on what selling means for them (refunds, disputes, tax, kids; not legal advice).
|
|
@@ -40,6 +41,7 @@ At the studio's root, reviewed in git. Nothing secret is ever in it.
|
|
|
40
41
|
| `items[].advantage` | `true` when it changes how the game plays: never offered, and never counted as owned, on a beginner or kids server. |
|
|
41
42
|
| `items[].taxCode` | a Stripe product tax code. Managed Payments needs one: by default `txcd_10201000` (video games, downloaded, permanent) or `txcd_10201001` (limited time). |
|
|
42
43
|
| `referrals` | what this studio pays a referrer for a new player's purchase (left out: nothing). |
|
|
44
|
+
| `catalog` | where the items are Products in Stripe: `["test"]`, `["test", "live"]`. `homie-studio shop catalog` writes it once Stripe's catalog matches; the Worker's checkouts then name each item's Product (0.24.3). |
|
|
43
45
|
|
|
44
46
|
`homie-studio shop check`, and every `homie-studio build`, refuse: anything that names chance, odds, random, a
|
|
45
47
|
crate, a box, a mystery, loot, a gacha, a spin or a roll; fields for odds or drop pools; a countdown, timer or
|
|
@@ -107,23 +109,59 @@ homie.rocks: not involved at any step.
|
|
|
107
109
|
- Each Checkout Session says beside the pay button that the item is delivered at once (the EU and UK withdrawal
|
|
108
110
|
acknowledgement) and that an unused item can still be refunded.
|
|
109
111
|
|
|
110
|
-
## Setting it up (
|
|
112
|
+
## Setting it up (the AI does the commands; the owner only uses Stripe's pages and one page here)
|
|
113
|
+
|
|
114
|
+
The AI works with **Stripe's own agent tools** (0.24.3): Stripe's agent plugin (`npm install -g @stripe/cli@latest &&
|
|
115
|
+
stripe agent setup`: Stripe's MCP server at `https://mcp.stripe.com` and Stripe's skills, for Claude Code and Codex) or
|
|
116
|
+
Stripe's connector in the Claude app. The owner signs it in once on Stripe's consent page and gives it **a sandbox
|
|
117
|
+
first**. An agent that cannot do OAuth uses an **Agent key** (a restricted key Stripe tags "Agent") from its
|
|
118
|
+
environment; from **2026-10-31** Stripe's MCP refuses full secret keys and restricted keys without that tag. The shop's
|
|
119
|
+
own key, in the Worker, is a plain restricted key and is not affected.
|
|
120
|
+
|
|
121
|
+
What only the owner does: make the Stripe account and (for live) activate it: business details, bank, identity, and,
|
|
122
|
+
for Managed Payments, its terms and Stripe's eligibility review; approve Stripe's sign-in page; make **one restricted
|
|
123
|
+
key** in Stripe's Dashboard (Stripe has no API that makes API keys) and paste it on the connect page; approve any
|
|
124
|
+
write Stripe sends to them for confirmation (a refund through Stripe's MCP, for one).
|
|
111
125
|
|
|
112
126
|
1. `npx --no-install homie-studio shop init --supporter` (shop.json and SELLING.md), then `shop check`, commit,
|
|
113
127
|
`npm run deploy` (it applies migration `0008_studio_shop.sql`; the shop stays closed).
|
|
114
|
-
2.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
`
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
2. **The catalog**, through Stripe's MCP: `homie-studio shop catalog` names the one read to make
|
|
129
|
+
(`stripe_api_read GET /v1/products`, expanding each default price); `homie-studio shop catalog --have <the saved
|
|
130
|
+
answer>` lists exactly the `stripe_api_write` calls still needed: each item a Product with the id
|
|
131
|
+
`homie_<studio slug>_<item id>` (the same in a sandbox and live), its name, blurb, a tax code (video games,
|
|
132
|
+
downloaded: `txcd_10201000`, or `txcd_10201001` for an item that lasts days or ends on a date; eligible for
|
|
133
|
+
Managed Payments) and metadata naming the studio and the item, with a default Price of shop.json's amount (a tip:
|
|
134
|
+
the Product only). A changed price is a new Price made the default; an item gone from shop.json is archived, never
|
|
135
|
+
deleted; a product the studio did not make is never named. When Stripe matches, it says so and writes
|
|
136
|
+
`"catalog": ["test"]` into shop.json (commit, deploy). shop.json's checked price is always what is charged; the
|
|
137
|
+
checkout names the Product so Stripe's Dashboard, reports and MCP see sales by product, and a Product missing in
|
|
138
|
+
this mode falls back to the item described inline.
|
|
139
|
+
3. **Tax**, read through Stripe's MCP: `GET /v1/tax/settings` (with the studio as the seller, Stripe Tax needs
|
|
140
|
+
`status: active`, the business address, or checkouts fail) and `GET /v1/tax/registrations` (none: Stripe Tax
|
|
141
|
+
collects nothing anywhere until the owner registers somewhere). Reading only; the owner and their accountant decide.
|
|
142
|
+
4. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes). The
|
|
143
|
+
owner makes one restricted key with **Checkout Sessions: Write, Charges: Write (refunds), PaymentIntents: Read,
|
|
144
|
+
Disputes: Read, Webhook Endpoints: Write**, not an Agent key, and pastes it. With it the page reads once (a typo is
|
|
145
|
+
caught here), **makes the webhook** at `https://<site>/api/shop/hook` for `checkout.session.completed`,
|
|
146
|
+
`checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`,
|
|
147
|
+
`charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`, `charge.dispute.closed` (API version
|
|
148
|
+
`2025-03-31.basil`, metadata `homie: shop-v1`), and puts the key and the webhook's signing secret, which Stripe hands
|
|
149
|
+
back once, straight into the Worker secrets `STRIPE_KEY` and `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input.
|
|
150
|
+
An older endpoint the kit made for the same address is turned off, never deleted. The page offers the seller
|
|
151
|
+
choice (the studio, or Stripe Managed Payments, with what each costs and does) and, in test mode with Managed
|
|
152
|
+
Payments, tries one test checkout with it (expired at once) and says whether Stripe took it. Afterwards the owner
|
|
153
|
+
may set the key's Webhook Endpoints back to None (recommended; the shop never needs it again). A key without
|
|
154
|
+
Webhook Endpoints: the page takes a webhook signing secret the owner made and revealed in Stripe instead. Test keys
|
|
155
|
+
only, unless `--live`.
|
|
156
|
+
5. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
|
|
157
|
+
refund it from `/_studio/office/shop`. Through Stripe's MCP, `GET /v1/webhook_endpoints` shows the endpoint the
|
|
158
|
+
page made (its `we_…` id): if it is not there, the MCP is signed in to another account or sandbox than the key.
|
|
159
|
+
6. **Live**, only when the owner says so: they activate the account, give Stripe's MCP access to the live account,
|
|
160
|
+
`shop catalog --mode live` makes the live catalog the same way, and `shop connect --live` takes a live key.
|
|
161
|
+
|
|
162
|
+
Never ask for a key in a chat, never put one in a file, an argument, a log or a commit. **Never make a webhook endpoint
|
|
163
|
+
or event destination through Stripe's MCP**: its answer carries the signing secret into the conversation (in Claude
|
|
164
|
+
Code the Homie mod refuses the call). Never use a Stripe CLI login or a key found on the computer.
|
|
127
165
|
|
|
128
166
|
## The office
|
|
129
167
|
|
|
@@ -133,6 +171,15 @@ to the studio's own Stripe Dashboard, a CSV for the accountant, and the referral
|
|
|
133
171
|
refund (`POST /_studio/api/shop/refund`, `homie-studio shop refund <order>`) is always an ASK the owner confirms
|
|
134
172
|
with one tap; so is marking a referrer paid.
|
|
135
173
|
|
|
174
|
+
A refund made anywhere else (in Stripe's Dashboard, or through Stripe's MCP after the owner approved Stripe's own
|
|
175
|
+
confirmation link) reaches the shop as `refund.created` / `charge.refunded`, and the item leaves the player's account
|
|
176
|
+
the same way. If the Worker's key is an Agent key, Stripe holds the Worker's refunds for a person's approval
|
|
177
|
+
(`approval_required`): the office answers 202 "held", nothing changes until someone approves it in Stripe (Settings,
|
|
178
|
+
Approvals), and the webhook finishes it. Reconnect with a plain restricted key to get the one tap back.
|
|
179
|
+
|
|
180
|
+
"How are sales?": `homie-studio shop` and `shop orders` (the studio's own books, no names), and through Stripe's MCP,
|
|
181
|
+
read-only, `stripe_analytics` or `stripe_api_read` on the balance, payouts and checkout sessions.
|
|
182
|
+
|
|
136
183
|
## Referrals
|
|
137
184
|
|
|
138
185
|
- A link with `?via=<host>` (another studio, homie.rocks, anywhere) opened by a person whose browser has never
|
|
@@ -150,5 +197,7 @@ with one tap; so is marking a referrer paid.
|
|
|
150
197
|
## Testing without money
|
|
151
198
|
|
|
152
199
|
The kit's tests run the Worker against `stripe-mock` (Stripe's open-source API mock, which checks every parameter
|
|
153
|
-
against Stripe's own API description) and sign webhooks with a test secret.
|
|
200
|
+
against Stripe's own API description) and sign webhooks with a test secret. stripe-mock also takes every write
|
|
201
|
+
`shop catalog` lists, a checkout that names its Product, and the webhook the connect page makes. Stripe's MCP is
|
|
202
|
+
never called by the tests: `shop catalog --have` reads an answer in the shapes Stripe's API documents. `STRIPE_API_BASE` (a loopback address
|
|
154
203
|
only) points a Worker at such a mock; it is never honoured for anything but 127.0.0.1 or localhost.
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
|
|
44
44
|
* every lab call is a no-op.
|
|
45
45
|
*/
|
|
46
|
-
import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
46
|
+
import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type DecidePicks, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
47
47
|
import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
|
|
48
48
|
import vocabulary from '../agents.json';
|
|
49
49
|
// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
|
|
@@ -376,12 +376,66 @@ function stepHost(dt: number): void {
|
|
|
376
376
|
b.flags = now - b.strikeAt < T.flashMs ? STRIKING : 0;
|
|
377
377
|
if (wants && live && now - b.atkAt > T.strikeMs) strike(b, now);
|
|
378
378
|
}
|
|
379
|
-
if (live) stepSlimes(dt, now);
|
|
379
|
+
if (live) { stepDirector(now); stepSlimes(dt, now); }
|
|
380
380
|
// Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
|
|
381
381
|
net.state('quests', openQuests());
|
|
382
382
|
room.update();
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/* ------------------------------------------------------------------ the slimes' director (section 20, opt-in) */
|
|
386
|
+
/**
|
|
387
|
+
* Every 6 s of a live night the host asks the studio's decision model how the slimes should hunt (`net.decide`: Clef,
|
|
388
|
+
* three typed questions, never text): a tactic, whether a wave comes now, how hard to push. It runs only when game.json
|
|
389
|
+
* says "decide": true (the room answers "off" otherwise, and asks are spent from the studio's AI brains' day); without it,
|
|
390
|
+
* over budget or slow, the floor answers: chase, no wave, steady, which is how the vale always played. On a kids server
|
|
391
|
+
* the slimes never gang up on the most hurt hero and the vale never pushes past steady. The heroes' dial is untouched:
|
|
392
|
+
* this is the slimes' mind, not the bots'.
|
|
393
|
+
*/
|
|
394
|
+
type Tactic = 'chase' | 'surround' | 'weakest' | 'regroup';
|
|
395
|
+
const TACTICS: Record<Tactic, string> = {
|
|
396
|
+
chase: 'Rush the nearest hero',
|
|
397
|
+
surround: 'Spread out and close in on a hero from every side',
|
|
398
|
+
weakest: 'Gang up on the most hurt hero',
|
|
399
|
+
regroup: 'Fall back to the King Slime and gather before the next push',
|
|
400
|
+
};
|
|
401
|
+
/** How often a slime comes, by pressure (0 a breather .. 4 fierce). 1100 ms at 2 is the vale as it always was. */
|
|
402
|
+
const SPAWN_MS = [1900, 1450, 1100, 850, 700];
|
|
403
|
+
const DIRECTOR_MS = 6000;
|
|
404
|
+
const director = { tactic: 'chase' as Tactic, pressure: 2, waveLeft: 0, by: 'floor', at: 0, asking: false, log: [] as Record<string, unknown>[] };
|
|
405
|
+
const directorFloor = (): DecidePicks => ({ tactic: 'chase', wave: false, pressure: 2 });
|
|
406
|
+
function stepDirector(now: number): void {
|
|
407
|
+
if (!room.hosting || director.asking || now < director.at) return;
|
|
408
|
+
director.at = now + DIRECTOR_MS;
|
|
409
|
+
director.asking = true;
|
|
410
|
+
const kidsRoom = kids();
|
|
411
|
+
const king = slimes.find((o) => o.size === 3);
|
|
412
|
+
const heroes = [...room.bodies.values()].filter((b) => !b.bot && b.seat !== null);
|
|
413
|
+
const state = {
|
|
414
|
+
heroes: heroes.map((b) => ({ hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN), level: b.level, nearKing: king ? Math.round(Math.hypot(king.x - b.x, king.y - b.y)) : null })),
|
|
415
|
+
slimes: { count: slimes.length, big: slimes.filter((o) => o.size === 2).length, king: king ? Math.round((100 * king.hp) / king.maxHp) : null },
|
|
416
|
+
night: { secondsLeft: room.clock().secondsLeft }, now: { tactic: director.tactic, pressure: director.pressure },
|
|
417
|
+
...(kidsRoom ? { note: 'Young players: keep it gentle' } : {}),
|
|
418
|
+
};
|
|
419
|
+
const tactics = Object.fromEntries(Object.entries(TACTICS).filter(([k]) => !(kidsRoom && k === 'weakest')));
|
|
420
|
+
const asked = performance.now();
|
|
421
|
+
void net.decide(state, {
|
|
422
|
+
tactic: { type: 'choice', instructions: 'How should the slimes hunt the heroes for the next few seconds? A fun fight, not a massacre: hurt heroes deserve a breather.', criteria: tactics },
|
|
423
|
+
wave: { type: 'noul', instructions: 'Should a wave of extra slimes come now?', criteria: { true: 'The heroes are healthy and it has gone quiet', false: 'The heroes are hurt, down or few' } },
|
|
424
|
+
pressure: { type: 'score', instructions: 'How hard should the vale push the heroes right now?', criteria: ['A breather', 'Easy', 'Steady', 'Hard', 'Fierce'] },
|
|
425
|
+
}, { floor: directorFloor }).then((d) => {
|
|
426
|
+
director.asking = false;
|
|
427
|
+
const t = String(d.picks['tactic'] ?? 'chase') as Tactic;
|
|
428
|
+
director.tactic = t in tactics ? t : 'chase';
|
|
429
|
+
director.pressure = clamp(Math.round(Number(d.picks['pressure'] ?? 2)), 0, kidsRoom ? 2 : 4);
|
|
430
|
+
if (d.picks['wave'] === true && director.waveLeft === 0) director.waveLeft = 3;
|
|
431
|
+
director.by = d.by;
|
|
432
|
+
director.log.push({ at: Date.now(), by: d.by, why: d.why ?? null, ms: Math.round(performance.now() - asked), tactic: director.tactic, pressure: director.pressure, wave: d.picks['wave'] === true });
|
|
433
|
+
if (director.log.length > 40) director.log.shift();
|
|
434
|
+
// Every screen sees what the slimes are up to (only when a model decided; the floor is the vale as always).
|
|
435
|
+
net.state('slimes', d.by === 'floor' ? null : { tactic: director.tactic, pressure: director.pressure });
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
385
439
|
/* ------------------------------------------------------------------ the guides' hands */
|
|
386
440
|
const bodyOfSeat = (seat: unknown): Body | null => { for (const o of room.bodies.values()) if (!o.bot && o.seat === seat) return o; return null; };
|
|
387
441
|
const people = (): Body[] => [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null && !(o.flags & DOWN));
|
|
@@ -498,8 +552,11 @@ function stepSlimes(dt: number, now: number): void {
|
|
|
498
552
|
if (lab.stage === 'dummy') return; // the lab's training slime stands still and never bites
|
|
499
553
|
const bodies = [...room.bodies.values()].filter((b) => !(b.flags & DOWN));
|
|
500
554
|
const people = bodies.filter((b) => !b.bot).length;
|
|
501
|
-
|
|
502
|
-
|
|
555
|
+
// The director's pressure (0 a breather .. 4 fierce; 2 is how the vale always played) sets how fast and how many.
|
|
556
|
+
const cap = Math.max(4, 6 + people * 2 + (director.pressure - 2) * 2) + (director.waveLeft > 0 ? 3 : 0);
|
|
557
|
+
if ((now >= spawnAt || director.waveLeft > 0) && slimes.length < cap) {
|
|
558
|
+
spawnAt = now + SPAWN_MS[director.pressure]!;
|
|
559
|
+
if (director.waveLeft > 0) director.waveLeft -= 1;
|
|
503
560
|
const edge = Math.floor(Math.random() * 4);
|
|
504
561
|
const x = edge === 0 ? 30 : edge === 1 ? W - 30 : Math.random() * W;
|
|
505
562
|
const y = edge === 2 ? 30 : edge === 3 ? H - 30 : Math.random() * H;
|
|
@@ -517,10 +574,18 @@ function stepSlimes(dt: number, now: number): void {
|
|
|
517
574
|
if (struck.has(s.id)) continue;
|
|
518
575
|
let target: Body | null = null; let bd = Infinity;
|
|
519
576
|
for (const b of prey) { const d = Math.hypot(b.x - s.x, b.y - s.y); if (d < bd) { bd = d; target = b; } }
|
|
577
|
+
// The director's tactic (section 20; chase is how the vale always played): the most hurt hero, a ring round the
|
|
578
|
+
// nearest, or back to the King to gather (a hero close by is still bitten).
|
|
579
|
+
if (director.tactic === 'weakest' && prey.length > 1) target = prey.reduce((a, b) => (b.hp / b.maxHp < a.hp / a.maxHp ? b : a));
|
|
520
580
|
const speed = s.size === 3 ? 70 : 95 - s.size * 10;
|
|
521
581
|
if (target) {
|
|
522
|
-
|
|
523
|
-
|
|
582
|
+
let gx = target.x; let gy = target.y;
|
|
583
|
+
const king = slimes.find((o) => o.size === 3);
|
|
584
|
+
if (director.tactic === 'surround' && bd > 90) { const a = s.id * 2.39996; gx += Math.cos(a) * 80; gy += Math.sin(a) * 80; }
|
|
585
|
+
if (director.tactic === 'regroup' && king && s !== king && bd > 140) { gx = king.x + Math.cos(s.id) * 70; gy = king.y + Math.sin(s.id) * 70; }
|
|
586
|
+
const dx = gx - s.x; const dy = gy - s.y; const dist = Math.hypot(target.x - s.x, target.y - s.y) || 1;
|
|
587
|
+
const step = Math.hypot(dx, dy) || 1;
|
|
588
|
+
if (step > 4) { s.x += (dx / step) * speed * dt; s.y += (dy / step) * speed * dt; }
|
|
524
589
|
if (dist < R + 10 + s.size * 10 && now - s.hitAt > 800) {
|
|
525
590
|
s.hitAt = now;
|
|
526
591
|
target.hp -= 5 * s.size + (s.size === 3 ? 10 : 0);
|
|
@@ -919,6 +984,13 @@ function hud(t: number): void {
|
|
|
919
984
|
// Top centre; on a narrow screen where that would touch the panel, just under it.
|
|
920
985
|
if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
|
|
921
986
|
else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
|
|
987
|
+
// What the slimes are up to, when the studio's decision model chose it (section 20; nothing shows on the floor).
|
|
988
|
+
const mind = c.phase === 'live' ? net.stateOf<{ tactic: string; pressure: number }>('slimes') : null;
|
|
989
|
+
if (mind) {
|
|
990
|
+
const words: Record<string, string> = { chase: 'rushing you', surround: 'closing in from every side', weakest: 'hunting the hurt', regroup: 'gathering round the King' };
|
|
991
|
+
ctx.font = SMALL; ctx.fillStyle = '#e7a86b'; ctx.textAlign = 'center';
|
|
992
|
+
ctx.fillText(`Slimes: ${words[mind.tactic] ?? mind.tactic}`, cw / 2, x0 + pw + 10 > cw / 2 - 80 ? y0 + 62 + 40 : 50);
|
|
993
|
+
}
|
|
922
994
|
ctx.textAlign = 'center';
|
|
923
995
|
if (c.phase === 'over') {
|
|
924
996
|
const rows = room.results().slice(0, 5);
|
|
@@ -1144,6 +1216,8 @@ net.expose({
|
|
|
1144
1216
|
me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
|
|
1145
1217
|
log: guideLog.slice(),
|
|
1146
1218
|
}),
|
|
1219
|
+
// The slimes' director (section 20): its tactic now, and each decision (who answered, how long it took).
|
|
1220
|
+
director: () => ({ tactic: director.tactic, pressure: director.pressure, by: director.by, log: director.log.slice(), net: net.stats().decides ?? null }),
|
|
1147
1221
|
// Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
|
|
1148
1222
|
camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
|
|
1149
1223
|
labels: () => shownLabels.map((l) => ({ text: l.text, self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
|