@homie-rocks/studio 0.32.0 → 0.33.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 +85 -2
- package/README.md +34 -9
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +13 -4
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/standalone-cli.mjs +30 -2
- package/lib/standalone-tools.mjs +20 -2
- package/lib/standalone.mjs +215 -9
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/standalone/STANDALONE.md +61 -6
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* COIN DASH — the rules: the example of a game whose rules run on the server.
|
|
3
|
+
*
|
|
4
|
+
* What is true in the game is decided here, in one place, on the studio's own Cloudflare: who took which coin, every
|
|
5
|
+
* score, when a round starts and ends. A player's browser sends what the player presses and draws what it is told
|
|
6
|
+
* (src/view.ts); it cannot change a score.
|
|
7
|
+
*
|
|
8
|
+
* HOW RULES ARE WRITTEN. Every entity owns its own state, in declared fields. A handler may write only the entity it
|
|
9
|
+
* runs for (`self`); everything else it reads is read-only. To affect another entity it sends an event, which arrives
|
|
10
|
+
* on a later tick. So a runner does not take a coin: it asks the coin (`take`), the coin answers the first one that
|
|
11
|
+
* asked (`score`) and is gone, and a second `take` finds nobody there.
|
|
12
|
+
*
|
|
13
|
+
* In this release a person's runner moves as today: its own browser says where it is, and the server holds each claim
|
|
14
|
+
* to the body's top speed (`move: 'owner'`). The server moves the bots with the same code (src/move.ts).
|
|
15
|
+
*/
|
|
16
|
+
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
17
|
+
import { move } from './move';
|
|
18
|
+
|
|
19
|
+
export default defineRules({
|
|
20
|
+
contract: 2,
|
|
21
|
+
space: { dims: 2 },
|
|
22
|
+
move,
|
|
23
|
+
shapes: {
|
|
24
|
+
events: { take: { by: f.ref() }, score: {} },
|
|
25
|
+
commands: {},
|
|
26
|
+
effects: { ding: {} },
|
|
27
|
+
},
|
|
28
|
+
entities: {
|
|
29
|
+
runner: {
|
|
30
|
+
player: { away: 'think', leave: 'bot' },
|
|
31
|
+
fields: { score: f.u16({ score: true }) },
|
|
32
|
+
motion: { frozenUntil: f.tick() },
|
|
33
|
+
input: { ax: f.i8(), ay: f.i8() },
|
|
34
|
+
body: { shape: 'circle', radius: 0.5, maxSpeed: 6, move: 'owner' },
|
|
35
|
+
tick(world, self) {
|
|
36
|
+
if (world.round.phase !== 'live') return;
|
|
37
|
+
// A second `take` sent before the coin is gone is dropped: the coin no longer exists.
|
|
38
|
+
for (const coin of world.near(self.pos, 1, 'coin')) world.send(coin.id, 'take', { by: self.id });
|
|
39
|
+
},
|
|
40
|
+
think(world, self) { // a bot, and a player who is away
|
|
41
|
+
const coin = world.near(self.pos, 64, 'coin')[0];
|
|
42
|
+
if (!coin) return { ax: 0, ay: 0 };
|
|
43
|
+
const d = world.math.norm(world.math.sub(coin.pos, self.pos));
|
|
44
|
+
return { ax: Math.round(d.x * 127), ay: Math.round(d.y * 127) };
|
|
45
|
+
},
|
|
46
|
+
on: {
|
|
47
|
+
score(world, self) { self.score += 1; },
|
|
48
|
+
arrive(world, self) { // joined during a break: stand still with the others
|
|
49
|
+
if (world.round.phase === 'over') self.motion.frozenUntil = world.round.endsAt;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
onRoom: {
|
|
53
|
+
roundStart(world, self) {
|
|
54
|
+
const spots = world.map.spots('start');
|
|
55
|
+
self.score = 0;
|
|
56
|
+
self.motion.frozenUntil = 0;
|
|
57
|
+
world.place(self, spots[self.seat % spots.length]);
|
|
58
|
+
},
|
|
59
|
+
roundOver(world, self) { self.motion.frozenUntil = world.round.endsAt; }, // stand still through the break
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
coin: {
|
|
63
|
+
on: {
|
|
64
|
+
take(world, self, e) { // first taker wins: a despawned coin hears nothing more this tick
|
|
65
|
+
world.send(e.by, 'score', {});
|
|
66
|
+
world.emit('ding', self.pos, {});
|
|
67
|
+
world.despawn(self);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
onRoom: { roundOver(world, self) { world.despawn(self); } },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
shared: {},
|
|
74
|
+
room: {
|
|
75
|
+
rounds: { seconds: 60, breakSeconds: 8 },
|
|
76
|
+
bots: { keep: 4 },
|
|
77
|
+
join(ctx, player) {
|
|
78
|
+
const spots = ctx.map.spots('start');
|
|
79
|
+
return { kind: 'runner', at: spots[player.seat % spots.length] };
|
|
80
|
+
},
|
|
81
|
+
on: {
|
|
82
|
+
roundStart(world) { for (const spot of world.map.spots('coins')) world.spawn('coin', spot, {}); },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
map: './map',
|
|
86
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* COIN DASH — the view: what a player sees, and what they press.
|
|
3
|
+
*
|
|
4
|
+
* The view draws the room it is told about and sends input. It decides nothing: the coins, the scores and the rounds
|
|
5
|
+
* are the rules' (src/rules.ts), which run on the server. `openRoom()` joins the room the play page names; `room.me`
|
|
6
|
+
* is the player's own runner, `room.each` every entity at render time, `room.on` the effects the rules emit.
|
|
7
|
+
*
|
|
8
|
+
* Canvas 2D on purpose: the point is the two halves, in a page of code each.
|
|
9
|
+
*/
|
|
10
|
+
import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
11
|
+
import { PALETTE, guardGestures } from '@homie-rocks/studio/netplay';
|
|
12
|
+
import type rules from './rules';
|
|
13
|
+
|
|
14
|
+
const room = openRoom<typeof rules>();
|
|
15
|
+
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
|
16
|
+
const g = canvas.getContext('2d') as CanvasRenderingContext2D;
|
|
17
|
+
guardGestures();
|
|
18
|
+
|
|
19
|
+
/* ------------------------------------------------------------------ input: keys, or a thumb dragged from where it landed */
|
|
20
|
+
const keys = new Set<string>();
|
|
21
|
+
addEventListener('keydown', (e) => { keys.add(e.code); });
|
|
22
|
+
addEventListener('keyup', (e) => { keys.delete(e.code); });
|
|
23
|
+
addEventListener('blur', () => keys.clear());
|
|
24
|
+
let thumb: { id: number; x0: number; y0: number; x: number; y: number } | null = null;
|
|
25
|
+
canvas.addEventListener('pointerdown', (e) => { if (!thumb) thumb = { id: e.pointerId, x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY }; });
|
|
26
|
+
addEventListener('pointermove', (e) => { if (thumb && e.pointerId === thumb.id) { thumb.x = e.clientX; thumb.y = e.clientY; } });
|
|
27
|
+
const lift = (e: PointerEvent): void => { if (thumb && e.pointerId === thumb.id) thumb = null; };
|
|
28
|
+
addEventListener('pointerup', lift);
|
|
29
|
+
addEventListener('pointercancel', lift);
|
|
30
|
+
|
|
31
|
+
function stick(): { ax: number; ay: number } {
|
|
32
|
+
let x = (keys.has('KeyD') || keys.has('ArrowRight') ? 1 : 0) - (keys.has('KeyA') || keys.has('ArrowLeft') ? 1 : 0);
|
|
33
|
+
let y = (keys.has('KeyW') || keys.has('ArrowUp') ? 1 : 0) - (keys.has('KeyS') || keys.has('ArrowDown') ? 1 : 0);
|
|
34
|
+
if (thumb) { x = (thumb.x - thumb.x0) / 48; y = -(thumb.y - thumb.y0) / 48; }
|
|
35
|
+
const l = Math.hypot(x, y);
|
|
36
|
+
if (l > 1) { x /= l; y /= l; }
|
|
37
|
+
return { ax: Math.round(x * 127), ay: Math.round(y * 127) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ------------------------------------------------------------------ effects the rules emit */
|
|
41
|
+
const dings: { x: number; y: number; at: number }[] = [];
|
|
42
|
+
room.on('ding', (e) => { if (e.at) dings.push({ x: e.at.x, y: e.at.y, at: performance.now() }); });
|
|
43
|
+
|
|
44
|
+
/* ------------------------------------------------------------------ drawing: metres to pixels, y up */
|
|
45
|
+
const map = room.map;
|
|
46
|
+
const W = map.bounds.max.x - map.bounds.min.x;
|
|
47
|
+
const H = map.bounds.max.y - map.bounds.min.y;
|
|
48
|
+
let scale = 1; let ox = 0; let oy = 0;
|
|
49
|
+
const px = (x: number): number => ox + (x - map.bounds.min.x) * scale;
|
|
50
|
+
const py = (y: number): number => oy + (map.bounds.max.y - y) * scale;
|
|
51
|
+
|
|
52
|
+
function fit(): void {
|
|
53
|
+
const dpr = Math.min(2, devicePixelRatio || 1);
|
|
54
|
+
canvas.width = Math.round(innerWidth * dpr); canvas.height = Math.round(innerHeight * dpr);
|
|
55
|
+
scale = Math.min(canvas.width / (W + 1), canvas.height / (H + 3));
|
|
56
|
+
ox = (canvas.width - W * scale) / 2; oy = (canvas.height - H * scale) / 2 + scale * 0.6;
|
|
57
|
+
}
|
|
58
|
+
addEventListener('resize', fit);
|
|
59
|
+
fit();
|
|
60
|
+
|
|
61
|
+
function frame(): void {
|
|
62
|
+
room.input(stick());
|
|
63
|
+
g.fillStyle = '#0b1020'; g.fillRect(0, 0, canvas.width, canvas.height);
|
|
64
|
+
g.fillStyle = '#141b33'; g.fillRect(px(map.bounds.min.x), py(map.bounds.max.y), W * scale, H * scale);
|
|
65
|
+
g.fillStyle = '#2a3560';
|
|
66
|
+
for (const b of map.boxes) g.fillRect(px(b.min.x), py(b.max.y), (b.max.x - b.min.x) * scale, (b.max.y - b.min.y) * scale);
|
|
67
|
+
for (const c of map.circles) { g.beginPath(); g.arc(px(c.at.x), py(c.at.y), c.r * scale, 0, Math.PI * 2); g.fill(); }
|
|
68
|
+
|
|
69
|
+
room.each('coin', (c) => {
|
|
70
|
+
g.fillStyle = '#ffd166'; g.beginPath(); g.arc(px(c.pos.x), py(c.pos.y), 0.28 * scale, 0, Math.PI * 2); g.fill();
|
|
71
|
+
g.fillStyle = '#fff3c4'; g.beginPath(); g.arc(px(c.pos.x) - 0.08 * scale, py(c.pos.y) - 0.08 * scale, 0.09 * scale, 0, Math.PI * 2); g.fill();
|
|
72
|
+
});
|
|
73
|
+
const now = performance.now();
|
|
74
|
+
for (let i = dings.length - 1; i >= 0; i -= 1) {
|
|
75
|
+
const t = (now - dings[i].at) / 450;
|
|
76
|
+
if (t >= 1) { dings.splice(i, 1); continue; }
|
|
77
|
+
g.strokeStyle = `rgba(255, 209, 102, ${1 - t})`; g.lineWidth = 0.08 * scale;
|
|
78
|
+
g.beginPath(); g.arc(px(dings[i].x), py(dings[i].y), (0.3 + t * 0.9) * scale, 0, Math.PI * 2); g.stroke();
|
|
79
|
+
}
|
|
80
|
+
const names = new Map(room.roster.map((r) => [r.seat, r]));
|
|
81
|
+
g.textAlign = 'center'; g.font = `${Math.round(0.42 * scale)}px system-ui, sans-serif`;
|
|
82
|
+
room.each('runner', (r) => {
|
|
83
|
+
const x = px(r.pos.x); const y = py(r.pos.y);
|
|
84
|
+
g.fillStyle = PALETTE[(r.seat ?? 0) % PALETTE.length];
|
|
85
|
+
g.beginPath(); g.arc(x, y, 0.5 * scale, 0, Math.PI * 2); g.fill();
|
|
86
|
+
// A dot where the runner faces.
|
|
87
|
+
g.fillStyle = '#0b1020'; g.beginPath(); g.arc(x + r.heading.x * 0.28 * scale, y - r.heading.y * 0.28 * scale, 0.11 * scale, 0, Math.PI * 2); g.fill();
|
|
88
|
+
if (r.mine) { g.strokeStyle = '#ffffff'; g.lineWidth = 0.07 * scale; g.beginPath(); g.arc(x, y, 0.58 * scale, 0, Math.PI * 2); g.stroke(); }
|
|
89
|
+
g.fillStyle = r.away ? '#7c86a8' : '#e8ecff';
|
|
90
|
+
g.fillText(names.get(r.seat ?? -1)?.name ?? '', x, y - 0.75 * scale);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The HUD: the clock (from the room's own), and the board (from the room's roster).
|
|
94
|
+
const round = room.round;
|
|
95
|
+
g.fillStyle = '#e8ecff'; g.font = `600 ${Math.round(0.7 * scale)}px system-ui, sans-serif`;
|
|
96
|
+
const top = oy - 0.5 * scale;
|
|
97
|
+
if (room.status !== 'playing') g.fillText(room.status === 'connecting' ? 'Joining the room…' : 'Coin Dash needs a connection to its room', canvas.width / 2, top);
|
|
98
|
+
else if (round) g.fillText(round.phase === 'live' ? `Round ${round.n} ${Math.ceil(round.secondsLeft)}` : `Round ${round.n} is over. Next in ${Math.ceil(round.secondsLeft)}`, canvas.width / 2, top);
|
|
99
|
+
g.textAlign = 'left'; g.font = `${Math.round(0.42 * scale)}px system-ui, sans-serif`;
|
|
100
|
+
[...room.roster].sort((a, b) => b.score - a.score || a.seat - b.seat).forEach((r, i) => {
|
|
101
|
+
g.fillStyle = PALETTE[r.seat % PALETTE.length];
|
|
102
|
+
g.fillText(`${r.score} ${r.name}${r.me ? ' (you)' : ''}`, ox + 0.3 * scale, oy + (0.6 + i * 0.55) * scale);
|
|
103
|
+
});
|
|
104
|
+
requestAnimationFrame(frame);
|
|
105
|
+
}
|
|
106
|
+
requestAnimationFrame(frame);
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
.panel li { margin: 4px 0; color: #e7d9b4; }
|
|
22
22
|
.panel li small { color: #a39a80; }
|
|
23
23
|
.panel .close { float: right; background: none; border: 0; color: #c9c0a6; font-size: 20px; cursor: pointer; }
|
|
24
|
+
/* A panel never runs off a short screen (a phone on its side, a small frame in somebody's post): there the new
|
|
25
|
+
hero's card becomes two rows at the bottom, clear of the page's own buttons along the top. While a panel is
|
|
26
|
+
open the touch controls (drawn over everything, .hp-touch) are not drawn over it. */
|
|
27
|
+
.panel { max-height: calc(100vh - 16px); overflow: auto; }
|
|
28
|
+
body:has(.panel:not([hidden])) .hp-touch { visibility: hidden; }
|
|
29
|
+
@media (max-height: 340px) {
|
|
30
|
+
#make { top: auto; bottom: calc(8px + env(safe-area-inset-bottom)); transform: translateX(-50%); width: min(520px, calc(100vw - 24px)); padding: 10px 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px; align-items: center; }
|
|
31
|
+
#make h2 { margin: 0; font-size: 16px; }
|
|
32
|
+
#make p { display: none; }
|
|
33
|
+
#make label.hc { margin: 0; font-size: 11.5px; line-height: 1.25; }
|
|
34
|
+
}
|
|
24
35
|
/* Asking a guide: buttons (agents.json "asks"), never a text box. On a computer: bottom centre. */
|
|
25
36
|
.asks { left: 50%; transform: translateX(-50%); bottom: calc(14px + env(safe-area-inset-bottom)); max-width: min(560px, calc(100vw - 200px)); box-sizing: border-box; padding: 8px 10px; border-radius: 14px; background: rgba(10,20,18,.84); border: 1px solid rgba(127,216,200,.45); text-align: center; }
|
|
26
37
|
.asks .who { font: 700 12.5px/1.2 inherit; font-family: inherit; color: #7fd8c8; margin: 0 0 6px; }
|
package/worker/brain.mjs
CHANGED
|
@@ -53,7 +53,8 @@ function argSpec(raw) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function argsOf(raw, where, problems) {
|
|
56
|
-
|
|
56
|
+
// No prototype: an argument name comes from outside (a model's answer, a person's ask), and "constructor" is not one.
|
|
57
|
+
const out = Object.create(null);
|
|
57
58
|
if (raw === undefined || raw === null) return out;
|
|
58
59
|
if (typeof raw !== 'object' || Array.isArray(raw)) { problems.push(`${where}: args is an object of name: type`); return null; }
|
|
59
60
|
const names = Object.keys(raw);
|
|
@@ -96,7 +97,7 @@ export function vocabularyOf(raw) {
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
const section = (key, max, each) => {
|
|
99
|
-
const out =
|
|
100
|
+
const out = Object.create(null);
|
|
100
101
|
const src = raw[key];
|
|
101
102
|
if (src === undefined) return out;
|
|
102
103
|
if (!src || typeof src !== 'object' || Array.isArray(src)) { problems.push(`${key} is an object of id: entry`); return out; }
|
|
@@ -173,7 +174,7 @@ export function checkArgs(specs, args, ctx = {}) {
|
|
|
173
174
|
/** How a value reads aloud: its label in agents.json, else the value with dashes as spaces. */
|
|
174
175
|
export function labelOf(vocab, value) {
|
|
175
176
|
const v = String(value ?? '');
|
|
176
|
-
return vocab?.labels
|
|
177
|
+
return vocab?.labels && Object.hasOwn(vocab.labels, v) ? vocab.labels[v] : v.replace(/[-_]+/g, ' ');
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
/**
|
|
@@ -181,7 +182,7 @@ export function labelOf(vocab, value) {
|
|
|
181
182
|
* the guide's own name for {me}. Null when the id or its arguments do not fit.
|
|
182
183
|
*/
|
|
183
184
|
export function renderLine(vocab, id, args = {}, { nameOf = (seat) => `Player ${Number(seat) + 1}`, me = 'Guide', view = null, players = null, kind = 'lines' } = {}) {
|
|
184
|
-
const entry = vocab?.[kind]
|
|
185
|
+
const entry = vocab?.[kind] && typeof id === 'string' && Object.hasOwn(vocab[kind], id) ? vocab[kind][id] : null;
|
|
185
186
|
if (!entry) return null;
|
|
186
187
|
if (checkArgs(entry.args, args, { view, players })) return null;
|
|
187
188
|
return entry.text.replace(SLOT_IN_TEXT, (m, k) => (k === 'me' ? String(me) : entry.args[k]?.kind === 'player' ? String(nameOf(args[k]) ?? '') : labelOf(vocab, args[k])));
|
|
@@ -304,14 +305,14 @@ export function parseDecision(out, vocab, { view = null, players = null, avoid =
|
|
|
304
305
|
}
|
|
305
306
|
if (!o || typeof o !== 'object' || Array.isArray(o)) return bad('not an object');
|
|
306
307
|
for (const k of Object.keys(o)) if (!['goal', 'args', 'say', 'sayArgs'].includes(k)) return bad(`extra field ${String(k).slice(0, 16)}`);
|
|
307
|
-
const goal = vocab.goals[o.goal];
|
|
308
|
+
const goal = (typeof o.goal === 'string' && Object.hasOwn(vocab.goals, o.goal) ? vocab.goals[o.goal] : null);
|
|
308
309
|
if (typeof o.goal !== 'string' || !goal) return bad('goal is not one of the vocabulary\'s');
|
|
309
310
|
const ga = o.args ?? {};
|
|
310
311
|
const gw = checkArgs(goal.args, ga, { view, players });
|
|
311
312
|
if (gw) return bad(`goal ${o.goal}: ${gw}`);
|
|
312
313
|
let say = null; let sayArgs = {};
|
|
313
314
|
if (o.say !== null && o.say !== undefined && o.say !== '') {
|
|
314
|
-
const line = vocab.lines[o.say];
|
|
315
|
+
const line = (typeof o.say === 'string' && Object.hasOwn(vocab.lines, o.say) ? vocab.lines[o.say] : null);
|
|
315
316
|
if (typeof o.say !== 'string' || !line) return bad('say is not one of the vocabulary\'s lines');
|
|
316
317
|
sayArgs = o.sayArgs ?? {};
|
|
317
318
|
const lw = checkArgs(line.args, sayArgs, { view, players });
|
|
@@ -332,7 +333,7 @@ export function scripted(vocab, { asks = [], view = null, players = null, avoid
|
|
|
332
333
|
const ctx = { view, players };
|
|
333
334
|
const near = (specs, a) => Object.entries(specs).some(([k, s]) => s.kind === 'player' && avoid.includes(a[k]));
|
|
334
335
|
for (const a of [...asks].sort((x, y) => (y.at ?? 0) - (x.at ?? 0))) {
|
|
335
|
-
const ask = vocab.asks[a.k];
|
|
336
|
+
const ask = typeof a.k === 'string' && Object.hasOwn(vocab.asks, a.k) ? vocab.asks[a.k] : null;
|
|
336
337
|
if (!ask || (!ask.goal && !ask.say)) continue;
|
|
337
338
|
// Arguments carry over by name; a player argument is the player who asked.
|
|
338
339
|
const fill = (specs) => Object.fromEntries(Object.entries(specs).map(([k, s]) => [k, s.kind === 'player' ? a.from : a.args?.[k]]));
|
package/worker/chat-page.mjs
CHANGED
|
@@ -527,7 +527,7 @@ export const CHAT_JS = String.raw`(function () {
|
|
|
527
527
|
else if (!typing && r.who === 'members') n = 'Only members of this server type here.';
|
|
528
528
|
if (!reacts && !watchersOut && r.react === 'signed-in' && !cfg.acct) { n = 'Sign in to chat in this room. '; link = 'Sign in'; }
|
|
529
529
|
note.textContent = n;
|
|
530
|
-
if (link) { var a = el('a', '', link); a.href = cfg.account + '?next=' + encodeURIComponent(location.pathname + location.search); a.target = surface === 'play' ? '_blank' : '_self'; note.appendChild(a); }
|
|
530
|
+
if (link) { var a = el('a', '', link); a.href = cfg.account + '?next=' + encodeURIComponent(window.__shell && window.__shell.returnPath ? window.__shell.returnPath() : location.pathname + location.search); a.target = surface === 'play' ? '_blank' : '_self'; note.appendChild(a); }
|
|
531
531
|
note.hidden = !n;
|
|
532
532
|
bubbleBox.parentNode.hidden = surface !== 'play' || !r.bubbles || mySeat() === null;
|
|
533
533
|
}
|
package/worker/embed.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Player cards. X's reference: developer.x.com, "Cards: player card" (archived)
|
|
3
|
+
* (redirects to docs.x.com/overview as checked 2026-10-08). Surviving official sample:
|
|
4
|
+
* https://github.com/xdevplatform/cards-player-samples/blob/main/player/page.html
|
|
5
|
+
* https://devcommunity.x.com/t/player-cards-improved-security-and-faster-approval-times/50033
|
|
6
|
+
* https://devcommunity.x.com/t/fullscreen-for-player-cards/70717
|
|
7
|
+
* The archived reference reserves cards for linear audio/video and excludes games. Phone apps
|
|
8
|
+
* open twitter:player top-level in their in-app browser. The 2026-10-08 public X web bundle
|
|
9
|
+
* https://abs.twimg.com/x-web/x-web/assets/article-card-DEH89WAV.js (function Ts) only frames
|
|
10
|
+
* YouTube/SoundCloud/Periscope; other hosts link to card_url in a new tab. Not a signed-in test.
|
|
11
|
+
* X documents HTTPS media players and sandboxing, NOT a supported game API, a current exhaustive
|
|
12
|
+
* ancestor list, or guaranteed fullscreen. The X domain families and the sandbox below are observed
|
|
13
|
+
* (the frame-ancestors header of the live game page https://snekarena.com/r/main, read 2026-10-08, and
|
|
14
|
+
* the bundle above), not a promise from X.
|
|
15
|
+
* No og:video/stream tag: an HTML game is not a video. Consumers of Twitter player tags can use the
|
|
16
|
+
* same metadata; other platforms keep the OG image (https://ogp.me/). No universal game-card standard.
|
|
17
|
+
*/
|
|
18
|
+
export const PLAYER_ANCESTORS = ['https://x.com', 'https://*.x.com', 'https://twitter.com', 'https://*.twitter.com'];
|
|
19
|
+
export const PLAYER_SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox';
|
|
20
|
+
/**
|
|
21
|
+
* The size the tags advertise: 480×480.
|
|
22
|
+
* - The two game pages whose posts showed a play button the week of 2026-10-08 both advertise 480×480
|
|
23
|
+
* (tags read as Twitterbot from https://snekarena.com/r/main and from a game page on https://www.spawn.co),
|
|
24
|
+
* with pictures of 1200×628 and 1200×800: X accepts a picture whose shape is not the player's.
|
|
25
|
+
* - X's reference says the player is scaled to the post's width at this width:height ratio. A phone's post is
|
|
26
|
+
* about 358 px wide: a 16:9 player there is 201 px tall, too short for a game's own dialogs and this
|
|
27
|
+
* shell's room sheet; a square one is 358 px tall.
|
|
28
|
+
* - X's web bundle (above) clamps the ratio to max(width/height, 1), so square is the tallest it shows.
|
|
29
|
+
* X's phone apps open the player address full screen, where this size only shapes the picture in the post.
|
|
30
|
+
*/
|
|
31
|
+
export const PLAYER_SIZE = { width: 480, height: 480 };
|
|
32
|
+
/**
|
|
33
|
+
* What X's reference says a card picture must be for the card to render: JPG, PNG, WEBP or GIF, at least 68,600
|
|
34
|
+
* pixels (262×262, or 350×196), under 5 MB. It also says the picture "should" have the player's dimensions:
|
|
35
|
+
* advice, which the build passes on as a warning and never as a refusal (lib/player-image.mjs).
|
|
36
|
+
*/
|
|
37
|
+
export const PLAYER_IMAGE = { formats: ['jpeg', 'png', 'webp', 'gif'], minPixels: 68600, maxBytes: 5000000 };
|
|
38
|
+
export const playerImageRule = (m) => (!m || !PLAYER_IMAGE.formats.includes(m.format) ? 'format'
|
|
39
|
+
: !(m.width * m.height >= PLAYER_IMAGE.minPixels) ? 'pixels' : !(m.bytes < PLAYER_IMAGE.maxBytes) ? 'bytes' : null);
|
|
40
|
+
|
|
41
|
+
/** These are switches, not plan limits. TV-only games must declare their lack of a single-screen view. */
|
|
42
|
+
export function playerEnabled(cat, game) {
|
|
43
|
+
return cat?.studio?.site?.playerCard !== false && game?.playerCard !== false &&
|
|
44
|
+
game?.screen?.singleScreen !== false && !['private', 'invite'].includes(game?.launch);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Exact HTTPS origins, or an HTTPS DNS wildcard, never arbitrary CSP text. An explicit [] denies all. */
|
|
48
|
+
export function playerOrigins(value) {
|
|
49
|
+
if (!Array.isArray(value)) return [...PLAYER_ANCESTORS];
|
|
50
|
+
const origins = [];
|
|
51
|
+
for (const v of value) {
|
|
52
|
+
if (typeof v !== 'string' || !/^https:\/\/(?:\*\.)?[a-z0-9.-]+(?::[0-9]+)?$/i.test(v)) continue;
|
|
53
|
+
try {
|
|
54
|
+
const wildcard = v.includes('*.');
|
|
55
|
+
const u = new URL(v.replace('*.', ''));
|
|
56
|
+
const labels = u.hostname.split('.');
|
|
57
|
+
if (labels.length < 2 || labels.some(l => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(l))) continue;
|
|
58
|
+
// A wildcard must name a domain, never a top-level suffix.
|
|
59
|
+
if (wildcard && (labels.length < 2 || /^(?:co|com|net|org)\.[a-z]{2}$/.test(u.hostname))) continue;
|
|
60
|
+
const origin = 'https://' + (wildcard ? '*.' : '') + u.host;
|
|
61
|
+
if (!origins.includes(origin)) origins.push(origin);
|
|
62
|
+
if (origins.length === 8) break;
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
return origins;
|
|
66
|
+
}
|
|
67
|
+
export function playerAncestors(cat) {
|
|
68
|
+
return playerOrigins(cat?.studio?.site?.playerCardOrigins).join(' ') || "'none'";
|
|
69
|
+
}
|
|
70
|
+
export function httpsAddress(value, origin) {
|
|
71
|
+
try { const u = new URL(value, origin); return u.protocol === 'https:' && !u.username && !u.password ? u.href : null; } catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
export function playerProperties(cat, game, { origin = '', title = game?.name, description = game?.blurb } = {}) {
|
|
74
|
+
const player = httpsAddress(`/${game?.id}/play/embed`, origin);
|
|
75
|
+
const measured = game?.playerImage;
|
|
76
|
+
const picture = measured && httpsAddress(measured.src, origin);
|
|
77
|
+
// twitter:site goes out when the studio has set its handle; a card is never withheld for the lack of one
|
|
78
|
+
// (neither live game page above carries it).
|
|
79
|
+
const handle = /^@[A-Za-z0-9_]{1,15}$/.test(cat?.studio?.site?.twitterSite || '') ? cat.studio.site.twitterSite : null;
|
|
80
|
+
if (!playerEnabled(cat, game) || !player || !picture || playerImageRule(measured)) return {};
|
|
81
|
+
return { ...(handle ? { 'twitter:site': handle } : {}), 'twitter:card': 'player', 'twitter:player': player, 'twitter:player:width': PLAYER_SIZE.width,
|
|
82
|
+
'twitter:player:height': PLAYER_SIZE.height, 'twitter:image': picture, 'twitter:title': title,
|
|
83
|
+
'twitter:description': String(description || `Play ${game.name} in your browser.`).slice(0, 200) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Browser restrictions belong to the outer host; failures must not kill a game's input loop.
|
|
87
|
+
* MDN, "iframe: sandbox"
|
|
88
|
+
* MDN, "Permissions Policy"
|
|
89
|
+
* Used only for the embedded instance, ahead of the game's scripts. Existing port/early provides
|
|
90
|
+
* storage/cookie shims for imported games; netplay and the shell already guard their own storage.
|
|
91
|
+
*/
|
|
92
|
+
export const EMBED_GAME_JS = String.raw`(function () {
|
|
93
|
+
function unavailable(feature) { parent.postMessage({t:'homie-net',what:'embed-unavailable',feature:feature}, '*'); }
|
|
94
|
+
function guarded(proto, name, feature) {
|
|
95
|
+
var real = proto && proto[name];
|
|
96
|
+
if (typeof real !== 'function') return;
|
|
97
|
+
try { proto[name] = function () {
|
|
98
|
+
try { var result = real.apply(this, arguments); return result && typeof result.catch === 'function' ? result.catch(function () { unavailable(feature); }) : result; }
|
|
99
|
+
catch (e) { unavailable(feature); return Promise.resolve(); }
|
|
100
|
+
}; } catch (e) {}
|
|
101
|
+
}
|
|
102
|
+
guarded(Element.prototype, 'requestPointerLock', 'pointer lock');
|
|
103
|
+
guarded(Element.prototype, 'requestFullscreen', 'fullscreen');
|
|
104
|
+
document.addEventListener('pointerlockerror', function () { unavailable('pointer lock'); });
|
|
105
|
+
document.addEventListener('fullscreenerror', function () { unavailable('fullscreen'); });
|
|
106
|
+
var pads = navigator.getGamepads;
|
|
107
|
+
if (pads) try { Object.defineProperty(navigator, 'getGamepads', { configurable:true, value:function () {
|
|
108
|
+
try { return pads.call(navigator); } catch (e) { if (!pads.warned) { pads.warned = true; unavailable('gamepad'); } return []; }
|
|
109
|
+
}}); } catch (e) {}
|
|
110
|
+
// Keep early requests pending and replay them on the first real press. A game that asks only once
|
|
111
|
+
// must start too; resolving a no-op resume/play loses that request.
|
|
112
|
+
var pressed = false, pending = [];
|
|
113
|
+
function afterPress(run) {
|
|
114
|
+
if (pressed) return run();
|
|
115
|
+
return new Promise(function (resolve, reject) { pending.push(function () { run().then(resolve, reject); }); });
|
|
116
|
+
}
|
|
117
|
+
['AudioContext','webkitAudioContext'].forEach(function (name) {
|
|
118
|
+
var AC = window[name]; if (!AC) return;
|
|
119
|
+
try { window[name] = new Proxy(AC, { construct:function (ctor,args) {
|
|
120
|
+
var ctx = Reflect.construct(ctor,args), resume = ctx.resume.bind(ctx);
|
|
121
|
+
if (!pressed) ctx.suspend().catch(function () {});
|
|
122
|
+
ctx.resume = function () { return afterPress(resume); };
|
|
123
|
+
return ctx;
|
|
124
|
+
}}); } catch (e) {}
|
|
125
|
+
});
|
|
126
|
+
['pointerdown','keydown','touchstart'].forEach(function (name) { addEventListener(name,function () {
|
|
127
|
+
if (pressed) return;
|
|
128
|
+
pressed = true; var queued = pending; pending = []; queued.forEach(function (run) { run(); });
|
|
129
|
+
},{capture:true,once:true}); });
|
|
130
|
+
var play = HTMLMediaElement.prototype.play;
|
|
131
|
+
HTMLMediaElement.prototype.play = function () {
|
|
132
|
+
var media = this;
|
|
133
|
+
return afterPress(function () { return play.call(media); });
|
|
134
|
+
};
|
|
135
|
+
}());`;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-hosted games: the rules this studio's build put into its Worker, and the host runtime a Table runs for one
|
|
3
|
+
* room of such a game (NETPLAY.md section 29; rules/host.ts is the runtime, worker/room.mjs the relay it serves).
|
|
4
|
+
*
|
|
5
|
+
* `homie-studio build` writes every server-hosted game's rules, guarded (lib/rules-guard.mjs), into the studio's
|
|
6
|
+
* site/src/rules/, with a table of them (site/src/rules/index.mjs): per game its rules module, its tunables, its map,
|
|
7
|
+
* its settings and its seats. The studio's site/src/worker.mjs hands that table over once, when the Worker loads:
|
|
8
|
+
*
|
|
9
|
+
* import { hostRules } from '@homie-rocks/studio/worker';
|
|
10
|
+
* import rules from './rules/index.mjs';
|
|
11
|
+
* hostRules(rules);
|
|
12
|
+
*
|
|
13
|
+
* Only the studio's own games are in it: no rules from outside the studio run on its server. The rules run in this
|
|
14
|
+
* Worker's isolate, behind the build's wall and the runtime's budget, until rules get an isolate of their own.
|
|
15
|
+
*/
|
|
16
|
+
import { createHost } from '../rules/host.ts';
|
|
17
|
+
import { compileMap, compileRules } from '../rules/rules.ts';
|
|
18
|
+
|
|
19
|
+
/** game id → { rules, tune, map, settings, seats, build } as the build wrote it, and what compiling each gave. */
|
|
20
|
+
const games = new Map();
|
|
21
|
+
const compiled = new Map();
|
|
22
|
+
|
|
23
|
+
/** The table of this studio's server-hosted games (the build's site/src/rules/index.mjs). Called once, at load. */
|
|
24
|
+
export function hostRules(table) {
|
|
25
|
+
games.clear();
|
|
26
|
+
compiled.clear();
|
|
27
|
+
if (!table || typeof table !== 'object') return 0;
|
|
28
|
+
for (const [id, entry] of Object.entries(table)) if (entry && typeof entry === 'object' && entry.rules) games.set(id, entry);
|
|
29
|
+
return games.size;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether this Worker holds rules for a game. */
|
|
33
|
+
export const hostedGame = (id) => games.has(id);
|
|
34
|
+
export const hostedBuild = (id) => games.get(id);
|
|
35
|
+
|
|
36
|
+
/** A game's rules, compiled once per isolate. Throws, naming the declaration, when they do not fit the contract. */
|
|
37
|
+
export function compiledFor(id) {
|
|
38
|
+
if (compiled.has(id)) return compiled.get(id);
|
|
39
|
+
const e = games.get(id);
|
|
40
|
+
if (!e) throw new Error('this build of the site holds no rules for this game');
|
|
41
|
+
const c = compileRules(e.rules, { tune: e.tune, map: compileMap(e.map, e.map?.name ?? 'main'), settings: e.settings, seats: e.seats });
|
|
42
|
+
compiled.set(id, c);
|
|
43
|
+
return c;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The host runtime for one room. `send(frame, text)` is the relay's `hostFrame`; the clock is the Worker's own, and the
|
|
48
|
+
* tick loop a timer chain inside the object. The Table supplies its synchronous store and recovery settings.
|
|
49
|
+
*/
|
|
50
|
+
export function startHost(game, { send, log, onPause, onResume, onEnd, ...recovery }) {
|
|
51
|
+
return createHost({
|
|
52
|
+
game, build: games.get(game)?.build ?? undefined, compiled: compiledFor(game), send, log, onPause, onResume, onEnd,
|
|
53
|
+
clock: { now: () => Date.now(), setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h) },
|
|
54
|
+
...recovery,
|
|
55
|
+
});
|
|
56
|
+
}
|