@volter/editor-model-play 0.5.192 → 0.5.194

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/editor-model-play",
3
- "version": "0.5.192",
3
+ "version": "0.5.194",
4
4
  "author": "Volter AI, Inc.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  ]
29
29
  },
30
30
  "dependencies": {
31
- "@volter/editor-sdk": "0.5.192"
31
+ "@volter/editor-sdk": "0.5.194"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
package/src/model-play.ts CHANGED
@@ -55,29 +55,82 @@ const generations = new Map<string, number>();
55
55
  const restarted = new Set<string>();
56
56
 
57
57
  /** Who last switched autoplay: the panel's toggle, a verb (CLI or `eval`), a person's input
58
- * in the game, or the running script no longer offering a bot (replaced, failed, or its bot
59
- * threw). */
60
- export type ModelPlayAutoplayBy = 'panel' | 'cli' | 'takeover' | 'script';
58
+ * in the game, the running script no longer offering a bot (replaced, failed, or its bot
59
+ * threw), or the run's time limit. */
60
+ export type ModelPlayAutoplayBy = 'panel' | 'cli' | 'takeover' | 'script' | 'limit';
61
+ /**
62
+ * EVERY AUTOPLAY RUN HAS A LIMIT, in simulation seconds: a bot whose game never ends would
63
+ * otherwise drive forever, and an agent waiting on it would wait forever. Reaching it turns
64
+ * autoplay off, pauses the game and logs `autoplay-limit` (`play-script.ts`).
65
+ */
66
+ export const MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS = 300;
61
67
  export interface ModelPlayAutoplay {
62
68
  /** The bot drives: its keys are merged into the keys the script reads. */
63
69
  readonly on: boolean;
64
70
  /** The running script registered a bot with `play.autoplay`. */
65
71
  readonly available: boolean;
72
+ /** The behaviours the running script's bot offers (`play.autoplay({ win, lose })`), in its
73
+ * order; a bare function is the one behaviour `play`. */
74
+ readonly behaviors: readonly string[];
75
+ /** The behaviour driving now, or armed for the next start. */
76
+ readonly behavior: string | null;
77
+ /** This run's limit and the simulation time it began at; null while autoplay is off. */
78
+ readonly limit: number | null;
79
+ readonly since: number | null;
66
80
  readonly by: ModelPlayAutoplayBy | null;
67
81
  /** Pressed while stopped: the next start turns autoplay on once its script offers a bot. */
68
82
  readonly armed: boolean;
83
+ /** A person's key or pointer reached the game in this run. Until one does, nobody drives
84
+ * while autoplay is off — the game runs on no input. */
85
+ readonly person: boolean;
86
+ /** What the bot last said it is doing (`{ keys, state }` from its controller); kept after
87
+ * autoplay goes off, so a limit or takeover still shows where the bot was. */
88
+ readonly state: string | null;
89
+ /** Why the last arm was not taken at start (the bot offers no bot, or not the behaviour it asked
90
+ * for, or several with none named); null otherwise. Said in the panel and the play log. */
91
+ readonly refused: string | null;
69
92
  }
70
- const NO_BOT: ModelPlayAutoplay = { on: false, available: false, by: null, armed: false };
93
+ /** What a switch-on asks for: the behaviour (required when the bot offers several) and the limit. */
94
+ export interface ModelPlayAutoplayRequest {
95
+ readonly behavior?: string | null;
96
+ readonly limit?: number | null;
97
+ }
98
+ const NO_BOT: ModelPlayAutoplay = { on: false, available: false, behaviors: [], behavior: null, limit: null, since: null, by: null, armed: false, person: false, state: null, refused: null };
71
99
  const ARMED: ModelPlayAutoplay = { ...NO_BOT, armed: true };
72
100
  /** Replaced, never mutated, as the clocks are. Absent is {@link NO_BOT}: a new run's state. */
73
101
  const autoplays = new Map<string, ModelPlayAutoplay>();
74
102
 
75
- /** A new run's autoplay: off, no bot yet. Play keeps an arm made while stopped; Stop drops it. */
103
+ /** A new run's autoplay: off, no bot yet, nobody driving. Play keeps an arm made while stopped
104
+ * (with the behaviour and limit it asked for); Stop drops it. The behaviours the last run's bot
105
+ * offered are kept, not available: while stopped they are what the panel's picker offers for an
106
+ * arm, which is checked against the bot the next start offers. */
76
107
  function resetAutoplay(documentId: string, keepArm: boolean): void {
77
- if (keepArm && modelPlayAutoplay(documentId).armed) autoplays.set(documentId, ARMED);
108
+ const now = modelPlayAutoplay(documentId);
109
+ if (keepArm && now.armed) autoplays.set(documentId, { ...ARMED, behaviors: now.behaviors, behavior: now.behavior, limit: now.limit });
110
+ else if (now.behaviors.length) autoplays.set(documentId, { ...NO_BOT, behaviors: now.behaviors });
78
111
  else autoplays.delete(documentId);
79
112
  }
80
113
 
114
+ /** A limit as given: a positive number of simulation seconds, or the default. */
115
+ function autoplayLimit(limit: number | null | undefined): number {
116
+ if (limit === undefined || limit === null) return MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS;
117
+ if (!Number.isFinite(limit) || limit <= 0) throw new Error(`An autoplay limit is a positive number of simulation seconds; got ${String(limit)}.`);
118
+ return limit;
119
+ }
120
+
121
+ /** The behaviour a switch-on drives: the one named, which the bot must offer; with none named,
122
+ * the bot's only behaviour. A bot with several needs one named, so a run always says what its
123
+ * bot was trying to do. */
124
+ function chosenBehavior(behaviors: readonly string[], named: string | null | undefined): string {
125
+ if (named) {
126
+ if (!behaviors.includes(named))
127
+ throw new Error(`This game's bot has no behaviour "${named}"; it offers ${behaviors.map(b => `"${b}"`).join(', ')}.`);
128
+ return named;
129
+ }
130
+ if (behaviors.length === 1) return behaviors[0]!;
131
+ throw new Error(`This game's bot offers several behaviours (${behaviors.join(', ')}); name the one to run: \`play autoplay on <behaviour>\`.`);
132
+ }
133
+
81
134
  function publish(): void {
82
135
  for (const listener of [...listeners]) listener();
83
136
  }
@@ -250,9 +303,10 @@ function setAutoplay(documentId: string, next: Partial<ModelPlayAutoplay>): void
250
303
  }
251
304
 
252
305
  /** Switch the running script's bot on or off. On asks for a playing document whose script
253
- * registered a bot; off always succeeds, and also drops an arm not yet taken — a person who
254
- * takes over while the game is still starting is driving, and the arm must not override them. */
255
- export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelPlayAutoplayBy): void {
306
+ * registered a bot, names the behaviour to drive (required when the bot offers several) and
307
+ * starts the run's limit; off always succeeds, and also drops an arm not yet taken — a person
308
+ * who takes over while the game is still starting is driving, and the arm must not override them. */
309
+ export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelPlayAutoplayBy, request: ModelPlayAutoplayRequest = {}): void {
256
310
  const now = modelPlayAutoplay(documentId);
257
311
  if (on && !playing.has(documentId))
258
312
  throw new Error(`Autoplay is available once the game is running, and nothing is playing in ${documentId}; \`play\` starts it, then \`play autoplay on\`.`);
@@ -261,29 +315,62 @@ export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelP
261
315
  throw new Error(clock.running
262
316
  ? 'No autoplay: this game doesn’t provide a bot — its play script registers none with `play.autoplay(controller)`.'
263
317
  : `Autoplay is available once the game is running, and it is not running yet${clock.failure ? ` (${clock.failure})` : ''}.`);
264
- if (on ? !now.on : now.on || now.armed) setAutoplay(documentId, on ? { on, by } : { on, by, armed: false });
318
+ if (on) {
319
+ const behavior = chosenBehavior(now.behaviors, request.behavior);
320
+ const limit = autoplayLimit(request.limit);
321
+ // Switching on again (another behaviour, a new limit) is a new run of the bot.
322
+ setAutoplay(documentId, { on, by, behavior, limit, since: clock.time, armed: false, state: null, refused: null });
323
+ } else if (now.on || now.armed) setAutoplay(documentId, { on, by, armed: false, since: null });
265
324
  }
266
325
 
267
326
  /** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button
268
- * before Play. Disarming always succeeds. */
269
- export function armModelPlayAutoplay(documentId: string, armed: boolean): void {
327
+ * before Play. The behaviour and limit are checked when the start takes the arm, since the bot
328
+ * is offered only then. Disarming always succeeds. */
329
+ export function armModelPlayAutoplay(documentId: string, armed: boolean, request: ModelPlayAutoplayRequest = {}): void {
270
330
  if (armed && playing.has(documentId))
271
331
  throw new Error(`${documentId} is already playing; switch autoplay on instead of arming it.`);
272
- if (modelPlayAutoplay(documentId).armed !== armed) setAutoplay(documentId, { armed });
332
+ if (armed) setAutoplay(documentId, { armed, refused: null, behavior: request.behavior ?? null, limit: request.limit === undefined || request.limit === null ? null : autoplayLimit(request.limit) });
333
+ else if (modelPlayAutoplay(documentId).armed) setAutoplay(documentId, { armed, behavior: null, limit: null });
273
334
  }
274
335
 
275
- /** The runner's report that a script has run its first update, offering a bot or not. An arm is
276
- * taken here: on with a bot, dropped without one. */
277
- export function settleModelPlayAutoplay(documentId: string, offered: boolean): void {
336
+ /** The runner's report that a script has run its first update, offering a bot's behaviours or
337
+ * none. An arm is taken here: on with a bot that has the behaviour it asked for (or only one),
338
+ * dropped otherwise. */
339
+ export function settleModelPlayAutoplay(documentId: string, behaviors: readonly string[]): void {
278
340
  const now = modelPlayAutoplay(documentId);
279
341
  if (!playing.has(documentId)) return;
280
- if (!now.armed) { setModelPlayAutoplayAvailable(documentId, offered); return; }
281
- setAutoplay(documentId, offered ? { available: true, armed: false, on: true, by: 'panel' } : { available: false, armed: false, by: 'script' });
342
+ if (!now.armed) { setModelPlayAutoplayAvailable(documentId, behaviors); return; }
343
+ let behavior: string | null = null;
344
+ let refused: string | null = null;
345
+ if (!behaviors.length) refused = 'the play script registers no bot with play.autoplay';
346
+ else {
347
+ try { behavior = chosenBehavior(behaviors, now.behavior); }
348
+ catch (error) { refused = error instanceof Error ? error.message : String(error); }
349
+ }
350
+ setAutoplay(documentId, behavior !== null
351
+ ? { available: true, behaviors, armed: false, on: true, by: 'panel', behavior, limit: now.limit ?? MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS, since: modelPlayClock(documentId).time, state: null, refused: null }
352
+ : { available: behaviors.length > 0, behaviors, armed: false, behavior: null, limit: null, by: 'script', refused });
282
353
  }
283
354
 
284
- /** The runner's report of whether the running script offers a bot. Losing it turns autoplay off. */
285
- export function setModelPlayAutoplayAvailable(documentId: string, available: boolean): void {
355
+ /** The runner's report of the behaviours the running script's bot offers (none: no bot). Losing
356
+ * the bot, or the behaviour driving, turns autoplay off. */
357
+ export function setModelPlayAutoplayAvailable(documentId: string, behaviors: readonly string[]): void {
286
358
  const now = modelPlayAutoplay(documentId);
287
- if (now.available === available || !playing.has(documentId)) return;
288
- setAutoplay(documentId, available || !now.on ? { available } : { available, on: false, by: 'script' });
359
+ if (!playing.has(documentId)) return;
360
+ const available = behaviors.length > 0;
361
+ const same = now.available === available && now.behaviors.length === behaviors.length && now.behaviors.every((b, i) => b === behaviors[i]);
362
+ if (same) return;
363
+ const lost = now.on && (now.behavior === null || !behaviors.includes(now.behavior));
364
+ setAutoplay(documentId, lost ? { available, behaviors, on: false, by: 'script', since: null } : { available, behaviors });
365
+ }
366
+
367
+ /** The runner's report of what the driving bot says it is doing; announced only when it changes. */
368
+ export function setModelPlayBotState(documentId: string, state: string | null): void {
369
+ if (playing.has(documentId) && modelPlayAutoplay(documentId).state !== state) setAutoplay(documentId, { state });
370
+ }
371
+
372
+ /** The runner's report that a person's key or pointer reached the game: from now on in this run,
373
+ * autoplay off means the person drives. */
374
+ export function noteModelPlayPerson(documentId: string): void {
375
+ if (playing.has(documentId) && !modelPlayAutoplay(documentId).person) setAutoplay(documentId, { person: true });
289
376
  }
@@ -74,10 +74,13 @@ import {
74
74
  modelPlayAutoplay,
75
75
  modelPlayClock,
76
76
  modelPlayGeneration,
77
+ noteModelPlayPerson,
77
78
  registerModelPlayStop,
78
79
  setModelPlayAutoplay,
80
+ setModelPlayBotState,
79
81
  setModelPlayFailure,
80
82
  setModelPlayAutoplayAvailable,
83
+ setModelPlayPaused,
81
84
  settleModelPlayAutoplay,
82
85
  subscribeModelPlayClock,
83
86
  takeModelPlayStep,
@@ -136,20 +139,30 @@ export interface ModelPlayContext {
136
139
  * authored opacity. Its colour is untouched, and the copies are `tint`'s. */
137
140
  setOpacity(object: THREE.Object3D | string, opacity: number | null): void;
138
141
  /**
139
- * Offer this game's bot. While the person (or an agent, `play autoplay on`) has autoplay on in
140
- * the Game panel, `controller` is called before each `update` and the keys it answers are held
141
- * for that update, merged into `keys`. Autoplay is off at every Play and Restart, and a
142
- * person's key or pointer in the game turns it off. One bot per script: registering again
143
- * replaces it, `null` withdraws it, and it goes with the script. Never bind autoplay to a game
144
- * key, and never start it from the script.
142
+ * Offer this game's bot, as named BEHAVIOURS: what the bot sets out to do, each its own
143
+ * controller. While the person (or an agent, `play autoplay on <behaviour>`) has autoplay on in
144
+ * the Game panel, the chosen behaviour's controller is called before each `update` and the keys
145
+ * it answers are held for that update, merged into `keys`. Offer the outcomes worth checking —
146
+ * one that plays to win and one that loses on purpose — so a run tests an ending by saying so,
147
+ * not by leaving the game alone. A bare function is the one behaviour `play`. Every run has a
148
+ * limit in simulation seconds (`play autoplay on <behaviour> --for <seconds>`, 300 unless
149
+ * given): reaching it turns autoplay off and pauses the game. Autoplay is off at every Play and
150
+ * Restart, and a person's key or pointer in the game turns it off. One bot per script:
151
+ * registering again replaces it, `null` withdraws it, and it goes with the script. Never bind
152
+ * autoplay to a game key, and never start it from the script.
145
153
  *
146
- * play.autoplay(({ dt }) => (car.speed < 20 ? ['ArrowUp'] : []));
154
+ * play.autoplay({
155
+ * win: ({ dt }) => (car.speed < 20 ? ['ArrowUp'] : []),
156
+ * lose: () => ['ArrowLeft'], // drives off the track
157
+ * });
147
158
  */
148
- autoplay(controller: ModelPlayAutoplayController | null): void;
159
+ autoplay(bot: ModelPlayAutoplayController | Readonly<Record<string, ModelPlayAutoplayController>> | null): void;
149
160
  }
150
161
 
151
162
  /** What the bot is handed before each `update` it drives. */
152
163
  export interface ModelPlayAutoplayInput {
164
+ /** The behaviour driving (`play autoplay on <behaviour>`), for a controller shared by several. */
165
+ readonly behavior: string;
153
166
  /** The `dt` the coming `update` is handed. */
154
167
  readonly dt: number;
155
168
  /** Simulation seconds and the update's number, as that update's log entries carry them. */
@@ -158,8 +171,27 @@ export interface ModelPlayAutoplayInput {
158
171
  /** The keys the person holds, by `KeyboardEvent.code`; the bot's are merged with them. */
159
172
  readonly keys: ReadonlySet<string>;
160
173
  }
161
- /** A game's bot: the keys (`KeyboardEvent.code`) it holds for the coming update. */
162
- export type ModelPlayAutoplayController = (input: ModelPlayAutoplayInput) => Iterable<string> | null | undefined;
174
+ /**
175
+ * A game's bot: the keys (`KeyboardEvent.code`) it holds for the coming update — or those keys
176
+ * with what it is doing now, `{ keys, state: 'heading to nest 2' }`. The state is the bot
177
+ * explaining itself: the Game panel shows it beside the driver, `play state` reports it, and
178
+ * each change is a `bot-state` entry in the play log. Say intentions and their reasons (a goal,
179
+ * a target, why it is waiting), not per-frame numbers.
180
+ */
181
+ export type ModelPlayAutoplayController = (input: ModelPlayAutoplayInput) =>
182
+ Iterable<string> | { readonly keys?: Iterable<string> | null; readonly state?: string | null } | null | undefined;
183
+
184
+ /** The longest bot state kept: a line for the panel, not a report. */
185
+ const BOT_STATE_CHARACTERS = 160;
186
+
187
+ /** A controller's answer as its keys and its state (undefined: it said nothing about its state). */
188
+ function botAnswer(answer: ReturnType<ModelPlayAutoplayController>): { keys: Iterable<string>; state: string | null | undefined } {
189
+ if (answer === null || answer === undefined) return { keys: [], state: undefined };
190
+ if (typeof (answer as Iterable<string>)[Symbol.iterator] === 'function') return { keys: answer as Iterable<string>, state: undefined };
191
+ const { keys, state } = answer as { keys?: Iterable<string> | null; state?: string | null };
192
+ const said = state === undefined ? undefined : state === null ? null : String(state).replace(/\s+/g, ' ').trim().slice(0, BOT_STATE_CHARACTERS) || null;
193
+ return { keys: keys ?? [], state: said };
194
+ }
163
195
 
164
196
  export interface ModelPlayGame {
165
197
  /** Once per drawn frame, with the simulation seconds since the last call (at most a tenth).
@@ -174,10 +206,32 @@ export interface ModelPlayGame {
174
206
  * logged once — per script, so a typo that survives a reload is said again for the new one. */
175
207
  interface Script {
176
208
  value: boolean;
177
- bot: ModelPlayAutoplayController | null;
209
+ /** The bot's behaviours by name, in the order offered; null without a bot. */
210
+ bot: Readonly<Record<string, ModelPlayAutoplayController>> | null;
178
211
  readonly unknown: Set<string>;
179
212
  }
180
213
 
214
+ /** The behaviour names a script's bot offers, in its order. */
215
+ function behaviorsOf(script: Script | undefined): string[] {
216
+ return script?.bot ? Object.keys(script.bot) : [];
217
+ }
218
+
219
+ /** `play.autoplay`'s argument as behaviours by name: a bare function is the one behaviour `play`. */
220
+ function botBehaviors(bot: unknown): Readonly<Record<string, ModelPlayAutoplayController>> | null {
221
+ if (bot === null) return null;
222
+ if (typeof bot === 'function') return { play: bot as ModelPlayAutoplayController };
223
+ if (typeof bot === 'object' && !Array.isArray(bot)) {
224
+ const entries = Object.entries(bot as Record<string, unknown>);
225
+ if (entries.length === 0) throw new Error('play.autoplay was given no behaviours: offer at least one, `{ win: (input) => keys }`.');
226
+ for (const [name, controller] of entries) {
227
+ if (!/^[A-Za-z][\w-]{0,39}$/.test(name)) throw new Error(`play.autoplay behaviour "${name}" is not a name: letters, digits, - and _, starting with a letter.`);
228
+ if (typeof controller !== 'function') throw new Error(`play.autoplay behaviour "${name}" is not a function (the controller).`);
229
+ }
230
+ return Object.freeze({ ...(bot as Record<string, ModelPlayAutoplayController>) });
231
+ }
232
+ throw new Error('play.autoplay takes a function (the bot), its behaviours by name (`{ win, lose }`), or null.');
233
+ }
234
+
181
235
  /** The longest `dt` one update is handed; longer scaled frames are split (`frameUpdates`). */
182
236
  const MAX_UPDATE_SECONDS = 0.1;
183
237
 
@@ -296,10 +350,11 @@ export function runPlayScript(options: {
296
350
  if (now.speed !== seen.speed) run.append('play', 'speed', { speed: now.speed, from: seen.speed });
297
351
  seen = now;
298
352
  const bot = modelPlayAutoplay(options.documentId);
299
- if (bot.on !== seenBot.on) run.append('play', bot.on ? 'autoplay-on' : 'autoplay-off', { by: bot.by });
353
+ if (bot.on !== seenBot.on || (bot.on && (bot.behavior !== seenBot.behavior || bot.since !== seenBot.since)))
354
+ run.append('play', bot.on ? 'autoplay-on' : 'autoplay-off', bot.on ? { by: bot.by, behavior: bot.behavior, limit: bot.limit } : { by: bot.by, behavior: seenBot.behavior });
300
355
  // An arm dropped before it was taken: a takeover, or a script that offers no bot. (Stop's
301
356
  // reset has no `by`, and its `play-stop` says enough.)
302
- else if (seenBot.armed && !bot.armed && bot.by !== null) run.append('play', 'autoplay-off', { by: bot.by, armed: true });
357
+ else if (seenBot.armed && !bot.armed && bot.by !== null) run.append('play', 'autoplay-off', { by: bot.by, armed: true, ...(bot.refused ? { why: bot.refused } : {}) });
303
358
  seenBot = bot;
304
359
  });
305
360
  options.container.style.opacity = '0';
@@ -326,10 +381,9 @@ export function runPlayScript(options: {
326
381
  const target = alive.value ? objectOf(alive, object, 'setOpacity') : null;
327
382
  if (target) materials.setOpacity(target, opacity);
328
383
  },
329
- autoplay(controller) {
330
- if (controller !== null && typeof controller !== 'function')
331
- throw new Error('play.autoplay takes a function (the bot) or null.');
332
- if (alive.value) alive.bot = controller;
384
+ autoplay(bot) {
385
+ const behaviors = botBehaviors(bot);
386
+ if (alive.value) alive.bot = behaviors;
333
387
  },
334
388
  });
335
389
  const scripts = new WeakMap<ModelPlayGame, Script>();
@@ -450,7 +504,7 @@ export function runPlayScript(options: {
450
504
  else if (!transition.acceptingKeys()) keys.clear();
451
505
  else for (const key of heldKeys) keys.add(key);
452
506
  // The panel's toggle is enabled by the bot the running script offers, as of the last frame.
453
- if (current()) setModelPlayAutoplayAvailable(options.documentId, game !== null && scripts.get(game)?.bot != null);
507
+ if (current()) setModelPlayAutoplayAvailable(options.documentId, game !== null ? behaviorsOf(scripts.get(game)) : []);
454
508
  const updates = frameUpdates(options.documentId, deltaSeconds);
455
509
  if (updates.length === 0) {
456
510
  // PAUSED: no update, so nothing states the camera; hold the pose the last frame drew.
@@ -478,13 +532,20 @@ export function runPlayScript(options: {
478
532
  /** Merge the bot's keys into `keys` for one update; true when it did, and the caller then
479
533
  * restores the person's keys after that update, however it ends. */
480
534
  const drive = (script: ModelPlayGame, dt: number): boolean => {
481
- const bot = driving ? scripts.get(script)?.bot : null;
482
- // Asked again per update: the bot's own failure, a takeover, or `play.autoplay(null)`
483
- // ends it mid-frame.
484
- if (!bot || !modelPlayAutoplay(options.documentId).on) return false;
535
+ // Asked again per update: the bot's own failure, a takeover, its limit, or
536
+ // `play.autoplay(null)` ends it mid-frame.
537
+ const now = modelPlayAutoplay(options.documentId);
538
+ const behavior = driving && now.on ? now.behavior : null;
539
+ const bot = behavior !== null ? scripts.get(script)?.bot?.[behavior] : undefined;
540
+ if (!bot || behavior === null) return false;
485
541
  restorePerson();
486
542
  try {
487
- for (const key of bot({ dt, simT: clock.time + simulated, tick: clock.tick + ran, keys: person }) ?? []) keys.add(String(key));
543
+ const answer = botAnswer(bot({ behavior, dt, simT: clock.time + simulated, tick: clock.tick + ran, keys: person }));
544
+ for (const key of answer.keys) keys.add(String(key));
545
+ if (answer.state !== undefined && answer.state !== now.state) {
546
+ setModelPlayBotState(options.documentId, answer.state);
547
+ run.append('play', 'bot-state', { behavior, state: answer.state });
548
+ }
488
549
  return true;
489
550
  } catch (error) {
490
551
  restorePerson();
@@ -524,7 +585,8 @@ export function runPlayScript(options: {
524
585
  // NOW THE SCRIPT HAS SAID WHETHER IT OFFERS A BOT (its default export and first update
525
586
  // are where `play.autoplay` is called): said before `running`, so no reader sees a running
526
587
  // game with its bot not yet counted, and an arm made while stopped is taken or dropped.
527
- const offered = scripts.get(next.game)?.bot != null;
588
+ const behaviors = behaviorsOf(scripts.get(next.game));
589
+ const offered = behaviors.length > 0;
528
590
  if (!offered && offeredBot !== false)
529
591
  run.append('play', 'autoplay-unavailable', { why: 'the play script registers no bot with play.autoplay(controller)' });
530
592
  offeredBot = offered;
@@ -532,7 +594,7 @@ export function runPlayScript(options: {
532
594
  // the stage was still preparing, before these listeners existed, and has only repeated
533
595
  // since. The person is driving, so an arm waiting for this update is dropped.
534
596
  if (heldKeys.size > 0 && modelPlayAutoplay(options.documentId).armed) takeover();
535
- settleModelPlayAutoplay(options.documentId, offered);
597
+ settleModelPlayAutoplay(options.documentId, behaviors);
536
598
  setModelPlayFailure(options.documentId, null);
537
599
  startedAt = Date.now();
538
600
  endedAt = null;
@@ -551,6 +613,17 @@ export function runPlayScript(options: {
551
613
  }
552
614
  advanceModelPlayClock(options.documentId, simulated, ran);
553
615
  ran = 0;
616
+ // THE RUN'S LIMIT: a bot still driving when its simulation seconds are spent is stopped and
617
+ // the game held, so a game that never ends cannot keep a bot (and whoever waits on it) going.
618
+ const bot = modelPlayAutoplay(options.documentId);
619
+ if (current() && bot.on && bot.limit !== null && bot.since !== null) {
620
+ const now = modelPlayClock(options.documentId).time;
621
+ if (now - bot.since >= bot.limit) {
622
+ run.append('play', 'autoplay-limit', { behavior: bot.behavior, limit: bot.limit, ran: now - bot.since, state: bot.state });
623
+ setModelPlayAutoplay(options.documentId, false, 'limit');
624
+ setModelPlayPaused(options.documentId, true);
625
+ }
626
+ }
554
627
  transition.frame(camera(), deltaSeconds);
555
628
  options.container.style.opacity = String(transition.hudOpacity());
556
629
  if (firstFrame) { firstFrame = false; options.ready(); }
@@ -571,9 +644,12 @@ export function runPlayScript(options: {
571
644
  // by hand.
572
645
  const surface = options.container.parentElement ?? options.container;
573
646
  const takeover = (): void => {
647
+ if (!current()) return;
648
+ // Whether or not a bot was driving, the person is driving from here on in this run.
649
+ noteModelPlayPerson(options.documentId);
574
650
  // An arm still waiting for the bot counts too: the person is driving before it could start.
575
651
  const now = modelPlayAutoplay(options.documentId);
576
- if (current() && (now.on || now.armed)) setModelPlayAutoplay(options.documentId, false, 'takeover');
652
+ if (now.on || now.armed) setModelPlayAutoplay(options.documentId, false, 'takeover');
577
653
  };
578
654
  const onKeyDown = (event: KeyboardEvent): void => {
579
655
  if (!surfaceAcceptsKey(event)) return;