tfrere HF Staff Cursor commited on
Commit
5a85e86
·
1 Parent(s): 59d841a

feat(moves): mount Academy moves by manifest kind (gait, trick on R, command script)

Browse files

- ?move=<ref> boot parameter (?policy= alias, ?session=&round= sugar)
- session:<id>[:<round>] refs fetch same-origin /api/play/* endpoints
- episodic manifests mount in the roll slot as a one-shot on R
- perpetual slot sitstand swaps the sit policy; walk unchanged
- script manifests load move_script.json and drive the 13-D command
vector on the walker (linear keyframes, loop), toggled by R
- HUD chip: PLAYING <prompt or name> by <author>

Co-authored-by: Cursor <cursoragent@cursor.com>

app/src/App.jsx CHANGED
@@ -41,13 +41,20 @@ export default function App() {
41
  window.addEventListener("touchstart", armTouch, { once: true, passive: true });
42
  if (params.get("touch") === "1") armTouch();
43
 
44
- // ?policy=<org/repo or .onnx URL>: request a community walk policy.
 
 
 
45
  // Only the request is recorded here; the game loads it after boot
46
  // (never blocking it) and flips the status to active/error itself.
47
- const policyRef = params.get("policy");
48
- if (policyRef) {
 
 
 
 
49
  useGame.setState({
50
- customPolicy: { ref: policyRef, name: policyRef, status: "loading" },
51
  });
52
  }
53
 
 
41
  window.addEventListener("touchstart", armTouch, { once: true, passive: true });
42
  if (params.get("touch") === "1") armTouch();
43
 
44
+ // ?move=<ref>: request a community move (Academy D7). ref is an
45
+ // "org/repo" Hub move, "session:<id>[:<round>]" (a private Academy run,
46
+ // served same-origin) or a direct .onnx URL. ?policy= is the legacy
47
+ // alias, and ?session=<id>[&round=<r>] is sugar for move=session:<id>:<r>.
48
  // Only the request is recorded here; the game loads it after boot
49
  // (never blocking it) and flips the status to active/error itself.
50
+ let moveRef = params.get("move") || params.get("policy");
51
+ if (!moveRef && params.get("session")) {
52
+ const round = params.get("round");
53
+ moveRef = `session:${params.get("session")}${round ? `:${round}` : ""}`;
54
+ }
55
+ if (moveRef) {
56
  useGame.setState({
57
+ customPolicy: { ref: moveRef, name: moveRef, status: "loading" },
58
  });
59
  }
60
 
app/src/game/custom-policy.js CHANGED
@@ -1,17 +1,40 @@
1
- // Community policy loading: resolve a Hub repo id (or a direct .onnx URL)
2
- // into a validated onnxruntime session the game can drop into its walk
3
- // slot. Mirrors the official publish pipeline's smoke run: shape checks,
4
- // a zeros inference and two noisy inferences that must produce finite,
5
- // non-constant actions before the session ever touches the duck.
 
 
 
 
 
 
 
6
  //
7
  // The module is deliberately framework-free (no game imports): the caller
8
  // hands in the `ort` namespace it already booted, and pure helpers like
9
  // manifestIncompatibility are reusable by standalone pages without pulling
10
  // the game bundle in.
11
 
12
- import { OBS_SIZE, NUM_JOINTS } from "./constants.js";
13
 
14
  const HUB_BASE = "https://huggingface.co";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  // Manifest claims the sim cannot satisfy (schema 2). Only present-and-wrong
17
  // claims reject: an absent field is trusted to match the microduck defaults.
@@ -24,59 +47,109 @@ export function manifestIncompatibility(manifest) {
24
  if (manifest.model_api != null && manifest.model_api > 2) {
25
  return `model_api ${manifest.model_api} is newer than this sim supports`;
26
  }
27
- if (manifest.obs_len != null && manifest.obs_len !== OBS_SIZE) {
28
- return `expects ${manifest.obs_len} observations (sim provides ${OBS_SIZE})`;
29
- }
30
- if (manifest.action_len != null && manifest.action_len !== NUM_JOINTS) {
31
- return `outputs ${manifest.action_len} actions (sim drives ${NUM_JOINTS})`;
32
- }
33
  const robotModel = manifest.robot?.model;
34
  if (robotModel != null && robotModel !== "microduck") {
35
  return `targets robot "${robotModel}", not microduck`;
36
  }
37
- // v1 only swaps the walk slot: perpetual, constant-command policies.
38
- if (manifest.kind != null && manifest.kind !== "perpetual") {
39
- return `"${manifest.kind}" policies are not supported yet (walk-slot only)`;
40
  }
41
- if (manifest.slot != null && manifest.slot !== "walk") {
42
- return `slot "${manifest.slot}" is not supported yet (walk-slot only)`;
 
 
43
  }
44
- const encoding = manifest.command?.encoding;
45
- if (encoding != null && encoding !== "constant") {
46
- return `command encoding "${encoding}" is not supported yet`;
47
  }
48
- if (manifest.mode != null && manifest.mode !== "walk") {
49
- return `mode "${manifest.mode}" is not supported yet (walk-slot only)`;
 
 
 
 
 
 
 
 
 
 
50
  }
 
 
51
  return null;
52
  }
53
 
54
- // A policy ref is either an org/repo Hub id or a direct URL to a .onnx.
55
- // A direct URL may sit next to a manifest.json (the Academy's private-policy
56
- // handoff serves both under one capability URL): it is probed best-effort.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  export function resolvePolicyRef(ref) {
58
  const r = String(ref || "").trim();
59
  if (/^https?:\/\//i.test(r)) {
60
  let manifestUrl = null;
 
61
  try {
62
  const u = new URL(r);
63
  const name = u.pathname.split("/").pop() || "";
64
  if (/\.onnx$/i.test(name)) {
65
- u.pathname = u.pathname.slice(0, -name.length) + "manifest.json";
 
66
  manifestUrl = u.href;
 
 
67
  }
68
  } catch { /* unparsable: no manifest probe */ }
69
- return { kind: "url", name: r.split("/").pop()?.split("?")[0] || r, onnxUrl: r, manifestUrl, manifestOptional: true };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  }
71
  if (!/^[\w.-]+\/[\w.-]+$/.test(r)) {
72
- throw new Error(`invalid policy ref "${r}" (expected org/repo or a .onnx URL)`);
73
  }
74
  return {
75
  kind: "hub",
76
  name: r,
 
77
  repoId: r,
78
  onnxUrl: `${HUB_BASE}/${r}/resolve/main/policy.onnx`,
79
  manifestUrl: `${HUB_BASE}/${r}/resolve/main/manifest.json`,
 
80
  };
81
  }
82
 
@@ -93,6 +166,69 @@ export async function fetchPolicyManifest(repoId) {
93
  return res.json();
94
  }
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  // Publish-pipeline smoke run: 1 input / 1 output, a zeros pass plus two
97
  // small-noise passes; every output must be [1,14], finite, and the noisy
98
  // passes must not produce identical actions (a constant net is a dud).
@@ -126,25 +262,37 @@ async function validateSession(ort, session) {
126
  if (maxDiff < 1e-9) throw new Error("constant output in smoke run (dead policy)");
127
  }
128
 
129
- // Load + validate a community policy. Resolves to everything the game needs
130
- // to swap its walk slot; throws with a clear message on any failure.
 
 
 
 
 
 
 
 
 
 
 
131
  // onProgress receives coarse stages for the HUD loading line.
 
 
 
132
  export async function loadCustomPolicy(ref, { ort, onProgress = () => {} } = {}) {
133
  const resolved = resolvePolicyRef(ref);
134
  let name = resolved.name;
 
135
  let actionScale = 1.0;
 
136
 
137
  if (resolved.manifestUrl) {
138
  onProgress("manifest");
139
- let manifest = null;
140
  if (resolved.manifestOptional) {
141
- // Sibling manifest of a direct URL: any failure (404, CORS, bad JSON)
142
- // just means "no manifest", exactly like before the probe existed.
143
- // Only a present-and-incompatible manifest rejects below.
144
- try {
145
- const res = await fetch(resolved.manifestUrl);
146
- if (res.ok) manifest = await res.json();
147
- } catch { manifest = null; }
148
  } else {
149
  try {
150
  manifest = await fetchPolicyManifest(resolved.repoId);
@@ -159,18 +307,34 @@ export async function loadCustomPolicy(ref, { ort, onProgress = () => {} } = {})
159
  if (reason) throw new Error(reason);
160
  if (typeof manifest.name === "string" && manifest.name) name = manifest.name;
161
  if (Number.isFinite(manifest.action_scale)) actionScale = manifest.action_scale;
 
 
 
162
  }
163
  }
164
 
165
- onProgress("download");
166
- const res = await fetch(resolved.onnxUrl);
167
- if (!res.ok) {
168
- throw new Error(
169
- res.status === 404
170
- ? `no policy.onnx found at ${resolved.name}`
171
- : `policy download failed (HTTP ${res.status})`,
172
- );
 
 
 
 
 
 
 
 
 
 
173
  }
 
 
 
174
  const bytes = new Uint8Array(await res.arrayBuffer());
175
 
176
  onProgress("session");
@@ -184,11 +348,15 @@ export async function loadCustomPolicy(ref, { ort, onProgress = () => {} } = {})
184
  onProgress("validate");
185
  await validateSession(ort, session);
186
 
 
 
 
 
 
187
  return {
 
188
  session,
189
- name,
190
- ref: String(ref),
191
- actionScale,
192
  inputName: session.inputNames[0],
193
  outputName: session.outputNames[0],
194
  };
 
1
+ // Community move loading: resolve a move ref (Hub repo id, Academy session
2
+ // ref or a direct .onnx URL) into a validated onnxruntime session (or a
3
+ // command script) the game can drop into one of its slots. Mirrors the
4
+ // official publish pipeline's smoke run: shape checks, a zeros inference
5
+ // and two noisy inferences that must produce finite, non-constant actions
6
+ // before the session ever touches the duck.
7
+ //
8
+ // Slots (manifest schema 2, Academy decision D7):
9
+ // kind "perpetual" (gait) -> "walk" (default) or "sitstand" (slot field)
10
+ // kind "episodic" (trick) -> "trick": a one-shot on the R key
11
+ // kind "script" -> "script": a 13-D command timeline played on
12
+ // the base walker while R is toggled on
13
  //
14
  // The module is deliberately framework-free (no game imports): the caller
15
  // hands in the `ort` namespace it already booted, and pure helpers like
16
  // manifestIncompatibility are reusable by standalone pages without pulling
17
  // the game bundle in.
18
 
19
+ import { OBS_SIZE, NUM_JOINTS, CMD_SIZE } from "./constants.js";
20
 
21
  const HUB_BASE = "https://huggingface.co";
22
+ // Same-origin Academy endpoints (backend/play_api.py) for private moves.
23
+ const PLAY_API = "/api/play";
24
+
25
+ export const SUPPORTED_KINDS = ["perpetual", "episodic", "script"];
26
+ // Default one-shot length for an episodic trick without a duration claim.
27
+ const DEFAULT_TRICK_S = 5.0;
28
+
29
+ // Game slot a manifest mounts into (see the header). A missing manifest is
30
+ // a plain walk policy, exactly like v1.
31
+ export function slotForManifest(manifest) {
32
+ const kind = manifest?.kind ?? "perpetual";
33
+ if (kind === "script") return "script";
34
+ if (kind === "episodic") return "trick";
35
+ const slot = String(manifest?.slot ?? "walk").toLowerCase();
36
+ return slot === "sitstand" || slot === "sit" ? "sitstand" : "walk";
37
+ }
38
 
39
  // Manifest claims the sim cannot satisfy (schema 2). Only present-and-wrong
40
  // claims reject: an absent field is trusted to match the microduck defaults.
 
47
  if (manifest.model_api != null && manifest.model_api > 2) {
48
  return `model_api ${manifest.model_api} is newer than this sim supports`;
49
  }
 
 
 
 
 
 
50
  const robotModel = manifest.robot?.model;
51
  if (robotModel != null && robotModel !== "microduck") {
52
  return `targets robot "${robotModel}", not microduck`;
53
  }
54
+ const kind = manifest.kind ?? "perpetual";
55
+ if (!SUPPORTED_KINDS.includes(kind)) {
56
+ return `"${manifest.kind}" policies are not supported (kinds: ${SUPPORTED_KINDS.join(", ")})`;
57
  }
58
+ // A script drives the built-in walker: no network claims to check.
59
+ if (kind === "script") return null;
60
+ if (manifest.obs_len != null && manifest.obs_len !== OBS_SIZE) {
61
+ return `expects ${manifest.obs_len} observations (sim provides ${OBS_SIZE})`;
62
  }
63
+ if (manifest.action_len != null && manifest.action_len !== NUM_JOINTS) {
64
+ return `outputs ${manifest.action_len} actions (sim drives ${NUM_JOINTS})`;
 
65
  }
66
+ if (kind === "perpetual") {
67
+ const slot = manifest.slot != null ? String(manifest.slot).toLowerCase() : null;
68
+ if (slot != null && !["walk", "sitstand", "sit"].includes(slot)) {
69
+ return `perpetual slot "${manifest.slot}" is not supported (walk or sitstand)`;
70
+ }
71
+ const encoding = manifest.command?.encoding;
72
+ if (encoding != null && encoding !== "constant") {
73
+ return `command encoding "${encoding}" is not supported yet`;
74
+ }
75
+ if (manifest.mode != null && manifest.mode !== "walk" && slot !== "sitstand" && slot !== "sit") {
76
+ return `mode "${manifest.mode}" is not supported yet (walk-slot only)`;
77
+ }
78
  }
79
+ // Episodic tricks: every slot (stand, roulade, kick_left, ground_pick,
80
+ // R, ...) mounts on the R key as a zero-command one-shot.
81
  return null;
82
  }
83
 
84
+ // Parse the "session:<id>[:<round>]" ref form. Returns null when `r` is not
85
+ // a session ref. `round` is null when absent.
86
+ function parseSessionRef(r) {
87
+ const m = /^session:([\w.-]+)(?::([\w.-]+))?$/i.exec(r);
88
+ return m ? { id: m[1], round: m[2] ?? null } : null;
89
+ }
90
+
91
+ // Build a same-origin Academy URL for a private session artifact.
92
+ function sessionApiUrl(endpoint, { id, round }, extra = {}) {
93
+ const q = new URLSearchParams({ session: id });
94
+ if (round != null) q.set("round", String(round));
95
+ for (const [k, v] of Object.entries(extra)) q.set(k, v);
96
+ return `${PLAY_API}/${endpoint}?${q}`;
97
+ }
98
+
99
+ // A move ref is one of:
100
+ // "org/repo" a Hub move repo (manifest + policy.onnx)
101
+ // "session:<id>[:<round>]" a private Academy run, served same-origin
102
+ // "https://.../x.onnx" a direct URL (sibling manifest probed)
103
+ // Resolves to the URLs of the three artifacts the loader may need.
104
  export function resolvePolicyRef(ref) {
105
  const r = String(ref || "").trim();
106
  if (/^https?:\/\//i.test(r)) {
107
  let manifestUrl = null;
108
+ let scriptUrl = null;
109
  try {
110
  const u = new URL(r);
111
  const name = u.pathname.split("/").pop() || "";
112
  if (/\.onnx$/i.test(name)) {
113
+ const dir = u.pathname.slice(0, -name.length);
114
+ u.pathname = `${dir}manifest.json`;
115
  manifestUrl = u.href;
116
+ u.pathname = `${dir}move_script.json`;
117
+ scriptUrl = u.href;
118
  }
119
  } catch { /* unparsable: no manifest probe */ }
120
+ return {
121
+ kind: "url",
122
+ name: r.split("/").pop()?.split("?")[0] || r,
123
+ author: null,
124
+ onnxUrl: r,
125
+ manifestUrl,
126
+ scriptUrl,
127
+ manifestOptional: true,
128
+ };
129
+ }
130
+ const sess = parseSessionRef(r);
131
+ if (sess) {
132
+ return {
133
+ kind: "session",
134
+ name: sess.round != null ? `session ${sess.id} (round ${sess.round})` : `session ${sess.id}`,
135
+ author: null,
136
+ onnxUrl: sessionApiUrl("policy", sess),
137
+ manifestUrl: sessionApiUrl("manifest", sess),
138
+ scriptUrl: sessionApiUrl("file", sess, { path: "move_script.json" }),
139
+ manifestOptional: true,
140
+ };
141
  }
142
  if (!/^[\w.-]+\/[\w.-]+$/.test(r)) {
143
+ throw new Error(`invalid move ref "${r}" (expected org/repo, session:<id> or a .onnx URL)`);
144
  }
145
  return {
146
  kind: "hub",
147
  name: r,
148
+ author: r.split("/")[0],
149
  repoId: r,
150
  onnxUrl: `${HUB_BASE}/${r}/resolve/main/policy.onnx`,
151
  manifestUrl: `${HUB_BASE}/${r}/resolve/main/manifest.json`,
152
+ scriptUrl: `${HUB_BASE}/${r}/resolve/main/move_script.json`,
153
  };
154
  }
155
 
 
166
  return res.json();
167
  }
168
 
169
+ // Optional sibling manifest (direct URL / Academy session): 404 = none.
170
+ // A 401 from the Academy means the visitor is not signed in - that one is
171
+ // worth surfacing instead of silently playing nothing.
172
+ async function fetchOptionalManifest(url) {
173
+ let res;
174
+ try {
175
+ res = await fetch(url);
176
+ } catch {
177
+ return null;
178
+ }
179
+ if (res.status === 401) throw new Error("sign in to the Academy to play a private move");
180
+ if (!res.ok) return null;
181
+ try {
182
+ return await res.json();
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ // Command script (kind "script"): {duration_s, loop, keyframes:[{t, cmd[13]}]}.
189
+ // Keyframes are sorted by t and every cmd is padded/truncated to CMD_SIZE.
190
+ export function normalizeMoveScript(raw) {
191
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.keyframes) || !raw.keyframes.length) {
192
+ throw new Error("move_script.json has no keyframes");
193
+ }
194
+ const keyframes = raw.keyframes.map((k, i) => {
195
+ const t = Number(k?.t);
196
+ if (!Number.isFinite(t) || t < 0) throw new Error(`keyframe ${i}: bad time`);
197
+ const src = Array.isArray(k?.cmd) ? k.cmd : [];
198
+ const cmd = new Float32Array(CMD_SIZE);
199
+ for (let c = 0; c < CMD_SIZE; c++) {
200
+ const v = Number(src[c] ?? 0);
201
+ if (!Number.isFinite(v)) throw new Error(`keyframe ${i}: non-finite command`);
202
+ cmd[c] = v;
203
+ }
204
+ return { t, cmd };
205
+ });
206
+ keyframes.sort((a, b) => a.t - b.t);
207
+ const last = keyframes[keyframes.length - 1].t;
208
+ const durationS = Number.isFinite(Number(raw.duration_s)) && Number(raw.duration_s) > 0
209
+ ? Math.max(Number(raw.duration_s), last)
210
+ : Math.max(last, 0.02);
211
+ return { keyframes, durationS, loop: raw.loop !== false, description: raw.description || "" };
212
+ }
213
+
214
+ // Linearly interpolated command at time t (loop-aware), written into `out`.
215
+ export function scriptCommandAt(script, t, out) {
216
+ const { keyframes, durationS, loop } = script;
217
+ let tt = t;
218
+ if (loop && durationS > 0) tt = ((t % durationS) + durationS) % durationS;
219
+ if (tt <= keyframes[0].t) { out.set(keyframes[0].cmd); return out; }
220
+ for (let i = 0; i < keyframes.length - 1; i++) {
221
+ const a = keyframes[i], b = keyframes[i + 1];
222
+ if (tt >= a.t && tt <= b.t) {
223
+ const u = (tt - a.t) / Math.max(b.t - a.t, 1e-6);
224
+ for (let c = 0; c < CMD_SIZE; c++) out[c] = (1 - u) * a.cmd[c] + u * b.cmd[c];
225
+ return out;
226
+ }
227
+ }
228
+ out.set(keyframes[keyframes.length - 1].cmd);
229
+ return out;
230
+ }
231
+
232
  // Publish-pipeline smoke run: 1 input / 1 output, a zeros pass plus two
233
  // small-noise passes; every output must be [1,14], finite, and the noisy
234
  // passes must not produce identical actions (a constant net is a dud).
 
262
  if (maxDiff < 1e-9) throw new Error("constant output in smoke run (dead policy)");
263
  }
264
 
265
+ async function fetchOrThrow(url, what) {
266
+ const res = await fetch(url);
267
+ if (!res.ok) {
268
+ if (res.status === 401) throw new Error("sign in to the Academy to play a private move");
269
+ throw new Error(
270
+ res.status === 404 ? `no ${what} found at ${url}` : `${what} download failed (HTTP ${res.status})`,
271
+ );
272
+ }
273
+ return res;
274
+ }
275
+
276
+ // Load + validate a community move. Resolves to everything the game needs
277
+ // to mount it in its slot; throws with a clear message on any failure.
278
  // onProgress receives coarse stages for the HUD loading line.
279
+ //
280
+ // Result: { kind, slot, name, title, author, academy, ref, actionScale,
281
+ // durationS, session?, inputName?, outputName?, script? }
282
  export async function loadCustomPolicy(ref, { ort, onProgress = () => {} } = {}) {
283
  const resolved = resolvePolicyRef(ref);
284
  let name = resolved.name;
285
+ let author = resolved.author;
286
  let actionScale = 1.0;
287
+ let manifest = null;
288
 
289
  if (resolved.manifestUrl) {
290
  onProgress("manifest");
 
291
  if (resolved.manifestOptional) {
292
+ // Sibling manifest of a direct URL / session: 404 or a network error
293
+ // just means "no manifest" (a plain walk policy), exactly like before
294
+ // the probe existed. Only present-and-incompatible manifests reject.
295
+ manifest = await fetchOptionalManifest(resolved.manifestUrl);
 
 
 
296
  } else {
297
  try {
298
  manifest = await fetchPolicyManifest(resolved.repoId);
 
307
  if (reason) throw new Error(reason);
308
  if (typeof manifest.name === "string" && manifest.name) name = manifest.name;
309
  if (Number.isFinite(manifest.action_scale)) actionScale = manifest.action_scale;
310
+ if (typeof manifest.academy?.author === "string" && manifest.academy.author) {
311
+ author = manifest.academy.author;
312
+ }
313
  }
314
  }
315
 
316
+ const slot = slotForManifest(manifest);
317
+ const kind = manifest?.kind ?? "perpetual";
318
+ const academy = manifest?.academy && typeof manifest.academy === "object" ? manifest.academy : null;
319
+ const title = academy?.prompt || manifest?.description || name;
320
+ const base = { kind, slot, name, title, author, academy, ref: String(ref), actionScale };
321
+
322
+ if (slot === "script") {
323
+ if (!resolved.scriptUrl) throw new Error("script moves need a manifest with a sibling move_script.json");
324
+ onProgress("script");
325
+ const res = await fetchOrThrow(resolved.scriptUrl, "move_script.json");
326
+ let raw;
327
+ try {
328
+ raw = await res.json();
329
+ } catch {
330
+ throw new Error("move_script.json is not valid JSON");
331
+ }
332
+ const script = normalizeMoveScript(raw);
333
+ return { ...base, script, durationS: script.durationS };
334
  }
335
+
336
+ onProgress("download");
337
+ const res = await fetchOrThrow(resolved.onnxUrl, "policy.onnx");
338
  const bytes = new Uint8Array(await res.arrayBuffer());
339
 
340
  onProgress("session");
 
348
  onProgress("validate");
349
  await validateSession(ort, session);
350
 
351
+ const claimed = Number(manifest?.duration_s ?? manifest?.episode_s ?? manifest?.episode?.duration_s);
352
+ const durationS = slot === "trick"
353
+ ? (Number.isFinite(claimed) && claimed > 0 ? Math.min(claimed, 30) : DEFAULT_TRICK_S)
354
+ : null;
355
+
356
  return {
357
+ ...base,
358
  session,
359
+ durationS,
 
 
360
  inputName: session.inputNames[0],
361
  outputName: session.outputNames[0],
362
  };
app/src/game/game.js CHANGED
@@ -57,7 +57,7 @@ import {
57
  playPropSweep, playLineBlip, setRumble,
58
  } from "./audio.js";
59
  import { createBallActor } from "./ball-actor.js";
60
- import { loadCustomPolicy as loadCustomPolicyModule } from "./custom-policy.js";
61
  import { initGhosts } from "./ghosts.js";
62
  import { makeInfiniteGrid, makeArenaWalls } from "./arena.js";
63
  import { createBallVisual } from "./ball-visual.js";
@@ -353,12 +353,21 @@ async function boot({ scene, camera, renderer }) {
353
  }
354
  donePolicies("7/7");
355
 
356
- // ── Community policy slot (walk only) ─────────────────────────────────
357
- // A validated Hub policy can replace sessions.walk; every other slot
358
- // (recovery, sitstand, kicks, roulade, rollers) stays official. The
359
- // official walker is pinned here so a swap is always reversible.
 
 
 
360
  const officialWalk = sessions.walk;
361
- let customPolicy = null; // { session, name, ref, actionScale }
 
 
 
 
 
 
362
  // Watchdog: abnormal-ending timestamps (recovery entries + solver
363
  // explosions) while a custom policy drives the duck. 3 within a rolling
364
  // 20 s window auto-reverts; ~10 s without a new event clears the slate
@@ -375,18 +384,27 @@ async function boot({ scene, camera, renderer }) {
375
  if (!customPolicy) return;
376
  const prev = customPolicy;
377
  customPolicy = null;
 
378
  watchdogEvents = [];
379
  sessions.walk = officialWalk;
 
 
380
  if (revertReason) {
381
  console.warn(`[policy] reverted "${prev.name}": ${revertReason}`);
382
  setStore({
383
- customPolicy: { ref: prev.ref, name: prev.name, status: "error", revertReason },
384
  });
385
  } else {
386
  setStore({ customPolicy: null });
387
  }
388
  }
389
 
 
 
 
 
 
 
390
  function watchdogAbnormalEnd(kind) {
391
  if (!customPolicy) return;
392
  const now = performance.now();
@@ -406,12 +424,15 @@ async function boot({ scene, camera, renderer }) {
406
  setStore({ customPolicy: { ref: String(ref), name: String(ref), status: "loading" } });
407
  try {
408
  const loaded = await loadCustomPolicyModule(ref, { ort });
409
- revertCustomPolicy(); // drop any previous custom walker first
410
  customPolicy = loaded;
411
  watchdogEvents = [];
412
- sessions.walk = loaded.session;
413
- setStore({ customPolicy: { ref: loaded.ref, name: loaded.name, status: "active" } });
414
- console.info(`[policy] custom walk policy active: ${loaded.name} (${loaded.ref})`);
 
 
 
415
  } catch (e) {
416
  const error = e?.message || String(e);
417
  console.warn(`[policy] load failed for "${ref}":`, e);
@@ -633,6 +654,7 @@ async function boot({ scene, camera, renderer }) {
633
  // Single reset path: Space, fall-kill, failed roll, loco switch.
634
  clearModeTimers();
635
  rollRun = null;
 
636
  kickRun = null;
637
  crouchRun = null;
638
  pickRun = null;
@@ -757,6 +779,12 @@ async function boot({ scene, camera, renderer }) {
757
  const gpZero = mode === "groundpick" || recovery !== null;
758
  cmd[3] = gpZero ? 0 : headSmooth[0]; cmd[4] = gpZero ? 0 : headSmooth[1];
759
  cmd[5] = gpZero ? 0 : headSmooth[2]; cmd[6] = gpZero ? 0 : headSmooth[3];
 
 
 
 
 
 
760
  for (let c = 0; c < CMD_SIZE; c++) obs[i++] = cmd[c];
761
  return obs;
762
  }
@@ -966,6 +994,17 @@ async function boot({ scene, camera, renderer }) {
966
  }
967
  stepAudioSim(); // footsteps / rumble / ball thumps off the fresh state
968
 
 
 
 
 
 
 
 
 
 
 
 
969
  // Watchdog housekeeping: a healthy stretch (no abnormal ending for
970
  // ~10 s) forgives earlier events, so player-caused falls spread over
971
  // a session never accumulate into a revert.
@@ -978,8 +1017,9 @@ async function boot({ scene, camera, renderer }) {
978
  if (death === "exploded") {
979
  haptics.pulse("explode");
980
  // Solver explosions only implicate the custom policy while it is the
981
- // one driving (legs walk); one-shots and rollers stay official.
982
- if (loco === "legs" && mode === "walk") watchdogAbnormalEnd("exploded");
 
983
  resetSim();
984
  } else if (recovery) {
985
  // Recovery state machine owns the duck: settle -> stand policy ->
@@ -1028,7 +1068,12 @@ async function boot({ scene, camera, renderer }) {
1028
  // same haptic thud, once - fallenSince latches until reset/upright.
1029
  if (fallenSince == null) haptics.pulse("fall");
1030
  fallenSince ??= now;
1031
- if (now - fallenSince > graceMs) resetSim();
 
 
 
 
 
1032
  }
1033
  } else {
1034
  fallDebounce = 0;
@@ -1089,15 +1134,20 @@ async function boot({ scene, camera, renderer }) {
1089
  if (obs[5] > -0.3) rollRun.tipped = true;
1090
  const upright = obs[5] < -0.85;
1091
  const done = rollRun.tipped && upright && rollRun.steps >= 40;
1092
- const expired = rollRun.steps >= 150; // 3 s, roll should long be over
 
 
1093
  if (done || expired) {
 
1094
  rollRun = null;
1095
  mode = "walk";
1096
  lastAction.fill(0);
1097
  // Timed out mid-roll: don't hand a tipped duck to the walking
1098
  // policy (it has no get-up skill).
1099
- if (!upright) resetSim();
1100
- else haptics.pulse("land"); // rolled through and stuck the landing
 
 
1101
  syncButtons();
1102
  }
1103
  }
@@ -1921,6 +1971,10 @@ async function boot({ scene, camera, renderer }) {
1921
  // the crouch-glide, exactly as the (now unbound) roll action did.
1922
  controller.on("sitToggle", ({ source } = {}) => {
1923
  if (loco !== "legs") return triggerCrouch(srcTag(source));
 
 
 
 
1924
  const sitting = mode === "sitstand" && sitFlag === 1;
1925
  setMode(sitting ? "walk" : "sit");
1926
  });
@@ -2009,11 +2063,30 @@ async function boot({ scene, camera, renderer }) {
2009
  clearModeTimers();
2010
  mode = "roll";
2011
  sitFlag = 0;
2012
- rollRun = { steps: 0, tipped: false };
 
 
 
 
 
2013
  syncButtons();
2014
  stickers?.pop("roll");
2015
  }
2016
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2017
  // Roller-only one-shot: crouch, glide low, stand back up (phase-driven).
2018
  function triggerCrouch(source = "kb") {
2019
  if (inputLocked || mode !== "walk" || locoSwitching || recovery) return;
@@ -2061,7 +2134,8 @@ async function boot({ scene, camera, renderer }) {
2061
  const sitting = mode === "sitstand" && sitFlag === 1;
2062
  const label =
2063
  recovery ? "Recovery"
2064
- : mode === "roll" ? "Roll"
 
2065
  : mode === "crouch" ? "Crouch"
2066
  : mode === "groundpick" ? "Pick"
2067
  : isKick() ? "Kick"
@@ -2109,6 +2183,8 @@ async function boot({ scene, camera, renderer }) {
2109
  loadCustomPolicy: loadCustomPolicyIntoGame,
2110
  clearCustomPolicy: revertCustomPolicy,
2111
  get customPolicy() { return customPolicy; },
 
 
2112
  get watchdogEvents() { return watchdogEvents.slice(); },
2113
  get loco() { return loco; },
2114
  get locoSwitching() { return locoSwitching; },
@@ -2164,9 +2240,10 @@ async function boot({ scene, camera, renderer }) {
2164
  // with READY. + fade on its own.
2165
  setStore({ bootDone: true });
2166
 
2167
- // ?policy=<ref> (parsed in App.jsx into the store): load the community
2168
- // policy now that the sim is up. Fire-and-forget - a bad ref surfaces
2169
- // its error in the HUD while the official walker keeps the duck alive.
 
2170
  const requestedPolicy = store().customPolicy;
2171
  if (requestedPolicy?.status === "loading" && requestedPolicy.ref) {
2172
  loadCustomPolicyIntoGame(requestedPolicy.ref);
 
57
  playPropSweep, playLineBlip, setRumble,
58
  } from "./audio.js";
59
  import { createBallActor } from "./ball-actor.js";
60
+ import { loadCustomPolicy as loadCustomPolicyModule, scriptCommandAt } from "./custom-policy.js";
61
  import { initGhosts } from "./ghosts.js";
62
  import { makeInfiniteGrid, makeArenaWalls } from "./arena.js";
63
  import { createBallVisual } from "./ball-visual.js";
 
353
  }
354
  donePolicies("7/7");
355
 
356
+ // ── Community move slots ──────────────────────────────────────────────
357
+ // A validated move mounts according to its manifest (Academy D7):
358
+ // perpetual -> sessions.walk (default) or sessions.sitstand (R key)
359
+ // episodic -> sessions.roll, played as a one-shot "trick" on R
360
+ // script -> a 13-D command timeline on the walker, toggled by R
361
+ // Every other slot (recovery, kicks, rollers) stays official. The
362
+ // official sessions are pinned here so a swap is always reversible.
363
  const officialWalk = sessions.walk;
364
+ const officialSitstand = sessions.sitstand;
365
+ const officialRoll = sessions.roll;
366
+ let customPolicy = null; // loadCustomPolicy result: { kind, slot, session?, script?, ... }
367
+ let scriptRun = null; // { t } while a kind "script" move drives the commands
368
+ // True while the custom session is the one producing this step's actions.
369
+ const customDriving = () =>
370
+ !!(customPolicy?.session && activeSession() === customPolicy.session);
371
  // Watchdog: abnormal-ending timestamps (recovery entries + solver
372
  // explosions) while a custom policy drives the duck. 3 within a rolling
373
  // 20 s window auto-reverts; ~10 s without a new event clears the slate
 
384
  if (!customPolicy) return;
385
  const prev = customPolicy;
386
  customPolicy = null;
387
+ scriptRun = null;
388
  watchdogEvents = [];
389
  sessions.walk = officialWalk;
390
+ sessions.sitstand = officialSitstand;
391
+ sessions.roll = officialRoll;
392
  if (revertReason) {
393
  console.warn(`[policy] reverted "${prev.name}": ${revertReason}`);
394
  setStore({
395
+ customPolicy: { ...storeMove(prev), status: "error", revertReason },
396
  });
397
  } else {
398
  setStore({ customPolicy: null });
399
  }
400
  }
401
 
402
+ // Store projection of a loaded move (no session / script objects in React).
403
+ const storeMove = (m) => ({
404
+ ref: m.ref, name: m.name, title: m.title ?? m.name, author: m.author ?? null,
405
+ kind: m.kind ?? "perpetual", slot: m.slot ?? "walk", academy: m.academy ?? null,
406
+ });
407
+
408
  function watchdogAbnormalEnd(kind) {
409
  if (!customPolicy) return;
410
  const now = performance.now();
 
424
  setStore({ customPolicy: { ref: String(ref), name: String(ref), status: "loading" } });
425
  try {
426
  const loaded = await loadCustomPolicyModule(ref, { ort });
427
+ revertCustomPolicy(); // drop any previous custom move first
428
  customPolicy = loaded;
429
  watchdogEvents = [];
430
+ if (loaded.slot === "walk") sessions.walk = loaded.session;
431
+ else if (loaded.slot === "sitstand") sessions.sitstand = loaded.session;
432
+ else if (loaded.slot === "trick") sessions.roll = loaded.session;
433
+ // "script": nothing to swap, the R key starts the command player.
434
+ setStore({ customPolicy: { ...storeMove(loaded), status: "active" } });
435
+ console.info(`[policy] custom ${loaded.kind} move active in slot ${loaded.slot}: ${loaded.name} (${loaded.ref})`);
436
  } catch (e) {
437
  const error = e?.message || String(e);
438
  console.warn(`[policy] load failed for "${ref}":`, e);
 
654
  // Single reset path: Space, fall-kill, failed roll, loco switch.
655
  clearModeTimers();
656
  rollRun = null;
657
+ scriptRun = null;
658
  kickRun = null;
659
  crouchRun = null;
660
  pickRun = null;
 
779
  const gpZero = mode === "groundpick" || recovery !== null;
780
  cmd[3] = gpZero ? 0 : headSmooth[0]; cmd[4] = gpZero ? 0 : headSmooth[1];
781
  cmd[5] = gpZero ? 0 : headSmooth[2]; cmd[6] = gpZero ? 0 : headSmooth[3];
782
+ // Command script (kind "script" move): the timeline owns the whole
783
+ // 13-D command vector while it plays on the walker. Movement input is
784
+ // ignored until R stops it (or the script ends, when it doesn't loop).
785
+ if (scriptRun && mode === "walk" && !recovery && customPolicy?.script) {
786
+ scriptCommandAt(customPolicy.script, scriptRun.t, cmd);
787
+ }
788
  for (let c = 0; c < CMD_SIZE; c++) obs[i++] = cmd[c];
789
  return obs;
790
  }
 
994
  }
995
  stepAudioSim(); // footsteps / rumble / ball thumps off the fresh state
996
 
997
+ // Command script clock: 50 Hz like everything here. A non-looping
998
+ // script ends on its own; a looping one runs until R (or a reset).
999
+ if (scriptRun) {
1000
+ scriptRun.t += CTRL_DT;
1001
+ const sc = customPolicy?.script;
1002
+ if (!sc || (!sc.loop && scriptRun.t >= sc.durationS)) {
1003
+ scriptRun = null;
1004
+ syncButtons();
1005
+ }
1006
+ }
1007
+
1008
  // Watchdog housekeeping: a healthy stretch (no abnormal ending for
1009
  // ~10 s) forgives earlier events, so player-caused falls spread over
1010
  // a session never accumulate into a revert.
 
1017
  if (death === "exploded") {
1018
  haptics.pulse("explode");
1019
  // Solver explosions only implicate the custom policy while it is the
1020
+ // one driving (its slot: walk, sitstand or the trick one-shot);
1021
+ // official one-shots and rollers never count.
1022
+ if (loco === "legs" && customDriving()) watchdogAbnormalEnd("exploded");
1023
  resetSim();
1024
  } else if (recovery) {
1025
  // Recovery state machine owns the duck: settle -> stand policy ->
 
1068
  // same haptic thud, once - fallenSince latches until reset/upright.
1069
  if (fallenSince == null) haptics.pulse("fall");
1070
  fallenSince ??= now;
1071
+ if (now - fallenSince > graceMs) {
1072
+ // A custom sit / trick that floors the duck counts for the
1073
+ // watchdog exactly like a recovery entry does for a walker.
1074
+ if (loco === "legs" && customDriving()) watchdogAbnormalEnd("fallen");
1075
+ resetSim();
1076
+ }
1077
  }
1078
  } else {
1079
  fallDebounce = 0;
 
1134
  if (obs[5] > -0.3) rollRun.tipped = true;
1135
  const upright = obs[5] < -0.85;
1136
  const done = rollRun.tipped && upright && rollRun.steps >= 40;
1137
+ // 3 s for the official roll (it should long be over); a custom
1138
+ // trick runs for its manifest duration instead.
1139
+ const expired = rollRun.steps >= rollRun.maxSteps;
1140
  if (done || expired) {
1141
+ const wasCustom = rollRun.custom;
1142
  rollRun = null;
1143
  mode = "walk";
1144
  lastAction.fill(0);
1145
  // Timed out mid-roll: don't hand a tipped duck to the walking
1146
  // policy (it has no get-up skill).
1147
+ if (!upright) {
1148
+ if (wasCustom) watchdogAbnormalEnd("fallen");
1149
+ resetSim();
1150
+ } else haptics.pulse("land"); // rolled through and stuck the landing
1151
  syncButtons();
1152
  }
1153
  }
 
1971
  // the crouch-glide, exactly as the (now unbound) roll action did.
1972
  controller.on("sitToggle", ({ source } = {}) => {
1973
  if (loco !== "legs") return triggerCrouch(srcTag(source));
1974
+ // A community trick / script takes over the R key while it is mounted
1975
+ // (a custom sitstand keeps the sit toggle: it IS the sit policy).
1976
+ if (customPolicy?.slot === "trick") return triggerRoll(srcTag(source));
1977
+ if (customPolicy?.slot === "script") return toggleScript();
1978
  const sitting = mode === "sitstand" && sitFlag === 1;
1979
  setMode(sitting ? "walk" : "sit");
1980
  });
 
2063
  clearModeTimers();
2064
  mode = "roll";
2065
  sitFlag = 0;
2066
+ // A custom episodic trick lives in the roll slot and runs for its own
2067
+ // duration (loadCustomPolicy clamps it); the official roulade keeps
2068
+ // its 3 s hard window.
2069
+ const custom = customPolicy?.slot === "trick" && sessions.roll === customPolicy.session;
2070
+ const maxSteps = custom ? Math.max(10, Math.round((customPolicy.durationS ?? 5) / CTRL_DT)) : 150;
2071
+ rollRun = { steps: 0, tipped: false, custom, maxSteps };
2072
  syncButtons();
2073
  stickers?.pop("roll");
2074
  }
2075
 
2076
+ // Kind "script" move: R toggles the command timeline on the walker.
2077
+ function toggleScript() {
2078
+ if (!customPolicy?.script) return;
2079
+ if (scriptRun) {
2080
+ scriptRun = null;
2081
+ syncButtons();
2082
+ return;
2083
+ }
2084
+ if (inputLocked || mode !== "walk" || standTimer || recovery || loco !== "legs") return;
2085
+ exitHeadMode();
2086
+ scriptRun = { t: 0 };
2087
+ syncButtons();
2088
+ }
2089
+
2090
  // Roller-only one-shot: crouch, glide low, stand back up (phase-driven).
2091
  function triggerCrouch(source = "kb") {
2092
  if (inputLocked || mode !== "walk" || locoSwitching || recovery) return;
 
2134
  const sitting = mode === "sitstand" && sitFlag === 1;
2135
  const label =
2136
  recovery ? "Recovery"
2137
+ : mode === "roll" ? (rollRun?.custom ? "Trick" : "Roll")
2138
+ : scriptRun ? "Script"
2139
  : mode === "crouch" ? "Crouch"
2140
  : mode === "groundpick" ? "Pick"
2141
  : isKick() ? "Kick"
 
2183
  loadCustomPolicy: loadCustomPolicyIntoGame,
2184
  clearCustomPolicy: revertCustomPolicy,
2185
  get customPolicy() { return customPolicy; },
2186
+ get scriptRun() { return scriptRun; },
2187
+ toggleScript,
2188
  get watchdogEvents() { return watchdogEvents.slice(); },
2189
  get loco() { return loco; },
2190
  get locoSwitching() { return locoSwitching; },
 
2240
  // with READY. + fade on its own.
2241
  setStore({ bootDone: true });
2242
 
2243
+ // ?move=<ref> / ?policy=<ref> / ?session=<id> (parsed in App.jsx into the
2244
+ // store): load the community move now that the sim is up. Fire-and-forget
2245
+ // - a bad ref surfaces its error in the HUD while the official walker
2246
+ // keeps the duck alive.
2247
  const requestedPolicy = store().customPolicy;
2248
  if (requestedPolicy?.status === "loading" && requestedPolicy.ref) {
2249
  loadCustomPolicyIntoGame(requestedPolicy.ref);
app/src/store.js CHANGED
@@ -29,10 +29,12 @@ export const useGame = create(
29
  locoSwitching: false,
30
  rollersLoading: false, // OSD line while the roller stack streams in
31
  variant: "classic",
32
- // Community walk policy: null = official walker. Written by the game
33
  // (game.js loadCustomPolicyIntoGame / the watchdog) and by App.jsx for
34
- // the boot-time ?policy= request.
35
- // { ref, name, status: "loading"|"active"|"error", error?, revertReason? }
 
 
36
  customPolicy: null,
37
  padConnected: false,
38
  touchMode: false,
 
29
  locoSwitching: false,
30
  rollersLoading: false, // OSD line while the roller stack streams in
31
  variant: "classic",
32
+ // Community move: null = all-official slots. Written by the game
33
  // (game.js loadCustomPolicyIntoGame / the watchdog) and by App.jsx for
34
+ // the boot-time ?move= request.
35
+ // { ref, name, status: "loading"|"active"|"error", error?, revertReason?,
36
+ // kind?: "perpetual"|"episodic"|"script", slot?: "walk"|"sitstand"|"trick"|"script",
37
+ // title?, author?, academy? (manifest.academy block) }
38
  customPolicy: null,
39
  padConnected: false,
40
  touchMode: false,
app/src/ui/Hud.jsx CHANGED
@@ -411,14 +411,19 @@ function Quickbar() {
411
  );
412
  }
413
 
414
- // Active community policy chip: rides the quickbar row in the same panel
415
- // language. Shows the policy's name and an X that swaps the official
416
- // walker back in (gameApi.clearCustomPolicy).
 
 
417
  function PolicyChip() {
418
  const cp = useGame((s) => s.customPolicy);
419
  if (cp?.status !== "active") return null;
 
 
 
420
  return (
421
- <HudPlate caption="Policy" captionFill="orange">
422
  <Box sx={{ display: "inline-flex", alignItems: "stretch", height: "100%" }}>
423
  <Box
424
  sx={{
@@ -426,20 +431,20 @@ function PolicyChip() {
426
  px: "0.85rem",
427
  cursor: "default",
428
  color: ORANGE,
429
- maxWidth: "14rem",
430
  overflow: "hidden",
431
  textOverflow: "ellipsis",
432
  display: "inline-block",
433
  lineHeight: `${CELL_H}px`,
434
  }}
435
- title={cp.ref}
436
  >
437
- {cp.name}
438
  </Box>
439
  <Box
440
  component="button"
441
  type="button"
442
- aria-label={`Unload policy ${cp.name}`}
443
  onClick={() => { uiClick(); gameApi.clearCustomPolicy?.(); }}
444
  sx={{
445
  ...hudHitSx,
@@ -532,7 +537,7 @@ function OsdLoad() {
532
  const policyLoading = useGame((s) => s.customPolicy?.status === "loading");
533
  const lines = [];
534
  if (rollersLoading) lines.push("LOADING ROLLERS");
535
- if (policyLoading) lines.push("LOADING POLICY");
536
  if (!lines.length) return null;
537
  return (
538
  <Box
@@ -582,8 +587,8 @@ function PolicyToast() {
582
  }, [cp, isError]);
583
  if (!isError) return null;
584
  const msg = cp.revertReason
585
- ? `POLICY REVERTED \u00b7 ${cp.revertReason}`
586
- : `POLICY LOAD FAILED \u00b7 ${cp.error ?? "unknown error"}`;
587
  return (
588
  <Box
589
  role="alert"
 
411
  );
412
  }
413
 
414
+ // Active community move chip: rides the quickbar row in the same panel
415
+ // language. "PLAYING: <prompt or name> by <owner>" (Academy moves carry
416
+ // their prompt and author in manifest.academy), a hint on the key that
417
+ // plays a trick / script, and an X that swaps the official slot back in
418
+ // (gameApi.clearCustomPolicy).
419
  function PolicyChip() {
420
  const cp = useGame((s) => s.customPolicy);
421
  if (cp?.status !== "active") return null;
422
+ const title = cp.title || cp.name;
423
+ const by = cp.author ? ` by ${cp.author}` : "";
424
+ const hint = cp.slot === "trick" || cp.slot === "script" || cp.slot === "sitstand" ? " \u00b7 R" : "";
425
  return (
426
+ <HudPlate caption="Playing" captionFill="orange">
427
  <Box sx={{ display: "inline-flex", alignItems: "stretch", height: "100%" }}>
428
  <Box
429
  sx={{
 
431
  px: "0.85rem",
432
  cursor: "default",
433
  color: ORANGE,
434
+ maxWidth: "22rem",
435
  overflow: "hidden",
436
  textOverflow: "ellipsis",
437
  display: "inline-block",
438
  lineHeight: `${CELL_H}px`,
439
  }}
440
+ title={`${cp.ref}${cp.kind ? ` (${cp.kind})` : ""}`}
441
  >
442
+ {`${title}${by}${hint}`}
443
  </Box>
444
  <Box
445
  component="button"
446
  type="button"
447
+ aria-label={`Unload move ${cp.name}`}
448
  onClick={() => { uiClick(); gameApi.clearCustomPolicy?.(); }}
449
  sx={{
450
  ...hudHitSx,
 
537
  const policyLoading = useGame((s) => s.customPolicy?.status === "loading");
538
  const lines = [];
539
  if (rollersLoading) lines.push("LOADING ROLLERS");
540
+ if (policyLoading) lines.push("LOADING MOVE");
541
  if (!lines.length) return null;
542
  return (
543
  <Box
 
587
  }, [cp, isError]);
588
  if (!isError) return null;
589
  const msg = cp.revertReason
590
+ ? `MOVE REVERTED \u00b7 ${cp.revertReason}`
591
+ : `MOVE LOAD FAILED \u00b7 ${cp.error ?? "unknown error"}`;
592
  return (
593
  <Box
594
  role="alert"