@homie-rocks/studio 0.18.1 → 0.18.2
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/README.md +3 -3
- package/lib/template-history.json +174 -9
- package/lib/upgrade.mjs +38 -11
- package/package.json +1 -1
- package/port/room.ts +36 -9
- package/saves/SAVES.md +4 -1
- package/starters/ember-vale/src/main.ts +67 -8
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -110,8 +110,8 @@ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
|
|
|
110
110
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
111
111
|
|
|
112
112
|
```sh
|
|
113
|
-
npx -y @homie-rocks/studio@0.18.
|
|
114
|
-
npx -y @homie-rocks/studio@0.18.
|
|
113
|
+
npx -y @homie-rocks/studio@0.18.2 upgrade # the plan; changes nothing
|
|
114
|
+
npx -y @homie-rocks/studio@0.18.2 upgrade --apply # after the person agrees
|
|
115
115
|
npm install && npm run build
|
|
116
116
|
```
|
|
117
117
|
|
|
@@ -278,7 +278,7 @@ npx homie-studio agents pass night-rush --label Claude
|
|
|
278
278
|
## Setup status
|
|
279
279
|
|
|
280
280
|
```sh
|
|
281
|
-
npx -y @homie-rocks/studio@0.18.
|
|
281
|
+
npx -y @homie-rocks/studio@0.18.2 setup status --connector yes # before a studio exists
|
|
282
282
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
283
283
|
```
|
|
284
284
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"v": 1,
|
|
3
|
-
"about": "Fingerprints of every studio template version (scripts/studio-template-history.mjs): homie-studio upgrade replaces a file or an AGENTS.md section only when it is exactly one of these.",
|
|
3
|
+
"about": "Fingerprints of every studio template version (scripts/studio-template-history.mjs): homie-studio upgrade replaces a file or an AGENTS.md section only when it is exactly one of these. A version's named entries are for a studio the template itself names (lib/upgrade.mjs NAMED_IN_TEMPLATE), as that studio reads them.",
|
|
4
4
|
"versions": {
|
|
5
5
|
"0.2.0": {
|
|
6
6
|
"files": {
|
|
@@ -138,14 +138,23 @@
|
|
|
138
138
|
"music/README.md": "b8a828de9311bd28",
|
|
139
139
|
"videos/README.md": "70614388cbb203c7",
|
|
140
140
|
"posts/README.md": "37841aae476bd540",
|
|
141
|
-
"site/README.md":
|
|
141
|
+
"site/README.md": [
|
|
142
|
+
"0fd152a20a3eb55f",
|
|
143
|
+
"ca131cd0472464fb"
|
|
144
|
+
],
|
|
142
145
|
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
143
146
|
},
|
|
144
147
|
"sections": {
|
|
145
148
|
"AGENTS.md": {
|
|
146
149
|
"(intro)": "214aa46eb3a895d5",
|
|
147
|
-
"## Layout":
|
|
148
|
-
|
|
150
|
+
"## Layout": [
|
|
151
|
+
"b5f801e112d163a9",
|
|
152
|
+
"dc8ce5f6276d369e"
|
|
153
|
+
],
|
|
154
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
155
|
+
"2af7c804c1e81d86",
|
|
156
|
+
"63833b7006efdef4"
|
|
157
|
+
],
|
|
149
158
|
"## The site": "7d3525ba39b7f262",
|
|
150
159
|
"## Making games": "c204473d9b468f49",
|
|
151
160
|
"## Rules": "969318425c99f5a5",
|
|
@@ -252,24 +261,44 @@
|
|
|
252
261
|
"files": {
|
|
253
262
|
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
254
263
|
"README.md": "684b3cca8001673c",
|
|
255
|
-
"games/README.md":
|
|
264
|
+
"games/README.md": [
|
|
265
|
+
"5ad32fe80c61f117",
|
|
266
|
+
"5e5e942baa7e0372"
|
|
267
|
+
],
|
|
256
268
|
"music/README.md": "b8a828de9311bd28",
|
|
257
269
|
"videos/README.md": "70614388cbb203c7",
|
|
258
270
|
"posts/README.md": "37841aae476bd540",
|
|
259
271
|
"site/README.md": "ca131cd0472464fb",
|
|
260
|
-
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
272
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3",
|
|
273
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2"
|
|
261
274
|
},
|
|
262
275
|
"sections": {
|
|
263
276
|
"AGENTS.md": {
|
|
264
277
|
"(intro)": "214aa46eb3a895d5",
|
|
265
278
|
"## Layout": "f14d641159480e6c",
|
|
266
|
-
"## Commands (all through the pinned CLI in node_modules)":
|
|
267
|
-
|
|
279
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
280
|
+
"3a3d3bef7e8d1a50",
|
|
281
|
+
"e579cd6ce3ce342a"
|
|
282
|
+
],
|
|
283
|
+
"## The site": [
|
|
284
|
+
"7c1618431bae6324",
|
|
285
|
+
"7d3525ba39b7f262"
|
|
286
|
+
],
|
|
268
287
|
"## Making games": "63069b31c87a2aa2",
|
|
269
288
|
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
270
289
|
"## Running live games (the back office)": "f0566f07cff04113",
|
|
271
290
|
"## Rules": "969318425c99f5a5",
|
|
272
|
-
"## Beta": "9d02638207a26e73"
|
|
291
|
+
"## Beta": "9d02638207a26e73",
|
|
292
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419"
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
"named": {
|
|
296
|
+
"Homie Arcade": {
|
|
297
|
+
"sections": {
|
|
298
|
+
"AGENTS.md": {
|
|
299
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
300
|
+
}
|
|
301
|
+
}
|
|
273
302
|
}
|
|
274
303
|
}
|
|
275
304
|
},
|
|
@@ -298,6 +327,15 @@
|
|
|
298
327
|
"## Rules": "969318425c99f5a5",
|
|
299
328
|
"## Beta": "9d02638207a26e73"
|
|
300
329
|
}
|
|
330
|
+
},
|
|
331
|
+
"named": {
|
|
332
|
+
"Homie Arcade": {
|
|
333
|
+
"sections": {
|
|
334
|
+
"AGENTS.md": {
|
|
335
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
301
339
|
}
|
|
302
340
|
},
|
|
303
341
|
"0.14.1": {
|
|
@@ -325,6 +363,15 @@
|
|
|
325
363
|
"## Rules": "969318425c99f5a5",
|
|
326
364
|
"## Beta": "9d02638207a26e73"
|
|
327
365
|
}
|
|
366
|
+
},
|
|
367
|
+
"named": {
|
|
368
|
+
"Homie Arcade": {
|
|
369
|
+
"sections": {
|
|
370
|
+
"AGENTS.md": {
|
|
371
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
328
375
|
}
|
|
329
376
|
},
|
|
330
377
|
"0.14.2": {
|
|
@@ -352,6 +399,15 @@
|
|
|
352
399
|
"## Rules": "969318425c99f5a5",
|
|
353
400
|
"## Beta": "9d02638207a26e73"
|
|
354
401
|
}
|
|
402
|
+
},
|
|
403
|
+
"named": {
|
|
404
|
+
"Homie Arcade": {
|
|
405
|
+
"sections": {
|
|
406
|
+
"AGENTS.md": {
|
|
407
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
355
411
|
}
|
|
356
412
|
},
|
|
357
413
|
"0.14.3": {
|
|
@@ -379,6 +435,15 @@
|
|
|
379
435
|
"## Rules": "969318425c99f5a5",
|
|
380
436
|
"## Beta": "9d02638207a26e73"
|
|
381
437
|
}
|
|
438
|
+
},
|
|
439
|
+
"named": {
|
|
440
|
+
"Homie Arcade": {
|
|
441
|
+
"sections": {
|
|
442
|
+
"AGENTS.md": {
|
|
443
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
382
447
|
}
|
|
383
448
|
},
|
|
384
449
|
"0.14.4": {
|
|
@@ -406,6 +471,15 @@
|
|
|
406
471
|
"## Rules": "969318425c99f5a5",
|
|
407
472
|
"## Beta": "9d02638207a26e73"
|
|
408
473
|
}
|
|
474
|
+
},
|
|
475
|
+
"named": {
|
|
476
|
+
"Homie Arcade": {
|
|
477
|
+
"sections": {
|
|
478
|
+
"AGENTS.md": {
|
|
479
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
409
483
|
}
|
|
410
484
|
},
|
|
411
485
|
"0.15.0": {
|
|
@@ -433,6 +507,15 @@
|
|
|
433
507
|
"## Rules": "969318425c99f5a5",
|
|
434
508
|
"## Beta": "9d02638207a26e73"
|
|
435
509
|
}
|
|
510
|
+
},
|
|
511
|
+
"named": {
|
|
512
|
+
"Homie Arcade": {
|
|
513
|
+
"sections": {
|
|
514
|
+
"AGENTS.md": {
|
|
515
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
436
519
|
}
|
|
437
520
|
},
|
|
438
521
|
"0.16.0": {
|
|
@@ -461,6 +544,15 @@
|
|
|
461
544
|
"## Rules": "969318425c99f5a5",
|
|
462
545
|
"## Beta": "9d02638207a26e73"
|
|
463
546
|
}
|
|
547
|
+
},
|
|
548
|
+
"named": {
|
|
549
|
+
"Homie Arcade": {
|
|
550
|
+
"sections": {
|
|
551
|
+
"AGENTS.md": {
|
|
552
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
464
556
|
}
|
|
465
557
|
},
|
|
466
558
|
"0.16.1": {
|
|
@@ -489,6 +581,15 @@
|
|
|
489
581
|
"## Rules": "969318425c99f5a5",
|
|
490
582
|
"## Beta": "9d02638207a26e73"
|
|
491
583
|
}
|
|
584
|
+
},
|
|
585
|
+
"named": {
|
|
586
|
+
"Homie Arcade": {
|
|
587
|
+
"sections": {
|
|
588
|
+
"AGENTS.md": {
|
|
589
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
492
593
|
}
|
|
493
594
|
},
|
|
494
595
|
"0.17.0": {
|
|
@@ -517,6 +618,15 @@
|
|
|
517
618
|
"## Rules": "969318425c99f5a5",
|
|
518
619
|
"## Beta": "9d02638207a26e73"
|
|
519
620
|
}
|
|
621
|
+
},
|
|
622
|
+
"named": {
|
|
623
|
+
"Homie Arcade": {
|
|
624
|
+
"sections": {
|
|
625
|
+
"AGENTS.md": {
|
|
626
|
+
"## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
520
630
|
}
|
|
521
631
|
},
|
|
522
632
|
"0.18.0": {
|
|
@@ -545,6 +655,15 @@
|
|
|
545
655
|
"## Rules": "969318425c99f5a5",
|
|
546
656
|
"## Beta": "9d02638207a26e73"
|
|
547
657
|
}
|
|
658
|
+
},
|
|
659
|
+
"named": {
|
|
660
|
+
"Homie Arcade": {
|
|
661
|
+
"sections": {
|
|
662
|
+
"AGENTS.md": {
|
|
663
|
+
"## Commands (all through the pinned CLI in node_modules)": "eedef844b7aaf7ad"
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
548
667
|
}
|
|
549
668
|
},
|
|
550
669
|
"0.18.1": {
|
|
@@ -573,6 +692,52 @@
|
|
|
573
692
|
"## Rules": "969318425c99f5a5",
|
|
574
693
|
"## Beta": "9d02638207a26e73"
|
|
575
694
|
}
|
|
695
|
+
},
|
|
696
|
+
"named": {
|
|
697
|
+
"Homie Arcade": {
|
|
698
|
+
"sections": {
|
|
699
|
+
"AGENTS.md": {
|
|
700
|
+
"## Commands (all through the pinned CLI in node_modules)": "eedef844b7aaf7ad"
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
"0.18.2": {
|
|
707
|
+
"files": {
|
|
708
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
709
|
+
"README.md": "684b3cca8001673c",
|
|
710
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
711
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
712
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
713
|
+
"videos/README.md": "16babc3437d5545b",
|
|
714
|
+
"posts/README.md": "37841aae476bd540",
|
|
715
|
+
"site/README.md": "ca131cd0472464fb",
|
|
716
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
717
|
+
},
|
|
718
|
+
"sections": {
|
|
719
|
+
"AGENTS.md": {
|
|
720
|
+
"(intro)": "214aa46eb3a895d5",
|
|
721
|
+
"## Layout": "eb1942414b8af7f8",
|
|
722
|
+
"## Commands (all through the pinned CLI in node_modules)": "9c51f189412e6a9f",
|
|
723
|
+
"## The site": "ab922bb6bde23293",
|
|
724
|
+
"## Making games": "a8a893eeada33bf5",
|
|
725
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
726
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
727
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
728
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
729
|
+
"## Rules": "969318425c99f5a5",
|
|
730
|
+
"## Beta": "9d02638207a26e73"
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
"named": {
|
|
734
|
+
"Homie Arcade": {
|
|
735
|
+
"sections": {
|
|
736
|
+
"AGENTS.md": {
|
|
737
|
+
"## Commands (all through the pinned CLI in node_modules)": "eedef844b7aaf7ad"
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
576
741
|
}
|
|
577
742
|
}
|
|
578
743
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* added a file, an AGENTS.md section, a .gitignore line or a package.json script the studio does not have;
|
|
11
11
|
* updated a file or an AGENTS.md section the studio never changed since an older template wrote it (its text
|
|
12
|
-
* is exactly an earlier version's: lib/template-history.json keeps a fingerprint of every one
|
|
13
|
-
* template's newer text replaces it;
|
|
12
|
+
* is exactly an earlier version's: lib/template-history.json keeps a fingerprint of every one, and of
|
|
13
|
+
* every one as a studio the template itself names reads it), so the template's newer text replaces it;
|
|
14
14
|
* kept anything the studio changed, or wrote itself: never touched. The plan says it differs, and
|
|
15
15
|
* `--diff` prints the difference, so the person can take the template's words by hand.
|
|
16
16
|
*
|
|
@@ -42,6 +42,14 @@ export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude
|
|
|
42
42
|
const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
|
|
43
43
|
/** The name and slug stand-ins the history is fingerprinted with. */
|
|
44
44
|
export const STAND_IN = { name: '{{studio.name}}', slug: '{{studio.slug}}' };
|
|
45
|
+
/**
|
|
46
|
+
* Studios the template's own words name: `demo` plays a live game "on Homie Arcade" (the Commands section). A
|
|
47
|
+
* studio's text is compared with its own name and slug made neutral (normalize), so for the studio that IS Homie
|
|
48
|
+
* Arcade that mention turns into the stand-in too, and no stand-in fingerprint could ever match its untouched text:
|
|
49
|
+
* it read as changed by the studio, and kept the template's older words for good. The history also keeps, for each of
|
|
50
|
+
* these names, the fingerprints that differ when the template is written for that studio (`named`).
|
|
51
|
+
*/
|
|
52
|
+
export const NAMED_IN_TEMPLATE = Object.freeze([Object.freeze({ name: 'Homie Arcade', slug: 'homie-arcade' })]);
|
|
45
53
|
const INTRO = '(intro)';
|
|
46
54
|
|
|
47
55
|
/** A text as the template compares it: line ends, trailing spaces and the studio's own name and slug made neutral. */
|
|
@@ -64,9 +72,9 @@ export function sections(text) {
|
|
|
64
72
|
return out.map((s) => ({ heading: s.heading, text: s.lines.join('\n') })).filter((s, i) => i > 0 || s.text.trim());
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
/** What the history keeps of one template: a fingerprint of every whole file and every AGENTS.md section
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
/** What the history keeps of one template: a fingerprint of every whole file and every AGENTS.md section, as `who`
|
|
76
|
+
* (the stand-ins, or a studio the template names) reads them. */
|
|
77
|
+
export function templatePrint(files, who = STAND_IN) {
|
|
70
78
|
return {
|
|
71
79
|
files: Object.fromEntries(TEMPLATE_FILES.filter((f) => f !== 'AGENTS.md' && files[f] !== undefined).map((f) => [f, fingerprint(files[f], who)])),
|
|
72
80
|
sections: { 'AGENTS.md': Object.fromEntries(sections(files['AGENTS.md']).map((s) => [s.heading, fingerprint(s.text, who)])) },
|
|
@@ -77,14 +85,33 @@ export function readHistory() {
|
|
|
77
85
|
try { return JSON.parse(readFileSync(new URL('./template-history.json', import.meta.url), 'utf8')); } catch { return { v: 1, versions: {} }; }
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
88
|
+
/**
|
|
89
|
+
* A template as the studios it names read it (NAMED_IN_TEMPLATE): for each, the fingerprints that differ from the
|
|
90
|
+
* stand-ins' (only a file or section that names it does). `render(who)` is what `homie-studio new` writes for `who`.
|
|
91
|
+
*/
|
|
92
|
+
export function namedPrint(render, plain = templatePrint(render(STAND_IN))) {
|
|
93
|
+
const named = {};
|
|
94
|
+
for (const who of NAMED_IN_TEMPLATE) {
|
|
95
|
+
const p = templatePrint(render(who), who);
|
|
96
|
+
const files = Object.fromEntries(Object.entries(p.files).filter(([f, h]) => plain.files[f] !== h));
|
|
97
|
+
const secs = Object.fromEntries(Object.entries(p.sections['AGENTS.md']).filter(([s, h]) => plain.sections['AGENTS.md'][s] !== h));
|
|
98
|
+
if (Object.keys(files).length || Object.keys(secs).length) named[who.name] = { ...(Object.keys(files).length ? { files } : {}), sections: { 'AGENTS.md': secs } };
|
|
99
|
+
}
|
|
100
|
+
return named;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every fingerprint an older template wrote, per file and per AGENTS.md section, as this studio (`who`) reads it. */
|
|
104
|
+
function known(history, who = {}) {
|
|
82
105
|
const files = new Map();
|
|
83
106
|
const secs = new Map();
|
|
84
107
|
for (const v of Object.values(history.versions ?? {})) {
|
|
85
|
-
// A
|
|
86
|
-
|
|
87
|
-
for (const
|
|
108
|
+
// A studio the template names reads those mentions as its own name: its fingerprints are kept beside the rest.
|
|
109
|
+
const as = who.name && v.named && Object.hasOwn(v.named, who.name) ? v.named[who.name] : null;
|
|
110
|
+
for (const p of as ? [v, as] : [v]) {
|
|
111
|
+
// A version may hold more than one hash for a file (two templates published under it): a list.
|
|
112
|
+
for (const [f, h] of Object.entries(p.files ?? {})) for (const x of [h].flat()) (files.get(f) ?? files.set(f, new Set()).get(f)).add(x);
|
|
113
|
+
for (const [heading, h] of Object.entries(p.sections?.['AGENTS.md'] ?? {})) for (const x of [h].flat()) (secs.get(heading) ?? secs.set(heading, new Set()).get(heading)).add(x);
|
|
114
|
+
}
|
|
88
115
|
}
|
|
89
116
|
return { files, secs };
|
|
90
117
|
}
|
|
@@ -137,7 +164,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
137
164
|
if (!name || !slug) return { ok: false, command: 'upgrade', why: 'studio.json has no name or slug; this does not look like a studio made with homie-studio new' };
|
|
138
165
|
const who = { name, slug };
|
|
139
166
|
const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
|
|
140
|
-
const { files: oldFiles, secs: oldSecs } = known(history);
|
|
167
|
+
const { files: oldFiles, secs: oldSecs } = known(history, who);
|
|
141
168
|
const changes = [];
|
|
142
169
|
const kept = [];
|
|
143
170
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
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/port/room.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent'] }
|
|
35
35
|
|
|
36
36
|
export interface RoomSnap<F = unknown> { r: [n: number, phase: number, startedAt: number, endsAt: number]; b: number[][]; w?: F }
|
|
37
|
-
export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number }
|
|
37
|
+
export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number; spawns?: [slot: number, index: number][] }
|
|
38
38
|
|
|
39
39
|
export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
40
40
|
/** The game id (the relay's logs). */
|
|
@@ -50,7 +50,11 @@ export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
50
50
|
/** Results on screen between rounds (default 7). */
|
|
51
51
|
breakSeconds?: number;
|
|
52
52
|
botName?: (slot: number) => string;
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A fresh body for a slot. Spread the spawns with `index`: at a round start the bodies get 0, 1, 2… in slot order; a
|
|
55
|
+
* body that arrives mid-round (a joiner with no bot to take over, a guide's seat) gets the lowest index no body in
|
|
56
|
+
* the room holds, so two bodies never get the same one.
|
|
57
|
+
*/
|
|
54
58
|
spawn: (slot: Slot, index: number) => B;
|
|
55
59
|
/** The body's fast fields for the snapshot, after [slot, seat, score]: small numbers only (use q()). */
|
|
56
60
|
pack: (b: B) => number[];
|
|
@@ -143,6 +147,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
143
147
|
const policy = (): Policy => net.policy;
|
|
144
148
|
let roster = new Roster({ min, max, botName, policy });
|
|
145
149
|
let bodies = new Map<number, B>();
|
|
150
|
+
/** The index each body was spawned with (slot → index), so a body that arrives mid-round never gets one in use. */
|
|
151
|
+
let spawned = new Map<number, number>();
|
|
146
152
|
let round: RoundInfo | null = null;
|
|
147
153
|
let hosting = false;
|
|
148
154
|
let tick = 0;
|
|
@@ -150,7 +156,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
150
156
|
const net = createNetplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>({
|
|
151
157
|
game: opts.game, maxPlayers: max, movement: opts.movement ?? 'owner',
|
|
152
158
|
snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
|
|
153
|
-
checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick }),
|
|
159
|
+
checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick, spawns: [...spawned].filter(([slot]) => bodies.has(slot)) }),
|
|
154
160
|
...(opts.netplay ?? {}),
|
|
155
161
|
// createRoom claims an AI's slot for it (its join passes `p.agent`), so its host can move an AI's body.
|
|
156
162
|
caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
|
|
@@ -163,17 +169,31 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
163
169
|
return null;
|
|
164
170
|
};
|
|
165
171
|
|
|
166
|
-
|
|
172
|
+
/** The lowest spawn index no body in the room holds. */
|
|
173
|
+
function freeIndex(): number {
|
|
174
|
+
const held = new Set<number>();
|
|
175
|
+
for (const slot of bodies.keys()) { const i = spawned.get(slot); if (i !== undefined) held.add(i); }
|
|
176
|
+
let i = 0;
|
|
177
|
+
while (held.has(i)) i += 1;
|
|
178
|
+
return i;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function syncBodies(): void {
|
|
167
182
|
const seen = new Set<number>();
|
|
168
|
-
let i = atIndex;
|
|
169
183
|
for (const s of roster.slots) {
|
|
170
184
|
seen.add(s.slot);
|
|
171
185
|
const b = bodies.get(s.slot);
|
|
172
|
-
if (!b) {
|
|
186
|
+
if (!b) {
|
|
187
|
+
// Mid-round (a joiner, a guide's seat): never an index another body holds, so never another body's spot.
|
|
188
|
+
const i = freeIndex();
|
|
189
|
+
const nb = opts.spawn(s, i); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; if (s.agent) nb.agent = { ...s.agent };
|
|
190
|
+
bodies.set(s.slot, nb); spawned.set(s.slot, i);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
173
193
|
b.seat = s.seat; b.name = s.name; b.bot = s.bot;
|
|
174
194
|
if (s.agent) b.agent = { ...s.agent }; else delete b.agent;
|
|
175
195
|
}
|
|
176
|
-
for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
|
|
196
|
+
for (const k of [...bodies.keys()]) if (!seen.has(k)) { bodies.delete(k); spawned.delete(k); }
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
function moved(b: B): void {
|
|
@@ -195,7 +215,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
195
215
|
roster.trim();
|
|
196
216
|
syncBodies();
|
|
197
217
|
let i = 0;
|
|
218
|
+
spawned = new Map();
|
|
198
219
|
for (const s of [...roster.slots].sort((a, c) => a.slot - c.slot)) {
|
|
220
|
+
spawned.set(s.slot, i);
|
|
199
221
|
const fresh = opts.spawn(s, i++);
|
|
200
222
|
fresh.slot = s.slot; fresh.seat = s.seat; fresh.name = s.name; fresh.bot = s.bot; fresh.score = 0;
|
|
201
223
|
if (s.agent) fresh.agent = { ...s.agent };
|
|
@@ -228,11 +250,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
228
250
|
if (ck) {
|
|
229
251
|
roster = Roster.from(ck.roster, { min, max, botName, policy });
|
|
230
252
|
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
253
|
+
// A checkpoint from before spawns were kept: the round start's order (slot order) is the best guess.
|
|
254
|
+
spawned = new Map(ck.spawns ?? [...bodies.keys()].sort((a, c) => a - c).map((slot, i) => [slot, i]));
|
|
231
255
|
round = ck.round;
|
|
232
256
|
tick = ck.tick;
|
|
233
257
|
} else {
|
|
234
258
|
roster = Roster.from(e.roster ?? [], { min, max, botName, policy });
|
|
235
259
|
bodies = new Map();
|
|
260
|
+
spawned = new Map();
|
|
236
261
|
syncBodies();
|
|
237
262
|
}
|
|
238
263
|
// The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions and scores from it.
|
|
@@ -243,8 +268,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
243
268
|
let b = bodies.get(slot);
|
|
244
269
|
if (!b) {
|
|
245
270
|
const sl = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };
|
|
246
|
-
|
|
247
|
-
|
|
271
|
+
const i = freeIndex();
|
|
272
|
+
b = opts.spawn(sl, i); b.slot = slot; b.seat = sl.seat; b.name = sl.name; b.bot = sl.bot;
|
|
273
|
+
bodies.set(slot, b); spawned.set(slot, i);
|
|
248
274
|
}
|
|
249
275
|
b.score = score;
|
|
250
276
|
opts.unpack(row.slice(3), b);
|
|
@@ -268,6 +294,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
268
294
|
else {
|
|
269
295
|
roster = new Roster({ min, max, botName, policy });
|
|
270
296
|
bodies = new Map();
|
|
297
|
+
spawned = new Map();
|
|
271
298
|
const seat = mySeat();
|
|
272
299
|
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
273
300
|
syncBodies();
|
package/saves/SAVES.md
CHANGED
|
@@ -23,7 +23,10 @@ The pattern (the `ember-vale` starter does exactly this):
|
|
|
23
23
|
1. When the player arrives, **load** the hero from saves; until it loads, play as a level-1 body.
|
|
24
24
|
2. The host decides what happened (who killed what) and tells **that player's own browser** (a netplay event
|
|
25
25
|
to its seat). Only the hero's own browser changes the hero and **saves** it.
|
|
26
|
-
3. The room carries what the others need to see (my level, so my health bar is right
|
|
26
|
+
3. The room carries what the others need to see (my level, so my health bar is right; my hero's name, so it is
|
|
27
|
+
the name over my head), never the save itself. The name goes to the host as an event, and the host keeps every
|
|
28
|
+
seat's in keyed state (`net.state('heroes', { [seat]: name })`), which a new host and a late joiner already have.
|
|
29
|
+
On a kids server nobody's typed name reaches anyone else: the others keep seeing the room's handles.
|
|
27
30
|
|
|
28
31
|
## Quick start
|
|
29
32
|
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* hero and saves it. A hardcore hero who falls becomes a memorial in the Hall of the Fallen, and its save is wiped
|
|
16
16
|
* in the same step (saves.fall). Canvas 2D on purpose: the point is the pattern, in a few hundred lines.
|
|
17
17
|
*
|
|
18
|
+
* NAMES: the room knows a person by their seat's name (their account's, else a two-word handle such as "Velvet
|
|
19
|
+
* Comet"); the vale shows their HERO's name, over their head and in the night's ranking, for them and for everyone
|
|
20
|
+
* else. Each hero's browser tells the host its hero's name (a `hero` event); the host keeps every seat's in the keyed
|
|
21
|
+
* state `heroes`, which a new host and a late joiner have already. On a kids server nobody's typed name reaches
|
|
22
|
+
* anyone else (NETPLAY.md section 17): there the others are shown by their handles, as the room has them.
|
|
23
|
+
*
|
|
18
24
|
* A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
|
|
19
25
|
* follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
|
|
20
26
|
* The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
|
|
@@ -31,7 +37,7 @@
|
|
|
31
37
|
* none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
|
|
32
38
|
* never from a model; "Quiet AI" hides them.
|
|
33
39
|
*/
|
|
34
|
-
import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
40
|
+
import { AI_MARK, createControls, createLabels, createRoom, 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';
|
|
35
41
|
import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
|
|
36
42
|
import vocabulary from '../agents.json';
|
|
37
43
|
|
|
@@ -46,6 +52,8 @@ const DOWN_MS = 4000;
|
|
|
46
52
|
const DOWN = 1;
|
|
47
53
|
const STRIKING = 2;
|
|
48
54
|
const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
|
|
55
|
+
/** The eight spots round the camp, in the order bodies take them: any first few are spread all the way round. */
|
|
56
|
+
const SPREAD = [0, 4, 2, 6, 1, 5, 3, 7];
|
|
49
57
|
const xpFor = (level: number): number => Math.round(20 * level ** 1.5);
|
|
50
58
|
const maxHpOf = (level: number): number => 60 + level * 12;
|
|
51
59
|
const damageOf = (level: number): number => 10 + level * 3;
|
|
@@ -129,10 +137,13 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
|
129
137
|
roundSeconds: 90,
|
|
130
138
|
breakSeconds: 8,
|
|
131
139
|
botName: (slot) => BOT_NAMES[slot % BOT_NAMES.length] as string,
|
|
140
|
+
// createRoom gives every body in the room its own index (a mid-round arrival the lowest free one), so no two
|
|
141
|
+
// bodies ever spawn on one spot; SPREAD puts the first few far apart round the camp (0°, 180°, 90°, 270°…).
|
|
132
142
|
spawn: (slot, i) => {
|
|
133
|
-
const a = (i /
|
|
143
|
+
const a = ((SPREAD[i % SPREAD.length] as number) / SPREAD.length) * Math.PI * 2;
|
|
144
|
+
const ring = 1 + Math.floor(i / SPREAD.length) * 0.5;
|
|
134
145
|
const lvl = slot.bot ? 1 + (slot.slot % 4) : 1;
|
|
135
|
-
const x = W / 2 + Math.cos(a) * 160; const y = H / 2 + Math.sin(a) * 120;
|
|
146
|
+
const x = W / 2 + Math.cos(a) * 160 * ring; const y = H / 2 + Math.sin(a) * 120 * ring;
|
|
136
147
|
return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, score: 0, x, y, hp: maxHpOf(lvl), maxHp: maxHpOf(lvl), level: lvl, face: a, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: x, ty: y };
|
|
137
148
|
},
|
|
138
149
|
pack: (b) => [q(b.x, 0), q(b.y, 0), Math.max(0, Math.round(b.hp)), b.maxHp, b.level, q(b.face, 2), b.flags],
|
|
@@ -157,6 +168,51 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
|
157
168
|
const net = room.net;
|
|
158
169
|
const myLevel = (): number => hero?.level ?? 1;
|
|
159
170
|
|
|
171
|
+
/* ------------------------------------------------------------------ heroes' names */
|
|
172
|
+
/** A hero's name as the vale shows it: one line of at most 20 characters, never ending in an AI or bot mark. */
|
|
173
|
+
const heroName = (raw: unknown): string =>
|
|
174
|
+
stripAi(String(raw ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')).slice(0, 20).trim();
|
|
175
|
+
/** On a kids server nobody's typed name reaches anyone else: the room's handles stay. */
|
|
176
|
+
const kids = (): boolean => net.policy.kids === true;
|
|
177
|
+
/** Every seat's hero name, as the host keeps it (keyed state `heroes`: seat → name). */
|
|
178
|
+
const heroNames = (): Record<string, string> => net.stateOf<Record<string, string>>('heroes') ?? {};
|
|
179
|
+
/** Host: one seat's hero name (null or '': none). */
|
|
180
|
+
function setHeroName(seat: number, name: string | null): void {
|
|
181
|
+
const next = { ...heroNames() };
|
|
182
|
+
if (name && !kids()) next[seat] = name; else delete next[seat];
|
|
183
|
+
net.state('heroes', next);
|
|
184
|
+
}
|
|
185
|
+
/** The name over a body and in the ranking: a person's hero's (on your own screen, yours at once), else the room's. */
|
|
186
|
+
function nameOf(b: { seat: number | null; bot: boolean; name: string; agent?: unknown }): string {
|
|
187
|
+
// A bot or an AI is always the room's name for it: an AI's ends in " · AI", and no hero name may hide that.
|
|
188
|
+
if (b.bot || b.agent || b.seat === null || net.isAgent(b.seat)) return b.name;
|
|
189
|
+
if (!lookOnly && hero && b.seat === room.mySeat()) return heroName(hero.name) || b.name;
|
|
190
|
+
return (kids() ? '' : heroNames()[b.seat]) || b.name;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Every frame: my hero's name goes to the host until the room has it (so a new host, a reload and a lost event all
|
|
194
|
+
* heal by themselves); the host keeps only seats a person still holds.
|
|
195
|
+
*/
|
|
196
|
+
let heroToldAt = 0;
|
|
197
|
+
let heroesKeptAt = 0;
|
|
198
|
+
function keepNames(now: number): void {
|
|
199
|
+
const seat = room.mySeat();
|
|
200
|
+
if (!lookOnly && seat !== null) {
|
|
201
|
+
const want = hero && !kids() ? heroName(hero.name) : '';
|
|
202
|
+
if ((heroNames()[seat] ?? '') !== want) {
|
|
203
|
+
if (room.hosting) setHeroName(seat, want);
|
|
204
|
+
else if (now - heroToldAt > 1500) { heroToldAt = now; net.send('hero', { name: want }); }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!room.hosting || now - heroesKeptAt < 1000) return;
|
|
208
|
+
heroesKeptAt = now;
|
|
209
|
+
const held = new Set([...net.peers.values()].filter((p) => p.seat !== null && !p.agent && !p.watch).map((p) => String(p.seat)));
|
|
210
|
+
if (seat !== null) held.add(String(seat));
|
|
211
|
+
const names = heroNames();
|
|
212
|
+
const keep = kids() ? {} : Object.fromEntries(Object.entries(names).filter(([k]) => held.has(k)));
|
|
213
|
+
if (Object.keys(keep).length !== Object.keys(names).length) net.state('heroes', keep);
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
/* ------------------------------------------------------------------ the guides' brain (agents.json) */
|
|
161
217
|
type PartyRow = { seat: number; dist: number; hp: number; down: boolean };
|
|
162
218
|
const greeted = new Map<number, Set<number>>();
|
|
@@ -223,6 +279,8 @@ net.on('event', (e: NetEvent) => {
|
|
|
223
279
|
if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
|
|
224
280
|
if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
|
|
225
281
|
if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
|
|
282
|
+
// A hero's browser names its hero (its own seat only: the relay stamps who sent it).
|
|
283
|
+
if (e.k === 'hero' && room.hosting && typeof e.from === 'number' && !net.isAgent(e.from)) setHeroName(e.from, heroName((e.d as { name?: unknown } | null)?.name));
|
|
226
284
|
});
|
|
227
285
|
function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
|
|
228
286
|
if (b.bot || b.seat === null) return;
|
|
@@ -552,7 +610,7 @@ function draw(t: number): void {
|
|
|
552
610
|
ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
|
|
553
611
|
ctx.globalAlpha = 1;
|
|
554
612
|
// An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
|
|
555
|
-
const text = `${b
|
|
613
|
+
const text = `${nameOf(b)}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''}${isGuide ? ' · guide' : ''} · ${b.level}`;
|
|
556
614
|
const near = meAt ? Math.hypot(x - meAt.x, y - meAt.y) : 0;
|
|
557
615
|
// Its spot is above its health bar; its body (and bar) is what other names keep off. People first, then guides,
|
|
558
616
|
// then bots; nearer you first.
|
|
@@ -635,7 +693,7 @@ function hud(t: number): void {
|
|
|
635
693
|
// A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
|
|
636
694
|
const v = lookOnly ? room.viewBody() : null;
|
|
637
695
|
const heroes = room.view().filter((b) => !b.bot).length;
|
|
638
|
-
const title = lookOnly ? (v ? `${v
|
|
696
|
+
const title = lookOnly ? (v ? `${nameOf(v)} · Lv ${v.level}` : 'Watching the vale') : hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…';
|
|
639
697
|
const sub = lookOnly ? (v ? `${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}` : `${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`) : hero ? `${hero.gold} gold · ${hero.kills} slain` : '';
|
|
640
698
|
const bar = v ? { colour: '#7ad35a', of: clamp(v.hp, 0, v.maxHp) / Math.max(1, v.maxHp) } : hero && !lookOnly ? { colour: '#ffcf6e', of: hero.xp / xpFor(hero.level) } : null;
|
|
641
699
|
// The panel is as wide as what it says: on a phone the vale runs under it, and the clock sits beside it.
|
|
@@ -665,7 +723,7 @@ function hud(t: number): void {
|
|
|
665
723
|
ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, top, 300, 30 + rows.length * 22);
|
|
666
724
|
ctx.font = TITLE; ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, top + 22);
|
|
667
725
|
ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
668
|
-
rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r
|
|
726
|
+
rows.forEach((r, i) => ctx.fillText(`${r.place}. ${nameOf(r)}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, top + 46 + i * 22));
|
|
669
727
|
}
|
|
670
728
|
if (performance.now() < bannerUntil) { ctx.font = '800 22px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e'; ctx.fillText(bannerText, cw / 2, canvas.height / k - 90); }
|
|
671
729
|
const st = saves.status();
|
|
@@ -797,6 +855,7 @@ function frame(t: number): void {
|
|
|
797
855
|
const dt = Math.min(0.05, (t - last) / 1000);
|
|
798
856
|
last = t;
|
|
799
857
|
stepMe(dt);
|
|
858
|
+
keepNames(t);
|
|
800
859
|
if (room.hosting) stepHost(dt);
|
|
801
860
|
draw(t);
|
|
802
861
|
frames += 1;
|
|
@@ -813,9 +872,9 @@ net.expose({
|
|
|
813
872
|
me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
|
|
814
873
|
log: guideLog.slice(),
|
|
815
874
|
}),
|
|
816
|
-
// Where the camera looks, and the names as drawn (
|
|
875
|
+
// Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
|
|
817
876
|
camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
|
|
818
|
-
labels: () => shownLabels.map((l) => ({ 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) })),
|
|
877
|
+
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) })),
|
|
819
878
|
});
|
|
820
879
|
|
|
821
880
|
exposePort(net, {
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.18.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.18.2';
|