@homie-rocks/studio 0.16.0 → 0.17.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/README.md +11 -4
- package/agents/GUIDES.md +99 -0
- package/agents/agents.ts +395 -0
- package/bin/homie-studio.mjs +71 -8
- package/lib/agent-seat.mjs +185 -0
- package/lib/build.mjs +23 -0
- package/lib/cloudflare.mjs +28 -0
- package/lib/mcp-tools.mjs +56 -0
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +14 -1
- package/lib/servers.mjs +66 -4
- package/lib/template-history.json +56 -0
- package/netplay/NETPLAY.md +157 -6
- package/netplay/netplay.ts +10 -4
- package/package.json +7 -1
- package/site/SITE.md +16 -4
- package/starters/ember-vale/agents.json +34 -0
- package/starters/ember-vale/index.html +8 -0
- package/starters/ember-vale/src/main.ts +252 -5
- package/starters/gem-rush/src/main.ts +4 -1
- package/worker/agents.mjs +359 -4
- package/worker/brain.mjs +417 -0
- package/worker/index.mjs +115 -7
- package/worker/office-page.mjs +273 -26
- package/worker/office.mjs +62 -8
- package/worker/pages.mjs +28 -10
- package/worker/room.mjs +115 -6
- package/worker/stats.mjs +4 -2
- package/worker/version.mjs +1 -1
|
@@ -22,8 +22,18 @@
|
|
|
22
22
|
* Servers and the skill dial (contract revision 6): the vale's bots hunt at the room's dial (`room.skillOf(body)`:
|
|
23
23
|
* how soon they notice a slime, how close they stand, how often they strike), and a beginner server's AI guide seats
|
|
24
24
|
* are kept as AI bodies, marked " · AI". createRoom does the seats; the bots below read the dial.
|
|
25
|
+
*
|
|
26
|
+
* AI GUIDES (contract revision 7, @homie-rocks/studio/agents): on a beginner server the guide seats get a brain. The
|
|
27
|
+
* vale's own words for it are ../agents.json: the goals a guide may take (follow, quest, lead, guard, back), the lines
|
|
28
|
+
* it may say, and the asks a new hero can tap ("Help me with King Slime", "Take me to camp", "No thanks"). The HANDS
|
|
29
|
+
* below play every frame at the dial: they walk to the goal, hunt what the goal says, and tell agents.done() when it is
|
|
30
|
+
* done. The BRAIN is the server's (the Table's Workers AI, the owner's key, or the owner's own Claude in the seat); with
|
|
31
|
+
* none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
|
|
32
|
+
* never from a model; "Quiet AI" hides them.
|
|
25
33
|
*/
|
|
26
|
-
import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
|
|
34
|
+
import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
35
|
+
import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
|
|
36
|
+
import vocabulary from '../agents.json';
|
|
27
37
|
|
|
28
38
|
/* ------------------------------------------------------------------ rules */
|
|
29
39
|
const W = 1600;
|
|
@@ -107,6 +117,10 @@ let slimeSeq = 0;
|
|
|
107
117
|
let spawnAt = 0;
|
|
108
118
|
let kingAt = 0;
|
|
109
119
|
const me = { x: W / 2, y: H / 2, face: 0, has: false, strikes: 0, flashAt: 0 };
|
|
120
|
+
/** Where a guide can lead the party (agents.json "lead"). */
|
|
121
|
+
const PLACES: Record<string, { x: number; y: number }> = { camp: { x: W / 2, y: H / 2 }, king: { x: W / 2, y: 120 }, 'east-woods': { x: W - 200, y: H / 2 + 30 } };
|
|
122
|
+
/** When the last King Slime fell (host): a guide on the King's quest is done. */
|
|
123
|
+
let kingSlainAt = 0;
|
|
110
124
|
|
|
111
125
|
const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
112
126
|
game: 'ember-vale',
|
|
@@ -142,6 +156,62 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
|
142
156
|
});
|
|
143
157
|
const net = room.net;
|
|
144
158
|
const myLevel = (): number => hero?.level ?? 1;
|
|
159
|
+
|
|
160
|
+
/* ------------------------------------------------------------------ the guides' brain (agents.json) */
|
|
161
|
+
type PartyRow = { seat: number; dist: number; hp: number; down: boolean };
|
|
162
|
+
const greeted = new Map<number, Set<number>>();
|
|
163
|
+
const warnedAt = new Map<number, number>();
|
|
164
|
+
const agents = useAgents(net, vocabulary as unknown as Vocabulary, {
|
|
165
|
+
// What a guide sees (host, at most every 2 s, under 2 KB): the vale near it, never a name, an account or typed text.
|
|
166
|
+
view: (slot) => {
|
|
167
|
+
const b = room.bodies.get(slot);
|
|
168
|
+
if (!b) return {};
|
|
169
|
+
const party: PartyRow[] = [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null)
|
|
170
|
+
.map((o) => ({ seat: o.seat as number, dist: Math.round(Math.hypot(o.x - b.x, o.y - b.y)), hp: Math.round((100 * o.hp) / Math.max(1, o.maxHp)), down: Boolean(o.flags & DOWN) }))
|
|
171
|
+
.filter((p) => p.dist < 900).sort((x, y) => x.dist - y.dist).slice(0, 6);
|
|
172
|
+
const king = slimes.find((o) => o.size === 3);
|
|
173
|
+
const near = slimes.filter((o) => Math.hypot(o.x - b.x, o.y - b.y) < 350).length;
|
|
174
|
+
const c = room.clock();
|
|
175
|
+
return {
|
|
176
|
+
me: { hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN) },
|
|
177
|
+
zone: zoneOf(b.x, b.y),
|
|
178
|
+
// A brain thinks again when this changes, so it changes only for what matters: the King close, or a swarm.
|
|
179
|
+
danger: king && Math.hypot(king.x - b.x, king.y - b.y) < 450 ? 'the King Slime' : near >= 5 ? 'slimes' : null,
|
|
180
|
+
party, quests: openQuests(), slimes: { near, king: Boolean(king) }, round: { phase: c.phase, left: c.secondsLeft },
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
// The scripted floor: with no AI, over budget, and between an AI's decisions. Synchronous; it never waits.
|
|
184
|
+
decide: (v, { slot, goal }) => {
|
|
185
|
+
const a = (v['asks'] as { k: string; args: Record<string, unknown> }[] | undefined)?.[0];
|
|
186
|
+
if (a?.k === 'ask_help') return { goal: 'quest', args: { quest: a.args['quest'] }, say: 'quest_help', sayArgs: { quest: a.args['quest'] } };
|
|
187
|
+
if (a?.k === 'lead_me') return { goal: 'lead', args: { place: a.args['place'] }, say: 'follow_me', sayArgs: { place: a.args['place'] } };
|
|
188
|
+
if (a?.k === 'no_thanks') return { goal: 'back', say: 'bye' };
|
|
189
|
+
const party = (v['party'] as PartyRow[] | undefined) ?? [];
|
|
190
|
+
// What a hero asked for is carried through until it is done.
|
|
191
|
+
if (goal && goal.state === 'active' && ['quest', 'lead', 'back'].includes(goal.goal)) return null;
|
|
192
|
+
const seen = greeted.get(slot) ?? new Set<number>();
|
|
193
|
+
greeted.set(slot, seen);
|
|
194
|
+
const fresh = party.find((p) => !seen.has(p.seat) && p.dist < 420);
|
|
195
|
+
if (fresh) { seen.add(fresh.seat); return { goal: 'follow', args: { seat: fresh.seat }, say: 'hello', sayArgs: { player: fresh.seat } }; }
|
|
196
|
+
if (v['danger'] === 'the King Slime') {
|
|
197
|
+
const warn = room.net.now() - (warnedAt.get(slot) ?? 0) > 30_000;
|
|
198
|
+
if (warn) warnedAt.set(slot, room.net.now());
|
|
199
|
+
return { goal: 'guard', ...(warn ? { say: 'careful', sayArgs: { thing: 'the King Slime' } } : {}) };
|
|
200
|
+
}
|
|
201
|
+
const hurt = party.find((p) => !p.down && p.hp < 40);
|
|
202
|
+
if (hurt) return { goal: 'follow', args: { seat: hurt.seat } };
|
|
203
|
+
if (party[0]) return { goal: 'follow', args: { seat: party[0].seat } };
|
|
204
|
+
return { goal: 'lead', args: { place: 'camp' } };
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
/** A guide's line over its head for a few seconds: the game's own words (agents.json), never a model's. */
|
|
208
|
+
const bubbles = new Map<number, { text: string; until: number }>();
|
|
209
|
+
/** What the e2e probe reads: goal changes (with the ask that led to one), asks, lines. Never a name or an account. */
|
|
210
|
+
const guideLog: Record<string, unknown>[] = [];
|
|
211
|
+
const logGuide = (row: Record<string, unknown>): void => { guideLog.push({ at: Date.now(), ...row }); if (guideLog.length > 80) guideLog.shift(); };
|
|
212
|
+
agents.on('say', (e) => { bubbles.set(e.slot, { text: e.text, until: performance.now() + 3600 }); logGuide({ ev: 'say', slot: e.slot, line: e.line }); });
|
|
213
|
+
agents.on('goal', (e) => logGuide({ ev: 'goal', slot: e.slot, goal: e.goal.goal, args: e.goal.args, from: e.goal.from, askAt: e.askAt }));
|
|
214
|
+
agents.on('ask', (e) => logGuide({ ev: 'ask', slot: e.slot, k: e.k, args: e.args, from: e.from }));
|
|
145
215
|
function mine(): Body {
|
|
146
216
|
const b = room.mine();
|
|
147
217
|
return b ?? ({ slot: -1, seat: room.mySeat(), name: net.name, bot: false, score: 0, x: me.x, y: me.y, hp: maxHpOf(myLevel()), maxHp: maxHpOf(myLevel()), level: myLevel(), face: me.face, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: 0, ty: 0 } as Body);
|
|
@@ -170,6 +240,7 @@ function strike(b: Body, now: number): void {
|
|
|
170
240
|
const xp = s.size === 3 ? 60 : 6 * s.size;
|
|
171
241
|
const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
|
|
172
242
|
b.score += xp;
|
|
243
|
+
if (s.size === 3) kingSlainAt = now;
|
|
173
244
|
tell(b, 'loot', { xp, gold });
|
|
174
245
|
// Everyone hears who slew it (a watcher on Auto cuts to them); the loot itself is the slayer's alone.
|
|
175
246
|
if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
|
|
@@ -193,7 +264,9 @@ function stepHost(dt: number): void {
|
|
|
193
264
|
continue;
|
|
194
265
|
}
|
|
195
266
|
let wants = false;
|
|
196
|
-
|
|
267
|
+
const guide = b.bot && b.agent?.role === 'guide' ? agents.goalOf(b.slot) : null;
|
|
268
|
+
if (guide) wants = stepGuide(b, guide, room.skillOf(b), dt, now);
|
|
269
|
+
else if (b.bot) {
|
|
197
270
|
// Bots are company, not carries: they hunt only what is near them, and strike slower than a person. At the
|
|
198
271
|
// room's dial (section 17): they notice a slime `reactionMs` late, hunt farther afield the more they lean to
|
|
199
272
|
// the front (positioning), aim a little off (aimNoise), and strike sooner the more aggressive they are.
|
|
@@ -229,9 +302,105 @@ function stepHost(dt: number): void {
|
|
|
229
302
|
if (wants && live && now - b.atkAt > STRIKE_MS) strike(b, now);
|
|
230
303
|
}
|
|
231
304
|
if (live) stepSlimes(dt, now);
|
|
305
|
+
// Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
|
|
306
|
+
net.state('quests', openQuests());
|
|
232
307
|
room.update();
|
|
233
308
|
}
|
|
234
309
|
|
|
310
|
+
/* ------------------------------------------------------------------ the guides' hands */
|
|
311
|
+
const bodyOfSeat = (seat: unknown): Body | null => { for (const o of room.bodies.values()) if (!o.bot && o.seat === seat) return o; return null; };
|
|
312
|
+
const people = (): Body[] => [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null && !(o.flags & DOWN));
|
|
313
|
+
const huntFrom = new Map<number, number>();
|
|
314
|
+
const arrivedAt = new Map<number, number>();
|
|
315
|
+
/**
|
|
316
|
+
* A guide's body, every host frame, doing its goal at the dial (`s`): where it stands (positioning), how soon it
|
|
317
|
+
* notices (reactionMs), how far off it strikes (aimNoise), how eagerly (aggression). Returns whether it strikes now.
|
|
318
|
+
*/
|
|
319
|
+
function stepGuide(b: Body, g: Goal, s: Skill, dt: number, now: number): boolean {
|
|
320
|
+
let tx = b.x; let ty = b.y; let hunt: Slime | null = null;
|
|
321
|
+
const nearTo = (x: number, y: number, r: number): Slime | null => { const n = nearestSlime(x, y); return n && Math.hypot(n.x - x, n.y - y) < r ? n : null; };
|
|
322
|
+
switch (g.goal) {
|
|
323
|
+
case 'follow': {
|
|
324
|
+
const p = bodyOfSeat(g.args['seat']);
|
|
325
|
+
if (!p) { agents.done(b.slot, false); break; }
|
|
326
|
+
// A step behind the hero (closer at a higher dial), each guide to its own side, and the slime that comes for them.
|
|
327
|
+
const back = standoff(s, 70, 150);
|
|
328
|
+
const side = (b.slot % 2 ? 1 : -1) * (0.5 + (b.slot % 3) * 0.25);
|
|
329
|
+
tx = p.x - Math.cos(p.face + side) * back; ty = p.y - Math.sin(p.face + side) * back;
|
|
330
|
+
hunt = nearTo(p.x, p.y, 200);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case 'quest': {
|
|
334
|
+
const quest = g.args['quest'];
|
|
335
|
+
if (quest === 'king-slime') {
|
|
336
|
+
if (kingSlainAt > g.at) { agents.done(b.slot, true); break; }
|
|
337
|
+
hunt = slimes.find((o) => o.size === 3) ?? null;
|
|
338
|
+
if (!hunt) { tx = PLACES['king']!.x; ty = PLACES['king']!.y + 70; }
|
|
339
|
+
} else if (quest === 'big-slime') {
|
|
340
|
+
hunt = slimes.filter((o) => o.size === 2).sort((x, y) => Math.hypot(x.x - b.x, x.y - b.y) - Math.hypot(y.x - b.x, y.y - b.y))[0] ?? null;
|
|
341
|
+
if (!hunt) agents.done(b.slot, true);
|
|
342
|
+
} else {
|
|
343
|
+
// A slime hunt: the slimes near the party, three of them.
|
|
344
|
+
if (!huntFrom.has(b.slot) || huntFrom.get(b.slot)! > b.score) huntFrom.set(b.slot, b.score);
|
|
345
|
+
if (b.score - huntFrom.get(b.slot)! >= 18) { huntFrom.delete(b.slot); agents.done(b.slot, true); break; }
|
|
346
|
+
const ps = people();
|
|
347
|
+
const cx = ps.length ? ps.reduce((a, o) => a + o.x, 0) / ps.length : b.x;
|
|
348
|
+
const cy = ps.length ? ps.reduce((a, o) => a + o.y, 0) / ps.length : b.y;
|
|
349
|
+
hunt = nearTo(cx, cy, 520) ?? nearestSlime(b.x, b.y);
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case 'lead': {
|
|
354
|
+
const at = PLACES[String(g.args['place'])] ?? PLACES['camp']!;
|
|
355
|
+
tx = at.x; ty = at.y;
|
|
356
|
+
// Done when it is there and a hero came along; given up when nobody came within 20 s of it arriving.
|
|
357
|
+
if (Math.hypot(at.x - b.x, at.y - b.y) < 60) {
|
|
358
|
+
if (!arrivedAt.has(b.slot)) arrivedAt.set(b.slot, now);
|
|
359
|
+
if (people().some((o) => Math.hypot(o.x - at.x, o.y - at.y) < 280)) { arrivedAt.delete(b.slot); agents.done(b.slot, true); }
|
|
360
|
+
else if (now - arrivedAt.get(b.slot)! > 20_000) { arrivedAt.delete(b.slot); agents.done(b.slot, false); }
|
|
361
|
+
} else arrivedAt.delete(b.slot);
|
|
362
|
+
hunt = nearTo(b.x, b.y, 110);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case 'guard': {
|
|
366
|
+
const ps = people().filter((o) => Math.hypot(o.x - b.x, o.y - b.y) < 700);
|
|
367
|
+
const cx = ps.length ? ps.reduce((a, o) => a + o.x, 0) / ps.length : b.x;
|
|
368
|
+
const cy = ps.length ? ps.reduce((a, o) => a + o.y, 0) / ps.length : b.y;
|
|
369
|
+
tx = cx + 50; ty = cy + 40;
|
|
370
|
+
hunt = nearTo(cx, cy, standoff(s, 260, 160));
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case 'back': {
|
|
374
|
+
const at = PLACES['camp']!;
|
|
375
|
+
tx = at.x; ty = at.y;
|
|
376
|
+
if (Math.hypot(at.x - b.x, at.y - b.y) < 50) agents.done(b.slot, true);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
default: break;
|
|
380
|
+
}
|
|
381
|
+
// Two guides with the same goal never stand in one spot: each keeps its own place around it.
|
|
382
|
+
if (!hunt && g.goal !== 'follow') { const k = b.slot * 2.4; tx += Math.cos(k) * 46; ty += Math.sin(k) * 46; }
|
|
383
|
+
// Reaction time: a guide re-aims only every reactionMs, a little off at a low dial.
|
|
384
|
+
if (hunt) {
|
|
385
|
+
if (!b.seenAt || now - b.seenAt >= s.reactionMs) { b.seenAt = now; b.ax = hunt.x + jitter(s, 40); b.ay = hunt.y + jitter(s, 40); }
|
|
386
|
+
tx = b.ax ?? hunt.x; ty = b.ay ?? hunt.y;
|
|
387
|
+
}
|
|
388
|
+
const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
|
|
389
|
+
if (dist > (hunt ? 50 : 24)) { const v = Math.min(SPEED * 0.85, dist / Math.max(dt, 1e-3)); b.x = clamp(b.x + (dx / dist) * v * dt, R, W - R); b.y = clamp(b.y + (dy / dist) * v * dt, R, H - R); b.face = Math.atan2(dy, dx); }
|
|
390
|
+
return Boolean(hunt && Math.hypot(hunt.x - b.x, hunt.y - b.y) < STRIKE_RANGE + hunt.size * 10 && now - b.atkAt > 1400 - 900 * s.aggression);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The quests open now: a slime hunt always, the King Slime when it is out or close to coming, a big slime when one is. */
|
|
394
|
+
function openQuests(): string[] {
|
|
395
|
+
const now = net.now();
|
|
396
|
+
return ['slime-hunt', ...(slimes.some((o) => o.size === 3) || kingAt - now < 30_000 ? ['king-slime'] : []), ...(slimes.some((o) => o.size === 2) ? ['big-slime'] : [])];
|
|
397
|
+
}
|
|
398
|
+
const zoneOf = (x: number, y: number): string => {
|
|
399
|
+
let best = 'vale'; let bd = 260;
|
|
400
|
+
for (const [id, p] of Object.entries(PLACES)) { const d = Math.hypot(p.x - x, p.y - y); if (d < bd) { bd = d; best = id; } }
|
|
401
|
+
return best;
|
|
402
|
+
};
|
|
403
|
+
|
|
235
404
|
function nearestSlime(x: number, y: number): Slime | null {
|
|
236
405
|
let best: Slime | null = null; let bd = Infinity;
|
|
237
406
|
for (const s of slimes) { const d = Math.hypot(s.x - x, s.y - y); if (d < bd) { bd = d; best = s; } }
|
|
@@ -332,7 +501,8 @@ function draw(t: number): void {
|
|
|
332
501
|
const isDown = Boolean(b.flags & DOWN);
|
|
333
502
|
ctx.globalAlpha = isDown ? 0.35 : 1;
|
|
334
503
|
if ((b.flags & STRIKING) || (self && performance.now() - me.flashAt < 160)) { ctx.strokeStyle = 'rgba(255,207,110,.7)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(x, y, STRIKE_RANGE * 0.8, 0, Math.PI * 2); ctx.stroke(); }
|
|
335
|
-
|
|
504
|
+
const isGuide = (net.slots ?? []).some((x) => x.slot === b.slot && x.agent?.role === 'guide');
|
|
505
|
+
ctx.fillStyle = self ? '#ffcf6e' : isGuide ? '#7fd8c8' : b.bot ? '#8aa0b8' : '#f3ead2';
|
|
336
506
|
ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill();
|
|
337
507
|
ctx.fillStyle = '#0b0f0a'; ctx.beginPath(); ctx.arc(x + Math.cos(b.face) * 10, y + Math.sin(b.face) * 10, 5, 0, Math.PI * 2); ctx.fill();
|
|
338
508
|
ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(x - 24, y - R - 12, 48, 5);
|
|
@@ -340,12 +510,32 @@ function draw(t: number): void {
|
|
|
340
510
|
ctx.globalAlpha = 1;
|
|
341
511
|
ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
|
|
342
512
|
// An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
|
|
343
|
-
ctx.fillText(`${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''} · ${b.level}`, x, y - R - 18);
|
|
513
|
+
ctx.fillText(`${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''}${isGuide ? ' · guide' : ''} · ${b.level}`, x, y - R - 18);
|
|
514
|
+
const said = bubbles.get(b.slot);
|
|
515
|
+
if (said && said.until > performance.now()) bubble(x, y - R - 44, said.text);
|
|
344
516
|
}
|
|
345
517
|
ctx.restore();
|
|
346
518
|
hud(t);
|
|
347
519
|
}
|
|
348
520
|
|
|
521
|
+
/** A speech bubble: up to three short lines, above a guide. */
|
|
522
|
+
function bubble(x: number, y: number, text: string): void {
|
|
523
|
+
ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
|
|
524
|
+
const words = text.split(' ');
|
|
525
|
+
const lines: string[] = [];
|
|
526
|
+
let line = '';
|
|
527
|
+
for (const w of words) { if ((line + ' ' + w).trim().length > 26 && line) { lines.push(line); line = w; } else line = (line + ' ' + w).trim(); }
|
|
528
|
+
if (line) lines.push(line);
|
|
529
|
+
const shown = lines.slice(0, 3);
|
|
530
|
+
const wid = Math.max(...shown.map((l) => ctx.measureText(l).width)) + 22;
|
|
531
|
+
const hgt = shown.length * 20 + 12;
|
|
532
|
+
ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.strokeStyle = 'rgba(127,216,200,.75)'; ctx.lineWidth = 2;
|
|
533
|
+
ctx.beginPath(); ctx.roundRect(x - wid / 2, y - hgt, wid, hgt, 10); ctx.fill(); ctx.stroke();
|
|
534
|
+
ctx.beginPath(); ctx.moveTo(x - 7, y); ctx.lineTo(x + 7, y); ctx.lineTo(x, y + 9); ctx.closePath(); ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.fill();
|
|
535
|
+
ctx.fillStyle = '#e9fff9'; ctx.textAlign = 'center';
|
|
536
|
+
shown.forEach((l, i) => ctx.fillText(l, x, y - hgt + 22 + i * 20));
|
|
537
|
+
}
|
|
538
|
+
|
|
349
539
|
function hud(t: number): void {
|
|
350
540
|
const k = Math.max(0.8, Math.min(1.4, canvas.height / 900));
|
|
351
541
|
ctx.save(); ctx.scale(k, k);
|
|
@@ -446,6 +636,54 @@ saves.on('player', () => ui.paint());
|
|
|
446
636
|
// Time in the vale, a lifetime stat: added every 30 s while the page is open and visible.
|
|
447
637
|
setInterval(() => { if (hero && document.visibilityState === 'visible') void saves.stats.add({ seconds: 30 }); }, 30_000);
|
|
448
638
|
|
|
639
|
+
/* ------------------------------------------------------------------ asking a guide (buttons, never typing) */
|
|
640
|
+
/** The guide nearest my hero (within 340 px), and the asks I can make of it: drawn as buttons, four times a second. */
|
|
641
|
+
let asksFor = -1;
|
|
642
|
+
let asksSig = '';
|
|
643
|
+
function paintAsks(): void {
|
|
644
|
+
const panel = $('asks');
|
|
645
|
+
const mySeat = room.mySeat();
|
|
646
|
+
const here = me.has && mySeat !== null && !lookOnly && ui.idle();
|
|
647
|
+
let best: { slot: number; name: string; d: number } | null = null;
|
|
648
|
+
if (here) {
|
|
649
|
+
for (const b of room.view()) {
|
|
650
|
+
const sl = (net.slots ?? []).find((x) => x.slot === b.slot);
|
|
651
|
+
if (!sl?.agent || sl.agent.role !== 'guide') continue;
|
|
652
|
+
const d = Math.hypot(b.x - me.x, b.y - me.y);
|
|
653
|
+
if (d < 340 && (!best || d < best.d)) best = { slot: b.slot, name: b.name, d };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (!best) { panel.hidden = true; asksFor = -1; asksSig = ''; return; }
|
|
657
|
+
const quests = (net.stateOf<string[]>('quests') ?? (room.hosting ? openQuests() : ['slime-hunt'])).slice(0, 3);
|
|
658
|
+
// Help with each open quest, one place to be led to (not the one I am in), and "No thanks"; three on a phone.
|
|
659
|
+
const zone = zoneOf(me.x, me.y);
|
|
660
|
+
const all = agents.askButtons(best.slot, { quests });
|
|
661
|
+
const lead = all.filter((b) => b.k === 'lead_me' && b.args['place'] !== zone).slice(0, 1);
|
|
662
|
+
const order = [...all.filter((b) => b.k === 'ask_help'), ...lead, ...all.filter((b) => b.k === 'no_thanks')];
|
|
663
|
+
const buttons = order.slice(0, innerWidth < 540 ? 3 : 5);
|
|
664
|
+
const sig = `${best.slot}|${best.name}|${buttons.map((b) => b.text).join('|')}`;
|
|
665
|
+
panel.hidden = false;
|
|
666
|
+
if (sig === asksSig) return;
|
|
667
|
+
asksSig = sig;
|
|
668
|
+
asksFor = best.slot;
|
|
669
|
+
panel.dataset['slot'] = String(best.slot);
|
|
670
|
+
$('asks-who').textContent = `${best.name} · guide`;
|
|
671
|
+
const list = $('asks-list');
|
|
672
|
+
list.textContent = '';
|
|
673
|
+
for (const b of buttons) {
|
|
674
|
+
const btn = document.createElement('button');
|
|
675
|
+
btn.type = 'button';
|
|
676
|
+
btn.textContent = b.text;
|
|
677
|
+
btn.dataset['ask'] = b.k;
|
|
678
|
+
btn.addEventListener('click', () => {
|
|
679
|
+
if (asksFor < 0) return;
|
|
680
|
+
if (agents.ask(asksFor, b.k, b.args)) { logGuide({ ev: 'asked', slot: asksFor, k: b.k, args: b.args }); btn.classList.add('sent'); setTimeout(() => btn.classList.remove('sent'), 900); }
|
|
681
|
+
});
|
|
682
|
+
list.append(btn);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
setInterval(paintAsks, 250);
|
|
686
|
+
|
|
449
687
|
/* ------------------------------------------------------------------ loop */
|
|
450
688
|
let last = performance.now();
|
|
451
689
|
let frames = 0;
|
|
@@ -460,7 +698,16 @@ function frame(t: number): void {
|
|
|
460
698
|
}
|
|
461
699
|
|
|
462
700
|
// Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
|
|
463
|
-
net.expose({
|
|
701
|
+
net.expose({
|
|
702
|
+
scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })),
|
|
703
|
+
// The guides, for the e2e probe: each guide's goal now, and the log of goals, asks and lines (seats and ids only).
|
|
704
|
+
guides: () => ({
|
|
705
|
+
hosting: room.hosting, talking: agents.talking, stats: agents.stats(),
|
|
706
|
+
now: agents.guides().map((g) => { const v = room.view().find((b) => b.slot === g.slot); return { slot: g.slot, seat: g.agent?.seat ?? null, name: g.name, goal: agents.goalOf(g.slot), x: v ? Math.round(v.x) : null, y: v ? Math.round(v.y) : null }; }),
|
|
707
|
+
me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
|
|
708
|
+
log: guideLog.slice(),
|
|
709
|
+
}),
|
|
710
|
+
});
|
|
464
711
|
|
|
465
712
|
exposePort(net, {
|
|
466
713
|
view: 'top',
|
|
@@ -726,9 +726,12 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
|
|
|
726
726
|
const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);
|
|
727
727
|
ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;
|
|
728
728
|
ctx.textAlign = 'right';
|
|
729
|
+
// The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
|
|
730
|
+
// default): the scores start under that band, so the buttons never cover a score.
|
|
731
|
+
const board = top + 18 + 44;
|
|
729
732
|
rows.forEach((a, i) => {
|
|
730
733
|
ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
|
|
731
|
-
ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad,
|
|
734
|
+
ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));
|
|
732
735
|
});
|
|
733
736
|
// role badge
|
|
734
737
|
ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
|