@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.
@@ -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
- if (now >= spawnAt && slimes.length < 6 + people * 2) {
502
- spawnAt = now + 1100;
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
- const dx = target.x - s.x; const dy = target.y - s.y; const dist = Math.hypot(dx, dy) || 1;
523
- s.x += (dx / dist) * speed * dt; s.y += (dy / dist) * speed * dt;
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: its binding or key is there, and today's budget is not spent. */
551
+ /** The brain this server asked for, if it can run now (brainFor above). */
478
552
  providerFor(mode) {
479
- if (mode === 'workers-ai') {
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
- const prompt = promptFor(vocab, { view: a.view, me: { seat: a.seat }, goal: a.view?.goal ?? null, asks, party: a.party, speech: this.room.policy.speech, quiet });
525
- a.calls = [...a.calls.filter((t) => t > now - 60_000), now];
526
- try {
527
- const r = await p.run({ ...prompt, schema: decisionSchema(vocab), ms: BRAIN_CADENCE.timeoutMs });
528
- ms = this.now() - now;
529
- cost = costOf({ provider: mode, model: r.model, usage: r.usage, system: prompt.system, user: prompt.user, text: typeof r.out === 'string' ? r.out : JSON.stringify(r.out ?? '') });
530
- this.calls += 1;
531
- this.spent.neurons += cost.neurons;
532
- this.spent.micros += cost.micros;
533
- this.budget.spend({ ...cost, provider: mode });
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;