@homie-rocks/studio 0.24.3 → 0.24.5
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 +51 -1
- package/README.md +21 -5
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +34 -10
- package/chat/CHAT.md +38 -5
- package/lib/agent-seat.mjs +60 -2
- package/lib/brain-probe.mjs +6 -5
- package/lib/build.mjs +6 -0
- package/lib/cloudflare.mjs +2 -0
- package/lib/doctor.mjs +14 -0
- package/lib/local-ai.mjs +66 -0
- package/lib/mcp-tools.mjs +10 -2
- package/lib/scaffold.mjs +17 -4
- package/lib/servers.mjs +31 -2
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +95 -17
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- package/site/SITE.md +4 -0
- package/starters/ember-vale/game.json +1 -0
- package/starters/ember-vale/src/main.ts +80 -6
- package/worker/agents.mjs +93 -47
- package/worker/brain.mjs +356 -5
- package/worker/chat-page.mjs +173 -28
- package/worker/chat.mjs +17 -7
- package/worker/index.mjs +57 -2
- package/worker/office.mjs +63 -3
- package/worker/room.mjs +48 -3
- package/worker/version.mjs +1 -1
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
|
|
44
44
|
* every lab call is a no-op.
|
|
45
45
|
*/
|
|
46
|
-
import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
46
|
+
import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type DecidePicks, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
47
47
|
import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
|
|
48
48
|
import vocabulary from '../agents.json';
|
|
49
49
|
// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
|
|
@@ -376,12 +376,66 @@ function stepHost(dt: number): void {
|
|
|
376
376
|
b.flags = now - b.strikeAt < T.flashMs ? STRIKING : 0;
|
|
377
377
|
if (wants && live && now - b.atkAt > T.strikeMs) strike(b, now);
|
|
378
378
|
}
|
|
379
|
-
if (live) stepSlimes(dt, now);
|
|
379
|
+
if (live) { stepDirector(now); stepSlimes(dt, now); }
|
|
380
380
|
// Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
|
|
381
381
|
net.state('quests', openQuests());
|
|
382
382
|
room.update();
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/* ------------------------------------------------------------------ the slimes' director (section 20, opt-in) */
|
|
386
|
+
/**
|
|
387
|
+
* Every 6 s of a live night the host asks the studio's decision model how the slimes should hunt (`net.decide`: Clef,
|
|
388
|
+
* three typed questions, never text): a tactic, whether a wave comes now, how hard to push. It runs only when game.json
|
|
389
|
+
* says "decide": true (the room answers "off" otherwise, and asks are spent from the studio's AI brains' day); without it,
|
|
390
|
+
* over budget or slow, the floor answers: chase, no wave, steady, which is how the vale always played. On a kids server
|
|
391
|
+
* the slimes never gang up on the most hurt hero and the vale never pushes past steady. The heroes' dial is untouched:
|
|
392
|
+
* this is the slimes' mind, not the bots'.
|
|
393
|
+
*/
|
|
394
|
+
type Tactic = 'chase' | 'surround' | 'weakest' | 'regroup';
|
|
395
|
+
const TACTICS: Record<Tactic, string> = {
|
|
396
|
+
chase: 'Rush the nearest hero',
|
|
397
|
+
surround: 'Spread out and close in on a hero from every side',
|
|
398
|
+
weakest: 'Gang up on the most hurt hero',
|
|
399
|
+
regroup: 'Fall back to the King Slime and gather before the next push',
|
|
400
|
+
};
|
|
401
|
+
/** How often a slime comes, by pressure (0 a breather .. 4 fierce). 1100 ms at 2 is the vale as it always was. */
|
|
402
|
+
const SPAWN_MS = [1900, 1450, 1100, 850, 700];
|
|
403
|
+
const DIRECTOR_MS = 6000;
|
|
404
|
+
const director = { tactic: 'chase' as Tactic, pressure: 2, waveLeft: 0, by: 'floor', at: 0, asking: false, log: [] as Record<string, unknown>[] };
|
|
405
|
+
const directorFloor = (): DecidePicks => ({ tactic: 'chase', wave: false, pressure: 2 });
|
|
406
|
+
function stepDirector(now: number): void {
|
|
407
|
+
if (!room.hosting || director.asking || now < director.at) return;
|
|
408
|
+
director.at = now + DIRECTOR_MS;
|
|
409
|
+
director.asking = true;
|
|
410
|
+
const kidsRoom = kids();
|
|
411
|
+
const king = slimes.find((o) => o.size === 3);
|
|
412
|
+
const heroes = [...room.bodies.values()].filter((b) => !b.bot && b.seat !== null);
|
|
413
|
+
const state = {
|
|
414
|
+
heroes: heroes.map((b) => ({ hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN), level: b.level, nearKing: king ? Math.round(Math.hypot(king.x - b.x, king.y - b.y)) : null })),
|
|
415
|
+
slimes: { count: slimes.length, big: slimes.filter((o) => o.size === 2).length, king: king ? Math.round((100 * king.hp) / king.maxHp) : null },
|
|
416
|
+
night: { secondsLeft: room.clock().secondsLeft }, now: { tactic: director.tactic, pressure: director.pressure },
|
|
417
|
+
...(kidsRoom ? { note: 'Young players: keep it gentle' } : {}),
|
|
418
|
+
};
|
|
419
|
+
const tactics = Object.fromEntries(Object.entries(TACTICS).filter(([k]) => !(kidsRoom && k === 'weakest')));
|
|
420
|
+
const asked = performance.now();
|
|
421
|
+
void net.decide(state, {
|
|
422
|
+
tactic: { type: 'choice', instructions: 'How should the slimes hunt the heroes for the next few seconds? A fun fight, not a massacre: hurt heroes deserve a breather.', criteria: tactics },
|
|
423
|
+
wave: { type: 'noul', instructions: 'Should a wave of extra slimes come now?', criteria: { true: 'The heroes are healthy and it has gone quiet', false: 'The heroes are hurt, down or few' } },
|
|
424
|
+
pressure: { type: 'score', instructions: 'How hard should the vale push the heroes right now?', criteria: ['A breather', 'Easy', 'Steady', 'Hard', 'Fierce'] },
|
|
425
|
+
}, { floor: directorFloor }).then((d) => {
|
|
426
|
+
director.asking = false;
|
|
427
|
+
const t = String(d.picks['tactic'] ?? 'chase') as Tactic;
|
|
428
|
+
director.tactic = t in tactics ? t : 'chase';
|
|
429
|
+
director.pressure = clamp(Math.round(Number(d.picks['pressure'] ?? 2)), 0, kidsRoom ? 2 : 4);
|
|
430
|
+
if (d.picks['wave'] === true && director.waveLeft === 0) director.waveLeft = 3;
|
|
431
|
+
director.by = d.by;
|
|
432
|
+
director.log.push({ at: Date.now(), by: d.by, why: d.why ?? null, ms: Math.round(performance.now() - asked), tactic: director.tactic, pressure: director.pressure, wave: d.picks['wave'] === true });
|
|
433
|
+
if (director.log.length > 40) director.log.shift();
|
|
434
|
+
// Every screen sees what the slimes are up to (only when a model decided; the floor is the vale as always).
|
|
435
|
+
net.state('slimes', d.by === 'floor' ? null : { tactic: director.tactic, pressure: director.pressure });
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
385
439
|
/* ------------------------------------------------------------------ the guides' hands */
|
|
386
440
|
const bodyOfSeat = (seat: unknown): Body | null => { for (const o of room.bodies.values()) if (!o.bot && o.seat === seat) return o; return null; };
|
|
387
441
|
const people = (): Body[] => [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null && !(o.flags & DOWN));
|
|
@@ -498,8 +552,11 @@ function stepSlimes(dt: number, now: number): void {
|
|
|
498
552
|
if (lab.stage === 'dummy') return; // the lab's training slime stands still and never bites
|
|
499
553
|
const bodies = [...room.bodies.values()].filter((b) => !(b.flags & DOWN));
|
|
500
554
|
const people = bodies.filter((b) => !b.bot).length;
|
|
501
|
-
|
|
502
|
-
|
|
555
|
+
// The director's pressure (0 a breather .. 4 fierce; 2 is how the vale always played) sets how fast and how many.
|
|
556
|
+
const cap = Math.max(4, 6 + people * 2 + (director.pressure - 2) * 2) + (director.waveLeft > 0 ? 3 : 0);
|
|
557
|
+
if ((now >= spawnAt || director.waveLeft > 0) && slimes.length < cap) {
|
|
558
|
+
spawnAt = now + SPAWN_MS[director.pressure]!;
|
|
559
|
+
if (director.waveLeft > 0) director.waveLeft -= 1;
|
|
503
560
|
const edge = Math.floor(Math.random() * 4);
|
|
504
561
|
const x = edge === 0 ? 30 : edge === 1 ? W - 30 : Math.random() * W;
|
|
505
562
|
const y = edge === 2 ? 30 : edge === 3 ? H - 30 : Math.random() * H;
|
|
@@ -517,10 +574,18 @@ function stepSlimes(dt: number, now: number): void {
|
|
|
517
574
|
if (struck.has(s.id)) continue;
|
|
518
575
|
let target: Body | null = null; let bd = Infinity;
|
|
519
576
|
for (const b of prey) { const d = Math.hypot(b.x - s.x, b.y - s.y); if (d < bd) { bd = d; target = b; } }
|
|
577
|
+
// The director's tactic (section 20; chase is how the vale always played): the most hurt hero, a ring round the
|
|
578
|
+
// nearest, or back to the King to gather (a hero close by is still bitten).
|
|
579
|
+
if (director.tactic === 'weakest' && prey.length > 1) target = prey.reduce((a, b) => (b.hp / b.maxHp < a.hp / a.maxHp ? b : a));
|
|
520
580
|
const speed = s.size === 3 ? 70 : 95 - s.size * 10;
|
|
521
581
|
if (target) {
|
|
522
|
-
|
|
523
|
-
|
|
582
|
+
let gx = target.x; let gy = target.y;
|
|
583
|
+
const king = slimes.find((o) => o.size === 3);
|
|
584
|
+
if (director.tactic === 'surround' && bd > 90) { const a = s.id * 2.39996; gx += Math.cos(a) * 80; gy += Math.sin(a) * 80; }
|
|
585
|
+
if (director.tactic === 'regroup' && king && s !== king && bd > 140) { gx = king.x + Math.cos(s.id) * 70; gy = king.y + Math.sin(s.id) * 70; }
|
|
586
|
+
const dx = gx - s.x; const dy = gy - s.y; const dist = Math.hypot(target.x - s.x, target.y - s.y) || 1;
|
|
587
|
+
const step = Math.hypot(dx, dy) || 1;
|
|
588
|
+
if (step > 4) { s.x += (dx / step) * speed * dt; s.y += (dy / step) * speed * dt; }
|
|
524
589
|
if (dist < R + 10 + s.size * 10 && now - s.hitAt > 800) {
|
|
525
590
|
s.hitAt = now;
|
|
526
591
|
target.hp -= 5 * s.size + (s.size === 3 ? 10 : 0);
|
|
@@ -919,6 +984,13 @@ function hud(t: number): void {
|
|
|
919
984
|
// Top centre; on a narrow screen where that would touch the panel, just under it.
|
|
920
985
|
if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
|
|
921
986
|
else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
|
|
987
|
+
// What the slimes are up to, when the studio's decision model chose it (section 20; nothing shows on the floor).
|
|
988
|
+
const mind = c.phase === 'live' ? net.stateOf<{ tactic: string; pressure: number }>('slimes') : null;
|
|
989
|
+
if (mind) {
|
|
990
|
+
const words: Record<string, string> = { chase: 'rushing you', surround: 'closing in from every side', weakest: 'hunting the hurt', regroup: 'gathering round the King' };
|
|
991
|
+
ctx.font = SMALL; ctx.fillStyle = '#e7a86b'; ctx.textAlign = 'center';
|
|
992
|
+
ctx.fillText(`Slimes: ${words[mind.tactic] ?? mind.tactic}`, cw / 2, x0 + pw + 10 > cw / 2 - 80 ? y0 + 62 + 40 : 50);
|
|
993
|
+
}
|
|
922
994
|
ctx.textAlign = 'center';
|
|
923
995
|
if (c.phase === 'over') {
|
|
924
996
|
const rows = room.results().slice(0, 5);
|
|
@@ -1144,6 +1216,8 @@ net.expose({
|
|
|
1144
1216
|
me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
|
|
1145
1217
|
log: guideLog.slice(),
|
|
1146
1218
|
}),
|
|
1219
|
+
// The slimes' director (section 20): its tactic now, and each decision (who answered, how long it took).
|
|
1220
|
+
director: () => ({ tactic: director.tactic, pressure: director.pressure, by: director.by, log: director.log.slice(), net: net.stats().decides ?? null }),
|
|
1147
1221
|
// Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
|
|
1148
1222
|
camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
|
|
1149
1223
|
labels: () => shownLabels.map((l) => ({ text: l.text, self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
|
package/worker/agents.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Nothing here imports the rest of the Worker (only brain.mjs, which is pure), so the relay (room.mjs) can use the
|
|
17
17
|
* dial and the names as they are.
|
|
18
18
|
*/
|
|
19
|
-
import { GUIDE_RULES, costOf, decisionSchema, ownerKey, parseDecision, promptFor, scripted, talks, workersAi } from './brain.mjs';
|
|
19
|
+
import { GUIDE_RULES, costOf, decisionSchema, localAiOf, localClef, ownerKey, parseDecision, promptFor, scripted, talks, workersAi } from './brain.mjs';
|
|
20
20
|
|
|
21
21
|
/** The skill dial (DESIGN D8; NETPLAY.md section 17). Level 3, Fair, is what the port kit's bots always were. */
|
|
22
22
|
export const SKILLS = Object.freeze([
|
|
@@ -271,6 +271,78 @@ export const BRAIN_BUDGET = Object.freeze({ neurons: 8000, usd: 1 });
|
|
|
271
271
|
export const HOUSE_PASS = 'house-';
|
|
272
272
|
const LOG_MAX = 50;
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The brain a server asked for, if it can run now (`{ run, engine }`), or why not (`{ why }`): its Workers AI binding
|
|
276
|
+
* or the owner's key is there and today's budget is not spent. With neither (`homie-studio dev`, or a key not set yet),
|
|
277
|
+
* the person's own Ollama runs it when dev found clef-flash there (`engine: 'local'`, free). `providers` stand in for
|
|
278
|
+
* the network in tests.
|
|
279
|
+
*/
|
|
280
|
+
export function brainFor(mode, env, { budget = null, providers = {} } = {}) {
|
|
281
|
+
const left = (k) => (budget ? budget.left(k) : Infinity);
|
|
282
|
+
const p = { 'workers-ai': workersAi, 'owner-key': ownerKey, local: localClef, ...providers };
|
|
283
|
+
const local = () => (localAiOf(env) ? { run: (args) => p.local(env, args), engine: 'local' } : null);
|
|
284
|
+
if (mode === 'workers-ai') {
|
|
285
|
+
if (!env?.AI) return local() ?? { why: 'no Workers AI binding (deploy once more to add it)' };
|
|
286
|
+
if (left('neurons') <= 0) return { why: 'today\'s Workers AI budget is spent (scripted until 00:00 UTC)' };
|
|
287
|
+
return { run: (args) => p['workers-ai'](env, args), engine: 'workers-ai' };
|
|
288
|
+
}
|
|
289
|
+
if (mode === 'owner-key') {
|
|
290
|
+
if (!env?.HOMIE_BRAIN_KEY) return local() ?? { why: 'no key yet (homie-studio agents brain key)' };
|
|
291
|
+
if (left('micros') <= 0) return { why: 'today\'s dollar cap is reached (scripted until 00:00 UTC)' };
|
|
292
|
+
return { run: (args) => p['owner-key'](env, args), engine: 'owner-key' };
|
|
293
|
+
}
|
|
294
|
+
return { why: 'scripted' };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* ONE DECISION, the way every brain makes it (the house guides on their alarm, the office's `agents try`): the brain
|
|
299
|
+
* asked (Clef's questions or a chat model's prompt), its answer checked by parseDecision, the fixed rule applied (a
|
|
300
|
+
* person's ask is answered the way agents.json says; a model that chose another goal is overruled), and the floor when
|
|
301
|
+
* nothing else decided.
|
|
302
|
+
*
|
|
303
|
+
* ctx: { view, me, goal, asks, party, speech, quiet, players, avoid, kids }. Returns { decision, provider, why, ms,
|
|
304
|
+
* cost, own (the brain's own pick, before any rule), p (Clef's probabilities), engine, model }.
|
|
305
|
+
*/
|
|
306
|
+
export async function think(vocab, { mode, brain, ctx, ms = BRAIN_CADENCE.timeoutMs, now = Date.now }) {
|
|
307
|
+
const { asks = [], view = null, players = null, avoid = [], quiet = false } = ctx;
|
|
308
|
+
const out = { decision: null, provider: 'script', why: brain?.why ?? null, ms: 0, cost: { neurons: 0, micros: 0 }, own: null, p: null, answers: null, usage: null, engine: null, model: null };
|
|
309
|
+
if (brain?.run) {
|
|
310
|
+
const t0 = now();
|
|
311
|
+
const prompt = promptFor(vocab, ctx);
|
|
312
|
+
const label = brain.engine === 'local' ? 'local' : mode;
|
|
313
|
+
try {
|
|
314
|
+
const r = await brain.run({ ...prompt, schema: decisionSchema(vocab), ms, vocab, ctx });
|
|
315
|
+
out.ms = now() - t0;
|
|
316
|
+
out.engine = r.engine ?? brain.engine ?? mode;
|
|
317
|
+
out.model = r.model ?? null;
|
|
318
|
+
out.p = r.p ?? null;
|
|
319
|
+
out.answers = r.answers ?? null;
|
|
320
|
+
out.usage = r.usage ?? null;
|
|
321
|
+
out.cost = costOf({ provider: out.engine === 'local' ? 'local' : mode, model: r.model, usage: r.usage, system: r.jsonMode === 'clef' ? '' : prompt.system, user: r.jsonMode === 'clef' ? '' : prompt.user, text: typeof r.out === 'string' ? r.out : JSON.stringify(r.out ?? '') });
|
|
322
|
+
out.cost.provider = label;
|
|
323
|
+
const parsed = r.out === null || r.out === undefined ? { ok: false, why: r.why ?? 'no answer' } : parseDecision(r.out, vocab, { view, players, avoid });
|
|
324
|
+
if (parsed.ok) { out.decision = parsed.decision; out.own = parsed.decision; out.provider = label; out.why = null; } else out.why = parsed.why;
|
|
325
|
+
// A fixed rule: a person's ask is answered the way agents.json says (its goal, with the values the person asked
|
|
326
|
+
// for). A model that chose another goal, or the asked goal with another value ("Help me with King Slime" answered
|
|
327
|
+
// with a slime hunt), is overruled by the floor's answer.
|
|
328
|
+
const ask = asks.at(-1);
|
|
329
|
+
const want = ask ? vocab.asks[ask.k]?.goal : undefined;
|
|
330
|
+
const other = out.decision && want && out.decision.goal === want
|
|
331
|
+
? Object.entries(ask.args ?? {}).find(([k, v]) => vocab.goals[want].args[k] && vocab.goals[want].args[k].kind !== 'player' && out.decision.args?.[k] !== v) : null;
|
|
332
|
+
if (out.decision && want && (out.decision.goal !== want || other)) {
|
|
333
|
+
const floor = scripted(vocab, { asks, view, players, avoid, quiet });
|
|
334
|
+
if (floor?.goal) { out.why = other ? `the model chose ${want} with ${other[0]} ${String(out.decision.args?.[other[0]]).slice(0, 24)}; the ask was answered as agents.json says` : `the model chose ${out.decision.goal}; the ask was answered as agents.json says`; out.decision = { ...floor, say: floor.say ?? null, sayArgs: floor.sayArgs ?? {} }; out.provider = `${label}+floor`; }
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
out.ms = now() - t0;
|
|
338
|
+
out.why = String(error?.message ?? error).slice(0, 120);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// The floor: no AI, an AI that failed, or an answer that was not a decision.
|
|
342
|
+
if (!out.decision) { out.decision = scripted(vocab, { asks, view, players, avoid, quiet }); out.provider = out.decision ? 'script' : 'none'; }
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
274
346
|
export class HouseAgents {
|
|
275
347
|
/**
|
|
276
348
|
* @param {object} o
|
|
@@ -286,7 +358,7 @@ export class HouseAgents {
|
|
|
286
358
|
this.now = now;
|
|
287
359
|
this.setAlarm = setAlarm;
|
|
288
360
|
this.budget = budget ?? { left: () => Infinity, spend: () => {} };
|
|
289
|
-
this.providers = { 'workers-ai': workersAi, 'owner-key': ownerKey, ...providers };
|
|
361
|
+
this.providers = { 'workers-ai': workersAi, 'owner-key': ownerKey, local: localClef, ...providers };
|
|
290
362
|
this.log = log;
|
|
291
363
|
this.agents = [];
|
|
292
364
|
this.decisions = [];
|
|
@@ -340,7 +412,7 @@ export class HouseAgents {
|
|
|
340
412
|
const label = names.length > n ? names[n] : `${names[n % names.length]} ${Math.floor(n / names.length) + 1}`;
|
|
341
413
|
const a = {
|
|
342
414
|
n, pass, name: aiName(label), seat: null, closed: false, view: null, viewAt: 0, zone: undefined, danger: undefined, goalState: null,
|
|
343
|
-
asks: [], answered: new Map(), carry: null, party: [], avoid: new Map(), calls: [], lastCallAt: -Infinity, dueAt: Infinity, dueWhy: null, sayAt: -Infinity, inFlight: false, provider: null,
|
|
415
|
+
asks: [], answered: new Map(), carry: null, party: [], said: [], seen: new Map(), avoid: new Map(), calls: [], lastCallAt: -Infinity, dueAt: Infinity, dueWhy: null, sayAt: -Infinity, inFlight: false, provider: null,
|
|
344
416
|
};
|
|
345
417
|
a.conn = {
|
|
346
418
|
loopback: true, ip: null, browser: null, via: null, player: null, qa: false, watch: false,
|
|
@@ -405,6 +477,8 @@ export class HouseAgents {
|
|
|
405
477
|
if (before && (JSON.stringify(d.zone ?? null) !== JSON.stringify(a.zone ?? null) || JSON.stringify(d.danger ?? null) !== JSON.stringify(a.danger ?? null))) this.due(a, now, d.danger ? 'danger' : 'zone');
|
|
406
478
|
a.zone = d.zone ?? null;
|
|
407
479
|
a.danger = d.danger ?? null;
|
|
480
|
+
// Who is new to this guide (first in its view, for 20 s): a reason to greet them once.
|
|
481
|
+
for (const p of Array.isArray(d.party) ? d.party.slice(0, 16) : []) if (Number.isInteger(p?.seat) && !a.seen.has(p.seat)) a.seen.set(p.seat, now);
|
|
408
482
|
// Asks the game lists in the view (the host's own player's asks reach a guide this way).
|
|
409
483
|
for (const x of Array.isArray(d.asks) ? d.asks.slice(0, 4) : []) if (x && typeof x.k === 'string' && Number.isInteger(x.from)) this.ask(a, { k: x.k, args: x.args ?? {}, from: x.from, at: Number(x.at) || now }, now);
|
|
410
484
|
if (a.dueAt === Infinity && this.near(a)) this.due(a, Math.max(now, (a.lastCallAt === -Infinity ? now : a.lastCallAt + BRAIN_CADENCE.everyMs)), 'every 12 s');
|
|
@@ -474,19 +548,9 @@ export class HouseAgents {
|
|
|
474
548
|
return due.length;
|
|
475
549
|
}
|
|
476
550
|
|
|
477
|
-
/** The brain this server asked for, if it can run now
|
|
551
|
+
/** The brain this server asked for, if it can run now (brainFor above). */
|
|
478
552
|
providerFor(mode) {
|
|
479
|
-
|
|
480
|
-
if (!this.env?.AI) return { why: 'no Workers AI binding (deploy once more to add it)' };
|
|
481
|
-
if (this.budget.left('neurons') <= 0) return { why: 'today\'s Workers AI budget is spent (scripted until 00:00 UTC)' };
|
|
482
|
-
return { run: (args) => this.providers['workers-ai'](this.env, args) };
|
|
483
|
-
}
|
|
484
|
-
if (mode === 'owner-key') {
|
|
485
|
-
if (!this.env?.HOMIE_BRAIN_KEY) return { why: 'no key yet (homie-studio agents brain key)' };
|
|
486
|
-
if (this.budget.left('micros') <= 0) return { why: 'today\'s dollar cap is reached (scripted until 00:00 UTC)' };
|
|
487
|
-
return { run: (args) => this.providers['owner-key'](this.env, args) };
|
|
488
|
-
}
|
|
489
|
-
return { why: 'scripted' };
|
|
553
|
+
return brainFor(mode, this.env, { budget: this.budget, providers: this.providers });
|
|
490
554
|
}
|
|
491
555
|
|
|
492
556
|
async decide(a) {
|
|
@@ -513,41 +577,20 @@ export class HouseAgents {
|
|
|
513
577
|
const mode = this.room.policy.brain;
|
|
514
578
|
const p = this.providerFor(mode);
|
|
515
579
|
this.why = p.why ?? null;
|
|
516
|
-
let decision = null;
|
|
517
|
-
let provider = 'script';
|
|
518
|
-
let why = p.why ?? null;
|
|
519
|
-
let ms = 0;
|
|
520
|
-
let cost = { neurons: 0, micros: 0 };
|
|
521
580
|
// Every decision keeps the 3 s pace (the relay takes one goal every 3 s); AI calls also count to 10 a minute.
|
|
522
581
|
a.lastCallAt = now;
|
|
523
|
-
if (p.run)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const parsed = parseDecision(r.out, vocab, { view: a.view, players, avoid });
|
|
535
|
-
if (parsed.ok) { decision = parsed.decision; provider = mode; why = null; } else why = parsed.why;
|
|
536
|
-
// A fixed rule: a person's ask is answered the way agents.json says (its goal). A model that chose something
|
|
537
|
-
// else is overruled by the floor's answer; its line stays when it is one of the ask's.
|
|
538
|
-
const ask = asks.at(-1);
|
|
539
|
-
const want = ask ? vocab.asks[ask.k]?.goal : undefined;
|
|
540
|
-
if (decision && want && decision.goal !== want) {
|
|
541
|
-
const floor = scripted(vocab, { asks, view: a.view, players, avoid, quiet });
|
|
542
|
-
if (floor?.goal) { why = `the model chose ${decision.goal}; the ask was answered as agents.json says`; decision = { ...floor, say: floor.say ?? null, sayArgs: floor.sayArgs ?? {} }; provider = `${mode}+floor`; }
|
|
543
|
-
}
|
|
544
|
-
} catch (error) {
|
|
545
|
-
ms = this.now() - now;
|
|
546
|
-
why = String(error?.message ?? error).slice(0, 120);
|
|
547
|
-
}
|
|
582
|
+
if (p.run) a.calls = [...a.calls.filter((t) => t > now - 60_000), now];
|
|
583
|
+
const said = a.said.filter((x) => now - x.at < 5 * 60_000).map((x) => ({ line: x.line, args: x.args, ago: now - x.at }));
|
|
584
|
+
const newHere = [...a.seen].filter(([seat, at]) => now - at < 20_000 && players.includes(seat)).map(([seat]) => seat);
|
|
585
|
+
const ctx = { view: a.view, me: { seat: a.seat }, goal: a.view?.goal ?? null, asks, party: a.party, speech: this.room.policy.speech, kids: this.room.policy.kids, quiet, players, avoid, said, newHere };
|
|
586
|
+
const t = await think(vocab, { mode, brain: p, ctx, ms: BRAIN_CADENCE.timeoutMs, now: this.now });
|
|
587
|
+
const { decision, provider, why, ms, cost } = t;
|
|
588
|
+
if (t.engine) {
|
|
589
|
+
this.calls += 1;
|
|
590
|
+
this.spent.neurons += cost.neurons;
|
|
591
|
+
this.spent.micros += cost.micros;
|
|
592
|
+
this.budget.spend({ neurons: cost.neurons, micros: cost.micros, provider: cost.provider ?? mode });
|
|
548
593
|
}
|
|
549
|
-
// The floor: no AI, an AI that failed, or an answer that was not a decision.
|
|
550
|
-
if (!decision) decision = scripted(vocab, { asks, view: a.view, players, avoid, quiet });
|
|
551
594
|
const did = decision ? this.act(a, decision, quiet) : { goal: null, say: null };
|
|
552
595
|
if (asks.length && decision) {
|
|
553
596
|
for (const x of asks) a.answered.set(`${x.k}|${x.from}|${JSON.stringify(x.args ?? {})}`, now + 6000);
|
|
@@ -559,6 +602,8 @@ export class HouseAgents {
|
|
|
559
602
|
at: now, seat: a.seat, guide: a.pass, provider: decision ? provider : 'none', ms, on: dueWhy,
|
|
560
603
|
goal: did.goal, args: did.goal ? decision.args : null, say: did.say, sayArgs: did.say ? decision.sayArgs : null,
|
|
561
604
|
...(why ? { why } : {}), ...(cost.neurons ? { neurons: Math.round(cost.neurons * 100) / 100 } : {}), ...(cost.micros ? { micros: cost.micros } : {}),
|
|
605
|
+
// What the brain itself chose when a rule overruled it (ids only), and how sure a decision model was.
|
|
606
|
+
...(t.own && /\+floor$/.test(provider) ? { own: { goal: t.own.goal, args: t.own.args } } : {}), ...(t.p ? { p: t.p } : {}), ...(t.model ? { model: t.model } : {}),
|
|
562
607
|
}].slice(-LOG_MAX);
|
|
563
608
|
a.inFlight = false;
|
|
564
609
|
if (this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
|
|
@@ -576,6 +621,7 @@ export class HouseAgents {
|
|
|
576
621
|
if (decision.say && !quiet) {
|
|
577
622
|
a.h.onMessage(JSON.stringify({ t: 'ev', k: `say:${decision.say}`, d: { args: decision.sayArgs ?? {} } }));
|
|
578
623
|
a.sayAt = this.now();
|
|
624
|
+
a.said = [...a.said, { line: decision.say, args: decision.sayArgs ?? {}, at: a.sayAt }].slice(-4);
|
|
579
625
|
out.say = decision.say;
|
|
580
626
|
}
|
|
581
627
|
return out;
|