@a-t-h-i/bot-lobby 0.6.4 → 0.6.6

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.
Files changed (44) hide show
  1. package/README.md +150 -12
  2. package/package.json +12 -3
  3. package/prompts/backend.md +3 -1
  4. package/prompts/designer.md +24 -1
  5. package/prompts/global.md +18 -0
  6. package/prompts/master.md +122 -20
  7. package/prompts/quickfix.md +16 -3
  8. package/prompts/worker.md +7 -2
  9. package/src/agents/backend.ts +2 -2
  10. package/src/agents/designer.ts +2 -2
  11. package/src/ask/dialog.ts +11 -1
  12. package/src/classifier/triage.ts +24 -7
  13. package/src/index.ts +4 -1
  14. package/src/lobby/feed.ts +7 -2
  15. package/src/lobby/keys.ts +0 -1
  16. package/src/lobby/quickfix.ts +29 -3
  17. package/src/lobby/runtime.ts +29 -26
  18. package/src/lobby/tabs/home.ts +6 -42
  19. package/src/lobby/tabs/quickfix.ts +1 -1
  20. package/src/lobby/tabs/tasks.ts +3 -2
  21. package/src/lobby/view.ts +18 -23
  22. package/src/master/decisions.ts +10 -2
  23. package/src/pi/activity.ts +1 -33
  24. package/src/pi/commands.ts +22 -11
  25. package/src/pi/events.ts +3 -17
  26. package/src/pi/plan-checklist.ts +305 -0
  27. package/src/pi/route.ts +180 -0
  28. package/src/pi/run-summary.ts +12 -3
  29. package/src/pi/settings-ui.ts +2 -2
  30. package/src/pi/start-task.ts +51 -5
  31. package/src/pi/tools.ts +13 -7
  32. package/src/pi/ui.ts +18 -284
  33. package/src/roles/worker.ts +11 -3
  34. package/src/schemas/configuration.ts +19 -6
  35. package/src/schemas/task.ts +31 -0
  36. package/src/workflow/brief.ts +59 -0
  37. package/src/workflow/track.ts +436 -0
  38. package/src/workflow/workflow.ts +151 -10
  39. package/src/pi/expressions.ts +0 -169
  40. package/src/pi/kaomoji.ts +0 -227
  41. package/src/pi/mascot-art.ts +0 -359
  42. package/src/pi/zen-large.ts +0 -699
  43. package/src/pi/zen-metrics.ts +0 -130
  44. package/src/pi/zen.ts +0 -659
@@ -49,6 +49,7 @@ import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecis
49
49
  import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
50
50
  import { tail, truncate } from "../text.ts";
51
51
  import { isAutoMode } from "../state/auto.ts";
52
+ import { briefRejection } from "./brief.ts";
52
53
  import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApproval } from "./approvals.ts";
53
54
  import { pingApproval } from "../pi/notify.ts";
54
55
  import { describeRun, runLogEntry } from "../pi/run-summary.ts";
@@ -59,6 +60,7 @@ import type { EffortRouter } from "../classifier/effort.ts";
59
60
  import { answerClarify } from "../classifier/triage.ts";
60
61
  import { appendMetrics, metricFromRun } from "../state/metrics.ts";
61
62
  import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
63
+ import { fastNext, onFastTrack, parseRoster, qaRequired, qaStillDue, qaTookPart, rosterWords, trackSummary } from "./track.ts";
62
64
 
63
65
  export const ORCHESTRATE_ACTIONS = [
64
66
  "clarify",
@@ -76,6 +78,7 @@ export const ORCHESTRATE_ACTIONS = [
76
78
  "resume",
77
79
  "decide",
78
80
  "budget",
81
+ "track",
79
82
  "status",
80
83
  "cancel",
81
84
  ] as const;
@@ -112,6 +115,10 @@ export interface OrchestrateParams {
112
115
  note?: string;
113
116
  reason?: string;
114
117
  text?: string;
118
+ /** track: the path the task takes. */
119
+ track?: "fast" | "full";
120
+ /** track: who takes part (designer, backend, qa, researcher). */
121
+ roster?: string[];
115
122
  }
116
123
 
117
124
  export interface WorkflowDeps {
@@ -204,6 +211,7 @@ export function describeTask(task: Task): string {
204
211
  `${task.id} — state: ${task.state}${task.paused ? " (paused)" : ""}`,
205
212
  `Title: ${task.title}`,
206
213
  `Request: ${truncate(taskRequest(task), 200)}`,
214
+ task.track ? trackSummary(task.track) : "",
207
215
  task.domains.length > 0 ? `Domains: ${task.domains.join(", ")}` : "",
208
216
  `Review iterations: ${Object.entries(task.reviewIterations).map(([d, n]) => `${d}=${n}`).join(", ")}`,
209
217
  pendingApprovals(task).length > 0
@@ -512,10 +520,12 @@ function handlePlan(task: Task, params: OrchestrateParams, deps: WorkflowDeps):
512
520
  requireState(task, ["planning", ...AMEND_PLAN_STATES]);
513
521
  const plan = params.plan?.trim();
514
522
  if (!plan) throw new Error("plan requires the plan text");
515
- const missing = validatePlan(plan);
523
+ // The fast track keeps a short plan: the full plan's required areas are the full workflow's.
524
+ const missing = onFastTrack(task) ? [] : validatePlan(plan);
516
525
  if (missing.length > 0) throw new Error(`plan is missing: ${missing.join(", ")}`);
517
526
  const amending = AMEND_PLAN_STATES.includes(task.state);
518
527
  task.plan = plan;
528
+ if (task.track?.autoPlan) delete task.track.autoPlan;
519
529
  writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "plan.md"), plan);
520
530
  const addressed = addressComments(task, deps);
521
531
  if (amending) {
@@ -570,7 +580,9 @@ function recordAdvisoryPushbacks(task: Task, entries: Array<{ pushback?: Pushbac
570
580
  }
571
581
  }
572
582
 
573
- function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval): string {
583
+ const FULL_NEXT = "Next: inspect the diff, then run action=qa once this domain's work is complete.";
584
+
585
+ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval, next = FULL_NEXT): string {
574
586
  const { result, run, issues } = outcome;
575
587
  const objection = result.pushback;
576
588
  return [
@@ -593,7 +605,7 @@ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?:
593
605
  pushback && objection
594
606
  ? `Pushback recorded (${pushback.id}): ${truncate(objection.reason, 240)}. Resolve with action=resolve_approval before re-delegating ${result.domain}.`
595
607
  : "",
596
- "Next: inspect the diff, then run action=qa once this domain's work is complete.",
608
+ next,
597
609
  ]
598
610
  .filter((line) => line.length > 0)
599
611
  .join("\n");
@@ -835,17 +847,46 @@ function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutc
835
847
  const pushback = recordPushback(task, outcome);
836
848
  task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
837
849
  updateScratchpad(deps, task, outcome);
838
- const spent = timeReport(outcome);
839
- return spent ? `${workerReport(outcome, approvals, pushback)}\n${spent}` : workerReport(outcome, approvals, pushback);
850
+ const grown = qaJoinsGrownFastTask(task, outcome);
851
+ const report = workerReport(outcome, approvals, pushback, onFastTrack(task) ? fastNext(task) : FULL_NEXT);
852
+ return [report, grown, timeReport(outcome)].filter(Boolean).join("\n");
840
853
  }
841
854
 
855
+ /**
856
+ * A fast task whose worker asks for a new dependency or an architecture
857
+ * change is not the small change it read as: QA joins, so it is checked
858
+ * before it completes. Returns the line for the oracle, or "".
859
+ */
860
+ function qaJoinsGrownFastTask(task: Task, outcome: WorkerOutcome): string {
861
+ const track = task.track;
862
+ if (!track || track.path !== "fast" || track.roster.includes("qa")) return "";
863
+ const asks = [...outcome.result.dependencyNeeds, ...outcome.result.architectureChanges];
864
+ if (asks.length === 0) return "";
865
+ track.roster = parseRoster([...track.roster, "qa"]);
866
+ track.reasons = [...track.reasons, `tests: ${AGENT_LABELS[outcome.result.domain]} asked for a dependency or an architecture change`];
867
+ recordDecision(task, `QA joined the fast track: ${AGENT_LABELS[outcome.result.domain]} asked for ${truncate(asks.join("; "), 200)}.`);
868
+ return "QA now takes part: a fast-track change that needs a new dependency or an architecture change is checked before it completes.";
869
+ }
870
+
871
+ /** States a fast task starts its work from: before anything is planned, and never while the user decides on a proposal. */
872
+ const FAST_START_STATES: readonly TaskState[] = ["created", "clarifying", "scouting", "synthesizing"];
873
+
842
874
  async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
843
- requireState(task, ["planning", "implementing", "reviewing"]);
875
+ const starting = onFastTrack(task) && FAST_START_STATES.includes(task.state);
876
+ if (!starting && FAST_START_STATES.includes(task.state)) {
877
+ throw new Error(`action not allowed in state "${task.state}": on the full workflow the user approves a proposal first (a small, clear change can take the fast track: action=track track=fast)`);
878
+ }
879
+ if (!starting) requireState(task, ["planning", "implementing", "reviewing"]);
844
880
  const assignments = parseAssignments(params);
881
+ const short = !deps.config.workflow.briefCheck ? [] : assignments.map((entry) => briefRejection(task.id, entry.domain, entry.instruction)).filter(Boolean);
882
+ if (short.length > 0) throw new Error(short.join("\n"));
845
883
  for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
846
884
  for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
847
885
  // Under a time budget every step is given its share before any starts; a spent budget starts none.
848
886
  const times = workerTimes(task, deps, assignments);
887
+ if (task.track && onFastTrack(task)) task.track.roster = parseRoster([...task.track.roster, ...assignments.map((entry) => entry.domain)]);
888
+ if (starting) startFast(task);
889
+ if (task.track?.autoPlan) addFastSteps(task, deps, assignments);
849
890
  if (task.state !== "implementing") transition(task, "implementing");
850
891
  await takeBaseline(task, deps);
851
892
  let report: string;
@@ -863,6 +904,61 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
863
904
  return note ? `${report}\n\n${note}` : report;
864
905
  }
865
906
 
907
+ /**
908
+ * The fast track's start: the request is small and clear, so it goes from
909
+ * shaping straight to planning without a proposal round (the engine walks the
910
+ * same states, so every transition stays legal), and the engine keeps a short
911
+ * plan whose steps are the delegations themselves.
912
+ */
913
+ function startFast(task: Task): void {
914
+ const walk: Partial<Record<TaskState, TaskState[]>> = {
915
+ created: ["clarifying", "awaiting_approval", "planning"],
916
+ clarifying: ["awaiting_approval", "planning"],
917
+ scouting: ["synthesizing", "awaiting_approval", "planning"],
918
+ synthesizing: ["awaiting_approval", "planning"],
919
+ };
920
+ for (const state of walk[task.state] ?? []) transition(task, state);
921
+ const track = task.track!;
922
+ if (!task.plan) {
923
+ task.plan = [
924
+ "## Objective",
925
+ oneLine(taskRequest(task), 600),
926
+ "",
927
+ "## Track",
928
+ `Fast track (${track.size}): ${rosterWords(track.roster)}. No scouts, proposal round or plan review; ${qaRequired(task) ? "QA takes part before completion (its tests as the last step, or the QA gate)" : "no QA gate, as nothing here needs tests"}.`,
929
+ "",
930
+ "## Steps",
931
+ ].join("\n");
932
+ track.autoPlan = true;
933
+ }
934
+ recordDecision(task, `Fast track: started without a proposal round (${track.size}; ${rosterWords(track.roster)}).`);
935
+ }
936
+
937
+ /** `Step 3: …`, `Steps 2-4 — …` at the start of a delegation. */
938
+ const STEP_PREFIX = /^\s*(?:\*\*)?steps?\s*#?\s*(\d+)(?:\s*(?:-|\u2013|\u2014|to|and|&)\s*#?\s*(\d+))?(?:\*\*)?\s*[:.)\u2013\u2014-]?\s*/i;
939
+
940
+ /**
941
+ * The fast track's plan grows with its delegations: a delegation that does
942
+ * not name a step already in the plan adds one, so the lobby's checklist
943
+ * follows the work without a plan document.
944
+ */
945
+ function addFastSteps(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): void {
946
+ const plan = task.plan ?? "";
947
+ const lines = plan.split("\n");
948
+ const heading = lines.findIndex((line) => /^##\s+steps\s*$/i.test(line));
949
+ let count = heading < 0 ? 0 : lines.slice(heading + 1).filter((line) => /^\d+\.\s/.test(line)).length;
950
+ const added: string[] = [];
951
+ for (const { domain, instruction } of assignments) {
952
+ const named = STEP_PREFIX.exec(instruction);
953
+ if (named && Number(named[2] ?? named[1]) <= count) continue;
954
+ count += 1;
955
+ added.push(`${count}. ${AGENT_LABELS[domain]}: ${oneLine(instruction.replace(STEP_PREFIX, ""), 160) || "its part of the request"}`);
956
+ }
957
+ if (added.length === 0) return;
958
+ task.plan = `${plan.trimEnd()}\n${added.join("\n")}`;
959
+ writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "plan.md"), task.plan);
960
+ }
961
+
866
962
  /**
867
963
  * Several domains at once. The workers share one file desk: each claims a file
868
964
  * before editing it, queues for a busy one, and hands it over with a note; a
@@ -1159,9 +1255,10 @@ function handleCompact(task: Task, params: OrchestrateParams, deps: WorkflowDeps
1159
1255
 
1160
1256
  /** §63: record history, drop scratchpads, then mark the task completed. */
1161
1257
  async function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
1162
- requireState(task, ["reviewing", "blocked"]);
1163
- // Without a QA pass only the user can let the task finish: they are asked, never overruled.
1164
- if (task.qaVerdict !== "pass" && !task.qaWaiver) await offerAcceptance(task, deps);
1258
+ // The fast track completes straight from its work; the full workflow from review.
1259
+ requireState(task, onFastTrack(task) ? ["implementing", "reviewing", "blocked"] : ["reviewing", "blocked"]);
1260
+ // Without QA's part (where the track asks for it) only the user can let the task finish: they are asked, never overruled.
1261
+ if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) await offerAcceptance(task, deps);
1165
1262
  if (task.state === "blocked") throw new Error("cannot complete: the task is blocked, and the user did not accept its work as it is");
1166
1263
  const blockers = completionBlockers(task, pendingApprovals(task).length);
1167
1264
  if (blockers.length > 0) throw new Error(`cannot complete: ${blockers.join("; ")}`);
@@ -1170,6 +1267,7 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1170
1267
  recordCompletion(deps, task, summary);
1171
1268
  removeTaskScratchpads(deps.root, deps.configDir, task.id);
1172
1269
  task.blockers = [];
1270
+ if (task.state === "implementing") transition(task, "reviewing");
1173
1271
  transition(task, "completed");
1174
1272
  const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
1175
1273
  const advice =
@@ -1227,6 +1325,48 @@ function handleResume(task: Task, params: OrchestrateParams): string {
1227
1325
  return "Task resumed. Continue with action=implement.";
1228
1326
  }
1229
1327
 
1328
+ /**
1329
+ * `action=track`: with neither `track` nor `roster`, the task's track; with
1330
+ * them, the oracle corrects the engine's read of the request. The fast track
1331
+ * is taken only before the work is planned, never against the user's --full
1332
+ * or the settings; once work is under way a track only gets stricter (the
1333
+ * full workflow, more members, QA never dropped).
1334
+ */
1335
+ function handleTrack(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
1336
+ const current = task.track;
1337
+ if (!params.track && !params.roster) return current ? trackSummary(current) : "This task began before tracks: it takes the full workflow.";
1338
+ const reason = oneLine(params.reason?.trim() || params.text?.trim() || "", 240);
1339
+ if (!reason) throw new Error("track requires reason: why the task takes that path, or needs those members");
1340
+ const path = params.track ?? current?.path ?? "full";
1341
+ const shaping = FAST_START_STATES.includes(task.state);
1342
+ if (path === "fast" && current?.path !== "fast") {
1343
+ if (!deps.config.workflow.fastTrack) throw new Error("the fast track is off in settings (workflow.fastTrack): this task takes the full workflow");
1344
+ if (current?.userChoice === "full") throw new Error("the user asked for the full workflow on this task (--full)");
1345
+ if (!shaping) throw new Error(`a task takes the fast track before its work is planned; this one is ${task.state}`);
1346
+ }
1347
+ const asked = params.roster ? parseRoster(params.roster) : [...(current?.roster ?? [])];
1348
+ // The full workflow always ends with the QA gate.
1349
+ const roster = path === "full" ? parseRoster([...asked, "qa"]) : asked;
1350
+ if (!shaping && current?.roster.includes("qa") && !roster.includes("qa")) throw new Error("QA stays on the roster once the work is under way");
1351
+ const grew = current?.path === "fast" && path === "full";
1352
+ task.track = {
1353
+ path,
1354
+ size: current?.size ?? "small",
1355
+ roster,
1356
+ reasons: [`oracle: ${reason}`, ...(current?.reasons ?? [])].slice(0, 8),
1357
+ source: "oracle",
1358
+ ...(current?.userChoice ? { userChoice: current.userChoice } : {}),
1359
+ ...(current?.autoPlan ? { autoPlan: true } : {}),
1360
+ at: new Date().toISOString(),
1361
+ };
1362
+ recordDecision(task, `Track: ${path === "fast" ? "fast track" : "full workflow"}; ${rosterWords(roster)} — ${reason}`);
1363
+ if (path === "fast") {
1364
+ return `Fast track: ${rosterWords(roster)}. Delegate straight away with action=implement (open each task with "Step N:"); no scouts, proposal or plan. ${qaRequired(task) ? "QA takes part before completion: its tests as the last step, or action=qa." : "No QA gate: nothing here needs tests."} Then action=complete.`;
1365
+ }
1366
+ if (!shaping) return `Full workflow from here: ${rosterWords(roster)}. The QA gate runs before the task completes${grew ? "; tell the user in one line why the task grew" : ""}.`;
1367
+ return `Full workflow: ${rosterWords(roster)}. Scout what the request touches, then propose a short bullet list for approval.`;
1368
+ }
1369
+
1230
1370
  function handleDecide(task: Task, params: OrchestrateParams): string {
1231
1371
  const text = params.text?.trim();
1232
1372
  if (!text) throw new Error("decide requires text");
@@ -1254,7 +1394,7 @@ type WorkerTime = AgentTime & { id: string };
1254
1394
  /** The task's budget and where it stands, when it has one. */
1255
1395
  function budgetFor(task: Task, deps: WorkflowDeps): { budget: TaskBudget; state: BudgetState } | undefined {
1256
1396
  const budget = readBudget(deps.root, deps.configDir, task.id);
1257
- return budget ? { budget, state: budgetState(task.id, budget, task.qaVerdict === "pass") } : undefined;
1397
+ return budget ? { budget, state: budgetState(task.id, budget, !qaStillDue(task)) } : undefined;
1258
1398
  }
1259
1399
 
1260
1400
  /** No new work starts once the budget is spent: the oracle asks the user for more, or wraps up. */
@@ -1501,6 +1641,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
1501
1641
  resume: handleResume,
1502
1642
  decide: handleDecide,
1503
1643
  budget: handleBudget,
1644
+ track: handleTrack,
1504
1645
  status: (task) => describeTask(task),
1505
1646
  cancel: handleCancel,
1506
1647
  };
@@ -1,169 +0,0 @@
1
- /**
2
- * Caller-driven expression schedule for the zen sprites.
3
- *
4
- * Pure and deterministic: `now` and the random source are always injected, so
5
- * the art, layout and metrics stay free of `Date.now`/`Math.random`. Each slot
6
- * and the oracle keeps its own state, holds its rest frame between events and,
7
- * after a random 20-30 s gap, plays a short blink or a longer emote.
8
- */
9
-
10
- /** Resting gap before the next expression, milliseconds. */
11
- export const BLINK_MIN_MS = 20_000;
12
- export const BLINK_MAX_MS = 30_000;
13
- /** How long one blink and one emote are held. */
14
- export const BLINK_MS = 500;
15
- export const EMOTE_MS = 2000;
16
- /** Fast clock while an expression plays, short enough that a blink is never skipped. */
17
- export const FAST_TICK_MS = 120;
18
-
19
- /** Art frame indexes: 0 rests, 1 blinks, `2 ..` are emotes. */
20
- export const REST_FRAME = 0;
21
- export const BLINK_FRAME = 1;
22
- export const EMOTE_FRAME = 2;
23
- /**
24
- * Emote frame range: `EMOTE_FRAME .. EMOTE_FRAME + EMOTE_FRAMES - 1`; an emote
25
- * steps through them: the open face, a blink, then its action (see kaomoji.ts).
26
- */
27
- export const EMOTE_FRAMES = 4;
28
- /** Each emote frame is held this long, so an emote steps through its frames. */
29
- export const EMOTE_STEP_MS = Math.floor(EMOTE_MS / EMOTE_FRAMES);
30
-
31
- const BLINK_CHANCE = 0.65;
32
- /** Working agents emote as often as they blink. */
33
- export const WORKING_BLINK_CHANCE = 0.5;
34
- /** Upper bound (exclusive) of an expression's variant. */
35
- const VARIANT_SPAN = 2 ** 30;
36
-
37
- export interface ExpressionState {
38
- /** Earliest `now` the next expression may start. */
39
- nextAt: number;
40
- /** When the playing expression ends; not after `now` while resting. */
41
- until: number;
42
- /** When the current expression started; drives emote frame stepping. */
43
- startedAt: number;
44
- /** Frame index for the art: 0 rest, 1 blink, 2+ emote. */
45
- frame: number;
46
- /** Drawn once per expression; picks which face an emote shows (see kaomoji.ts). */
47
- variant: number;
48
- }
49
-
50
- /** A random draw clamped to `[0, 1]`, so a non-finite source value can never leak. */
51
- function unit(rng: () => number): number {
52
- const value = rng();
53
- return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
54
- }
55
-
56
- /** Resting window between expressions, milliseconds. */
57
- export interface ExpressionGap {
58
- min: number;
59
- max: number;
60
- }
61
-
62
- /** Idle agent slots rest 20-30 s between expressions. */
63
- export const SLOT_GAP: ExpressionGap = { min: BLINK_MIN_MS, max: BLINK_MAX_MS };
64
- /** Working agents are livelier: 8-15 s between expressions. */
65
- export const WORKING_GAP: ExpressionGap = { min: 8_000, max: 15_000 };
66
- /** The oracle is the scene's centrepiece: it blinks and emotes every 6-12 s. */
67
- export const ORACLE_GAP: ExpressionGap = { min: 6_000, max: 12_000 };
68
-
69
- /** Random resting gap before the next expression, inside `gap` (the slot window by default). */
70
- export function nextGap(rng: () => number, gap: ExpressionGap = SLOT_GAP): number {
71
- return gap.min + Math.round(unit(rng) * (gap.max - gap.min));
72
- }
73
-
74
- /** Whether the next expression blinks (~65% by default) or emotes. */
75
- export function pickEvent(rng: () => number, blinkChance = BLINK_CHANCE): "blink" | "emote" {
76
- return unit(rng) < blinkChance ? "blink" : "emote";
77
- }
78
-
79
- function drawVariant(rng: () => number): number {
80
- return Math.floor(unit(rng) * (VARIANT_SPAN - 1));
81
- }
82
-
83
- /** A freshly rested expression that fires for the first time after one random gap. */
84
- export function createExpression(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
85
- const start = Number.isFinite(now) ? now : 0;
86
- return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME, variant: 0 };
87
- }
88
-
89
- /** True while `now` is inside a playing blink or emote. */
90
- export function isPlaying(state: ExpressionState, now: number): boolean {
91
- return Number.isFinite(now) && now < state.until;
92
- }
93
-
94
- /** True when any state is mid-expression; the caller uses it to retime its clock. */
95
- export function anyPlaying(states: readonly ExpressionState[], now: number): boolean {
96
- return states.some((state) => isPlaying(state, now));
97
- }
98
-
99
- /**
100
- * Advance one sprite to `now`: start a blink or emote once the gap has elapsed,
101
- * drop back to the rest frame when it ends, and change nothing in between.
102
- */
103
- export function advanceExpression(
104
- state: ExpressionState,
105
- now: number,
106
- rng: () => number,
107
- gap: ExpressionGap = SLOT_GAP,
108
- blinkChance = BLINK_CHANCE,
109
- ): ExpressionState {
110
- if (!Number.isFinite(now)) return state;
111
- if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
112
- if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
113
- return play(now, rng, gap, blinkChance);
114
- }
115
-
116
- function play(now: number, rng: () => number, gap: ExpressionGap, blinkChance: number): ExpressionState {
117
- const blink = pickEvent(rng, blinkChance) === "blink";
118
- return blink ? startBlink(now, rng, gap) : triggerEmote(now, rng, gap);
119
- }
120
-
121
- function startBlink(now: number, rng: () => number, gap: ExpressionGap): ExpressionState {
122
- const until = now + BLINK_MS;
123
- return { nextAt: until + nextGap(rng, gap), until, startedAt: now, frame: BLINK_FRAME, variant: drawVariant(rng) };
124
- }
125
-
126
- /**
127
- * Start an emote right now, whatever the sprite was doing: the reaction when a
128
- * slot's situation changes (it starts, finishes, fails, stalls, waits or
129
- * receives a file). The next scheduled expression follows one gap later.
130
- */
131
- export function triggerEmote(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
132
- const start = Number.isFinite(now) ? now : 0;
133
- const until = start + EMOTE_MS;
134
- return { nextAt: until + nextGap(rng, gap), until, startedAt: start, frame: EMOTE_FRAME, variant: drawVariant(rng) };
135
- }
136
-
137
- /** One sub-step of a playing expression; the oracle's blink and glance step through these. */
138
- export const PHASE_MS = FAST_TICK_MS;
139
-
140
- /** Sub-steps elapsed in the playing expression, 0 while resting or on a bad clock. */
141
- export function expressionPhase(state: ExpressionState, now: number): number {
142
- if (!isPlaying(state, now)) return 0;
143
- const elapsed = now - state.startedAt;
144
- return Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / PHASE_MS) : 0;
145
- }
146
-
147
- /** How long the oracle lip-syncs after it says something new, and one mouth shape's hold. */
148
- export const TALK_MS = 1800;
149
- export const TALK_STEP_MS = FAST_TICK_MS;
150
-
151
- /** Mouth-shape index while the oracle is still talking about what it said at `since`, else undefined. */
152
- export function talkFrame(since: number | undefined, now: number): number | undefined {
153
- if (since === undefined || !Number.isFinite(since) || !Number.isFinite(now)) return undefined;
154
- const elapsed = now - since;
155
- return elapsed >= 0 && elapsed < TALK_MS ? Math.floor(elapsed / TALK_STEP_MS) : undefined;
156
- }
157
-
158
- /** Emote frame for `now`: one step per EMOTE_STEP_MS, clamped to the last frame. */
159
- function emoteFrameAt(startedAt: number, now: number): number {
160
- const elapsed = now - startedAt;
161
- const step = Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / EMOTE_STEP_MS) : 0;
162
- return EMOTE_FRAME + Math.min(EMOTE_FRAMES - 1, Math.max(0, step));
163
- }
164
-
165
- /** Advance an in-flight emote's frame, keeping the same object when it has not changed. */
166
- function steppedEmote(state: ExpressionState, now: number): ExpressionState {
167
- const frame = emoteFrameAt(state.startedAt, now);
168
- return frame === state.frame ? state : { ...state, frame };
169
- }