@homie-rocks/studio 0.45.3 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/guides/game/RULES.md +54 -1
- package/guides/game/SKILL.md +46 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +42 -0
- package/netplay/NETPLAY.md +3 -1
- package/package.json +1 -1
- package/rules/core.ts +12 -6
- package/rules/host.ts +100 -11
- package/rules/rules.ts +21 -8
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +152 -16
- package/worker/room-store.mjs +113 -11
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,64 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.46.0] - 2026-10-10
|
|
21
|
+
|
|
22
|
+
**Plugin 0.46.0**
|
|
23
|
+
|
|
24
|
+
Keep a world and its account characters across restarts.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Opt-in lasting single-area worlds: `room.lifetime: "persistent"` retains a room
|
|
29
|
+
across empty periods, restarts and compatible builds. State fields declared
|
|
30
|
+
`{ durable: true }` are written the moment they change, as one small journal
|
|
31
|
+
record committed before that tick is published, exactly once across a crash.
|
|
32
|
+
The rest of the room is checkpointed every `room.durability.movementSeconds`
|
|
33
|
+
(30 by default for a lasting world) and on pause or an orderly stop; a restore
|
|
34
|
+
is the last checkpoint plus the journal, so value is never lost or repeated
|
|
35
|
+
though positions may rewind a moment. A room where nothing changes writes
|
|
36
|
+
nothing. Storage failures stop play and retain committed state.
|
|
37
|
+
- Optional `room.characters: true` keeps one character per game on the existing
|
|
38
|
+
signed-in player account. Rules read `player.character` on join and write its
|
|
39
|
+
inventory through declared entity fields. A durable lease and return outbox
|
|
40
|
+
prevent two rooms holding the character at once. Explicit `world.finish()`
|
|
41
|
+
resets retain the latest account character instead of replaying its old inventory.
|
|
42
|
+
No manual provisioning.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Only opted-in worlds use a stable public room instead of allocating fresh match
|
|
47
|
+
copies. Full worlds wait for seats; named room links select other worlds.
|
|
48
|
+
- Lasting worlds default to `offline: false` so practice cannot look like a
|
|
49
|
+
durable session. An explicit `offline: true` still allows unsaved local practice.
|
|
50
|
+
- Only character games require studio account sign-in before playing and return
|
|
51
|
+
the character on disconnect/recall instead of leaving it as a bot.
|
|
52
|
+
- `room.durability.movementSeconds` defaults to 30 when `lifetime` is
|
|
53
|
+
`"persistent"`; it is still 1 for every other room. In a lasting world an
|
|
54
|
+
undeclared field, a position or an undeclared non-player entity can come back
|
|
55
|
+
up to that many seconds old after a crash: declare what must not.
|
|
56
|
+
- Only lasting worlds retain incompatible saves instead of
|
|
57
|
+
resetting and stop on write failure. Their save envelopes do
|
|
58
|
+
not use the match path's 16 MB cap. Existing simulation and Cloudflare constraints
|
|
59
|
+
still apply. Ordinary rules games and browser-hosted games keep their APIs,
|
|
60
|
+
save costs and lifecycle.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- Refund-event SQL triggers no longer try to parse plain-text settings as JSON,
|
|
65
|
+
which could prevent account room tickets being signed. The automatic
|
|
66
|
+
`0020_function_meta.sql` migration preserves refund IDs and delivery behavior.
|
|
67
|
+
|
|
68
|
+
### Upgrade notes
|
|
69
|
+
|
|
70
|
+
- First milestone-3 slice. Compatible deployments still restart the Worker and
|
|
71
|
+
reload old views; uninterrupted rule swaps, migrations, durable command retries,
|
|
72
|
+
atomic trades and restore points remain later slices. Commands should be
|
|
73
|
+
idempotent; a command lost before commitment has not happened. Queued events are
|
|
74
|
+
not atomic transfers when a recipient leaves. Existing cloud saves, shop orders
|
|
75
|
+
and grants, and app records retain their existing behavior.
|
|
76
|
+
- Version 0.46.0 is provisional. No deploy or merge is part of this PR.
|
|
77
|
+
|
|
20
78
|
## [0.45.3] - 2026-10-10
|
|
21
79
|
|
|
22
80
|
**Plugin 0.45.3**
|
package/guides/game/RULES.md
CHANGED
|
@@ -137,6 +137,58 @@ for an app. A wall/TV watches without taking a seat. Each interactive phone
|
|
|
137
137
|
takes one of the room's declared seats; shared state is replicated, public and transient. An app's
|
|
138
138
|
lasting, authorized business data belongs in `createAppRecords`, not in room fields.
|
|
139
139
|
|
|
140
|
+
## Lasting worlds and account characters
|
|
141
|
+
|
|
142
|
+
For a world that should still exist tomorrow, set game.json:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{"room":{"host":"server","lifetime":"persistent","characters":true}}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Lasting worlds default to `offline: false`: wait for the durable server. A studio
|
|
149
|
+
may explicitly set `offline: true` for separate local practice, whose progress
|
|
150
|
+
is not saved to the world or account. Never count that as proof of persistence.
|
|
151
|
+
|
|
152
|
+
`characters` is optional: `lifetime` alone keeps the world. Account characters
|
|
153
|
+
require the existing studio sign-in/passkey. Play redirects to the account page
|
|
154
|
+
when needed. In `room.join(ctx, player)`, return
|
|
155
|
+
`player.character ?? { kind: 'runner', at: spawn }`. Keep inventory in that player
|
|
156
|
+
kind's declared fields and change it only in rules. Mark inventory/currency and
|
|
157
|
+
collectible-ID sets with `{durable: true, visibility: 'owner'}`; use `durable: true`
|
|
158
|
+
for world changes and public progress too, omitting visibility only when public.
|
|
159
|
+
Durability and recipient visibility are separate declarations. The runtime returns its kind,
|
|
160
|
+
position, heading, fields and motion to the account when it leaves. Use
|
|
161
|
+
`player: true` or `leave: 'despawn'`; a returned character cannot also become a bot.
|
|
162
|
+
Do not reset inventory in `arrive`, round-start handlers or `world.finish`.
|
|
163
|
+
|
|
164
|
+
The public world has a stable room name; `?room=<name>` chooses another world.
|
|
165
|
+
Every durable change commits before publishing its results. An empty world sleeps and
|
|
166
|
+
retains state. A failed write stops play with the last good save intact. Another
|
|
167
|
+
room waits for the current holder to return the character; no timeout grants a
|
|
168
|
+
stale copy. No database or IDs are set up by hand. Ordinary games keep their
|
|
169
|
+
existing save frequency and behavior.
|
|
170
|
+
|
|
171
|
+
Write idempotent pickups using a saved collectible ID/set; duplicate commands
|
|
172
|
+
must not mint again. Events arrive later and can become `undeliverable` when a
|
|
173
|
+
recipient leaves; do not treat sending one as an atomic inventory transfer.
|
|
174
|
+
Trading and shop-grant consumption need the later economy slice. Browser saves
|
|
175
|
+
remain player-owned and are not an authoritative inventory.
|
|
176
|
+
|
|
177
|
+
Durable fields, entity lifecycle, pending events and character leases use an
|
|
178
|
+
atomic incremental journal. The rest is checkpointed every 30 seconds by default
|
|
179
|
+
(`room.durability.movementSeconds`, 1–60), and on pause/shutdown. Unchanged rooms
|
|
180
|
+
write nothing. A crash can rewind movement, but committed value and consumed
|
|
181
|
+
inputs replay together exactly once. Declare every fact needed to prevent a
|
|
182
|
+
repeat reward durable, including pickup IDs and cooldowns that protect value.
|
|
183
|
+
Compatible deploys still restart/reload and then restore the world.
|
|
184
|
+
A state-schema change is refused on restore with data retained; keep the previous
|
|
185
|
+
compatible build until migrations ship. Offline play has no account authority.
|
|
186
|
+
|
|
187
|
+
For proof, build normally, then sign two players in and test movement, pickup,
|
|
188
|
+
restart mid-pickup, a compatible rebuild, and a fresh profile signing in as the
|
|
189
|
+
same player. Verify actual inventory and world state. A lasting world need not
|
|
190
|
+
finish a round: its browser proof replaces the match check's round requirement.
|
|
191
|
+
|
|
140
192
|
## State and field types
|
|
141
193
|
|
|
142
194
|
`f` declares state, event/command/effect payloads, ask state and guide views.
|
|
@@ -683,7 +735,8 @@ for a minute with delay. Movement should respond before the authoritative respon
|
|
|
683
735
|
score and other shared outcomes still arrive with the network. The studio chooses `players.max`; neither frame rate nor feel is guaranteed by a passing build.
|
|
684
736
|
|
|
685
737
|
Room state is saved automatically (default movementSeconds: 1, plus round end,
|
|
686
|
-
pause and finish
|
|
738
|
+
pause and finish; a lasting world instead journals its durable fields at once and
|
|
739
|
+
checkpoints the rest every 30 seconds). After 60 seconds with no person the room ends even if screens or
|
|
687
740
|
AI remain. Use browser createSaves for player-owned character progress; it is not
|
|
688
741
|
server-verified money. Entity fields default to public. Declare visibility explicitly for private information (below); shared state and motion remain public. Use declared server round records for verified match history.
|
|
689
742
|
|
package/guides/game/SKILL.md
CHANGED
|
@@ -179,11 +179,55 @@ edits, build, act on the diagnostics and test the actual play before declaring i
|
|
|
179
179
|
Never turn typing off to make room: place the strip instead (`chat/CHAT.md` has every field).
|
|
180
180
|
- Update `game.json` `name` and `blurb`, and the `<title>`.
|
|
181
181
|
|
|
182
|
+
## Lasting worlds and account characters
|
|
183
|
+
|
|
184
|
+
For a world that should still exist tomorrow, set game.json:
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
{"room":{"host":"server","lifetime":"persistent","characters":true}}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Lasting worlds default to `offline: false`: wait for the durable server. A studio
|
|
191
|
+
may explicitly set `offline: true` for separate local practice, whose progress
|
|
192
|
+
is not saved to the world or account. Never count that as proof of persistence.
|
|
193
|
+
|
|
194
|
+
`characters` is optional: `lifetime` alone keeps the world. Account characters
|
|
195
|
+
require the existing studio sign-in/passkey. Play redirects to the account page
|
|
196
|
+
when needed. In `room.join(ctx, player)`, return
|
|
197
|
+
`player.character ?? { kind: 'runner', at: spawn }`. Keep inventory in that player
|
|
198
|
+
kind's declared fields and change it only in rules. The runtime returns its kind,
|
|
199
|
+
position, heading, fields and motion to the account when it leaves. Use
|
|
200
|
+
`player: true` or `leave: 'despawn'`; a returned character cannot also become a bot.
|
|
201
|
+
Do not reset inventory in `arrive`, round-start handlers or `world.finish`.
|
|
202
|
+
|
|
203
|
+
The public world has a stable room name; `?room=<name>` chooses another world.
|
|
204
|
+
Every tick commits before publishing its results. An empty world sleeps and
|
|
205
|
+
retains state. A failed write stops play with the last good save intact. Another
|
|
206
|
+
room waits for the current holder to return the character; no timeout grants a
|
|
207
|
+
stale copy. No database or IDs are set up by hand. Ordinary games keep their
|
|
208
|
+
existing save frequency and behavior.
|
|
209
|
+
|
|
210
|
+
Write idempotent pickups using a saved collectible ID/set; duplicate commands
|
|
211
|
+
must not mint again. Events arrive later and can become `undeliverable` when a
|
|
212
|
+
recipient leaves; do not treat sending one as an atomic inventory transfer.
|
|
213
|
+
Trading and shop-grant consumption need the later economy slice. Browser saves
|
|
214
|
+
remain player-owned and are not an authoritative inventory.
|
|
215
|
+
|
|
216
|
+
This first slice saves the whole world every tick, so opted-in games pay for
|
|
217
|
+
more writes. Compatible deploys still restart/reload and then restore the world.
|
|
218
|
+
A state-schema change is refused on restore with data retained; keep the previous
|
|
219
|
+
compatible build until migrations ship. Offline play has no account authority.
|
|
220
|
+
|
|
221
|
+
For proof, build normally, then sign two players in and test movement, pickup,
|
|
222
|
+
restart mid-pickup, a compatible rebuild, and a fresh profile signing in as the
|
|
223
|
+
same player. Verify actual inventory and world state. A lasting world need not
|
|
224
|
+
finish a round: its browser proof replaces the match check's round requirement.
|
|
225
|
+
|
|
182
226
|
## Progress that lasts: cloud saves
|
|
183
227
|
|
|
184
228
|
When the plan says progress persists across sessions or devices (the codex's "Progress that lasts", or the person
|
|
185
|
-
asks for a character, levels, unlocks, a collection, hardcore), wire in saves.
|
|
186
|
-
|
|
229
|
+
asks for a character, levels, unlocks, a collection, hardcore), wire in saves. For player-owned browser progress use the existing saves API below. For a server-authoritative
|
|
230
|
+
world or inventory, use Lasting worlds above. Ordinary match rooms expire after their last player leaves.
|
|
187
231
|
|
|
188
232
|
- `game.json`: `"saves": true`. A new game of that kind can start from the starter:
|
|
189
233
|
`npx --no-install homie-studio game new <id> --from ember-vale --name "<Name>"` (a hero that lasts, lifetime
|
package/lib/build.mjs
CHANGED
|
@@ -602,7 +602,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
602
602
|
// studio.json site.order: the catalogue's order is every listing's order.
|
|
603
603
|
const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
|
|
604
604
|
// Server-hosted games: what this build made, and for a one-game build what the catalogue before it said of the others.
|
|
605
|
-
const rulesOf = new Map(ruled.map((r) => [r.id, { host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds, ...(r.schema.records?{records:true}:{}) }]));
|
|
605
|
+
const rulesOf = new Map(ruled.map((r) => [r.id, { ...(r.settings.lifetime ? { lifetime: r.settings.lifetime } : {}), ...(r.settings.characters ? { characters: true } : {}), host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds, ...(r.schema.records?{records:true}:{}) }]));
|
|
606
606
|
if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.contract && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
|
|
607
607
|
// What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
|
|
608
608
|
const builds = {};
|
package/lib/rules-types.mjs
CHANGED
|
@@ -80,7 +80,7 @@ type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
|
|
|
80
80
|
entities: ${entities}; move?: Moves;
|
|
81
81
|
room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number }; historySeconds?: number;
|
|
82
82
|
start?: (world: RW) => void;
|
|
83
|
-
join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => ${joins};
|
|
83
|
+
join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: (${joins}) | null }) => ${joins};
|
|
84
84
|
on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
|
|
85
85
|
};
|
|
86
86
|
asks?: ${record(Object.entries(d.asks).map(([n, a]) => [n, `{ state: Fields; questions: ${value(a.questions)}; floor: (state: ReadonlyState<${fields(a.state)}>) => ${picks(a.questions)} }`]))};
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
|
|
1
|
+
import { FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
|
|
2
2
|
import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
|
|
3
3
|
import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
|
|
4
4
|
import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
|
|
@@ -802,6 +802,7 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
|
|
|
802
802
|
[`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
|
|
803
803
|
[`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
|
|
804
804
|
[`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
|
|
805
|
+
[`site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`]: FUNCTIONS_META_MIGRATION,
|
|
805
806
|
[`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
|
|
806
807
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
807
808
|
'.claude/skills/.gitkeep': '',
|
|
@@ -973,6 +974,11 @@ function ensurePartsStateMigration(root) {
|
|
|
973
974
|
return `site/migrations/${PURCHASE_STATE_FILE}`;
|
|
974
975
|
}
|
|
975
976
|
|
|
977
|
+
function ensureFunctionMeta(root) {
|
|
978
|
+
const file=join(root,'site','migrations',FUNCTIONS_META_MIGRATION_FILE);
|
|
979
|
+
if(existsSync(file))return null;
|
|
980
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_META_MIGRATION);return `site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`;
|
|
981
|
+
}
|
|
976
982
|
function ensureFunctionCursors(root) {
|
|
977
983
|
const file=join(root,'site','migrations',FUNCTIONS_CURSOR_MIGRATION_FILE);
|
|
978
984
|
if(existsSync(file))return null;
|
|
@@ -989,12 +995,12 @@ function ensureMcpMigration(root) {
|
|
|
989
995
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
|
|
990
996
|
}
|
|
991
997
|
export function ensureMigrations(root) {
|
|
992
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root)].filter(Boolean);
|
|
998
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root)].filter(Boolean);
|
|
993
999
|
}
|
|
994
1000
|
|
|
995
1001
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
996
1002
|
export function migrationWord(file) {
|
|
997
|
-
return /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
1003
|
+
return /function_meta/.test(file) ? 'refund events alongside plain-text studio settings' : /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
998
1004
|
}
|
|
999
1005
|
|
|
1000
1006
|
/**
|
|
@@ -2691,6 +2691,48 @@
|
|
|
2691
2691
|
}
|
|
2692
2692
|
}
|
|
2693
2693
|
}
|
|
2694
|
+
},
|
|
2695
|
+
"0.46.0": {
|
|
2696
|
+
"files": {
|
|
2697
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2698
|
+
"README.md": "684b3cca8001673c",
|
|
2699
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2700
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2701
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2702
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2703
|
+
"posts/README.md": "37841aae476bd540",
|
|
2704
|
+
"site/README.md": "732894273a32dc3f",
|
|
2705
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2706
|
+
},
|
|
2707
|
+
"sections": {
|
|
2708
|
+
"AGENTS.md": {
|
|
2709
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2710
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2711
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2712
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2713
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2714
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2715
|
+
"## Making games": "bb715272fd1a615b",
|
|
2716
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2717
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2718
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2719
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2720
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2721
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2722
|
+
"## Rules": "c48971c1437414c2",
|
|
2723
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2724
|
+
"## Beta": "9d02638207a26e73"
|
|
2725
|
+
}
|
|
2726
|
+
},
|
|
2727
|
+
"named": {
|
|
2728
|
+
"Homie Arcade": {
|
|
2729
|
+
"sections": {
|
|
2730
|
+
"AGENTS.md": {
|
|
2731
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2694
2736
|
}
|
|
2695
2737
|
}
|
|
2696
2738
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -2106,7 +2106,9 @@ from outside the studio run on its server.
|
|
|
2106
2106
|
The Table writes the whole room to its SQLite `save` table in one synchronous transaction:
|
|
2107
2107
|
entities, motion, shared state, rounds, seats and their tokens, pending events, timers and
|
|
2108
2108
|
inputs, RNG state, tick, epoch, state hash and build hash. Saves larger than 2 MB use several
|
|
2109
|
-
rows in that transaction. The default interval is `room.durability.movementSeconds: 1
|
|
2109
|
+
rows in that transaction. The default interval is `room.durability.movementSeconds: 1`
|
|
2110
|
+
(30 for a `lifetime: "persistent"` world, which writes its `durable` fields to a journal the moment they change and
|
|
2111
|
+
saves the rest on this interval);
|
|
2110
2112
|
round end, `world.finish()` and pause save immediately. Both elapsed ticks and elapsed wall
|
|
2111
2113
|
time bound the interval, including ticks that catch up after a delay. Cloudflare's output
|
|
2112
2114
|
gate holds outgoing frames until preceding writes are confirmed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
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/rules/core.ts
CHANGED
|
@@ -79,7 +79,7 @@ export const IGNORE_MAX = 16;
|
|
|
79
79
|
export const ANSWER_MAX = 256;
|
|
80
80
|
|
|
81
81
|
export type Driver = 'person' | 'bot' | 'ai';
|
|
82
|
-
export interface SeatInfo { seat: number; driver: Driver; owner: string }
|
|
82
|
+
export interface SeatInfo { seat: number; driver: Driver; owner: string; character?: import('./rules.ts').Character | null }
|
|
83
83
|
/** One seat's step for a tick: its input values (already held to their types) and, for an owner-moved body, its claim. */
|
|
84
84
|
export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec3; vel: Vec3; heading: Vec3; r: number } | null }
|
|
85
85
|
export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
|
|
@@ -163,6 +163,7 @@ export interface Core {
|
|
|
163
163
|
round(): Extract<CoreOut, { t: 'round' }>;
|
|
164
164
|
bodies(): Body[];
|
|
165
165
|
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
166
|
+
character(seat: number): import('./rules.ts').Character | null;
|
|
166
167
|
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
167
168
|
/** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
|
|
168
169
|
drain(): CoreOut[];
|
|
@@ -183,7 +184,7 @@ export interface Core {
|
|
|
183
184
|
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
184
185
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
185
186
|
*/
|
|
186
|
-
export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
187
|
+
export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number) => import('./rules.ts').Character | null | undefined; characterSaved?: (seat: number, character: import('./rules.ts').Character) => void; rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
187
188
|
const dims = c.dims;
|
|
188
189
|
const tickHz = c.settings.tickHz;
|
|
189
190
|
const dt = 1 / tickHz;
|
|
@@ -838,7 +839,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
838
839
|
return rows;
|
|
839
840
|
}
|
|
840
841
|
function joinBody(info: SeatInfo): Ent | null {
|
|
841
|
-
const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner });
|
|
842
|
+
const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner, ...(info.character !== undefined ? { character: deepFreeze(info.character) } : {}) });
|
|
842
843
|
let made: Ent | null = null;
|
|
843
844
|
run('room', 'join', null, 'join', () => {
|
|
844
845
|
// What room.join returns is read by its own data properties, and its fields are charged before they are held to their shapes.
|
|
@@ -864,6 +865,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
864
865
|
} else {
|
|
865
866
|
if (e) {
|
|
866
867
|
if (e.kind.on.leave) run(e.kind.name, 'on.leave', e, 'ent', () => e.kind.on.leave(world, e.self, Object.freeze({})));
|
|
868
|
+
opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
|
|
867
869
|
e.dead = true; ents.delete(e.id);
|
|
868
870
|
}
|
|
869
871
|
seats.delete(s.seat);
|
|
@@ -975,7 +977,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
975
977
|
if (tick < restartAt) return;
|
|
976
978
|
startMatch();
|
|
977
979
|
// Seats stay through a finished match: every holder's body is made again, as if it had just joined.
|
|
978
|
-
for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
|
|
980
|
+
for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner, ...(opts.characterForSeat ? { character: opts.characterForSeat(s.seat) } : {}) } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
|
|
979
981
|
return;
|
|
980
982
|
}
|
|
981
983
|
if (restartAsked) { restartAsked = false; endAsked = false; startRound(); return; }
|
|
@@ -992,7 +994,10 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
992
994
|
function finishMatch(): void {
|
|
993
995
|
finishing = false; restartAsked = false;
|
|
994
996
|
out.push({ t: 'round', n: round.n, phase: 'over', endsAt: tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0)), startedAt: round.startedAt, results: results() });
|
|
995
|
-
for (const e of ents.values())
|
|
997
|
+
for (const e of ents.values()) {
|
|
998
|
+
if (e.kind.player) opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
|
|
999
|
+
e.dead = true;
|
|
1000
|
+
}
|
|
996
1001
|
ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
|
|
997
1002
|
for (const s of seats.values()) s.id = null;
|
|
998
1003
|
shared = initFields(c.shared, dims); sharedChanged();
|
|
@@ -1456,7 +1461,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
1456
1461
|
if (!floorCall && value) guideViews.set(seat, value);
|
|
1457
1462
|
return value;
|
|
1458
1463
|
},
|
|
1459
|
-
seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128) } }); },
|
|
1464
|
+
seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128), ...(info.character !== undefined ? { character: info.character } : {}) } }); },
|
|
1460
1465
|
seatAway: (seat, away) => { ops.push({ op: 'away', seat, away }); },
|
|
1461
1466
|
seatLeave: (seat) => { ops.push({ op: 'leave', seat }); },
|
|
1462
1467
|
setPolicy: (p) => { policy = normalizeCorePolicy(p); },
|
|
@@ -1477,6 +1482,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
1477
1482
|
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
|
|
1478
1483
|
},
|
|
1479
1484
|
shared: () => packFields(c.shared, shared, dims),
|
|
1485
|
+
character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
|
|
1480
1486
|
bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
|
|
1481
1487
|
bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
|
|
1482
1488
|
drain: () => { const o = out; out = []; return o; },
|