Compare commits

...

7 Commits

Author SHA1 Message Date
BenStullsBets 8e790b4a95 feat(dial): snap to the closest altitude on drag release
A drag is a way of grabbing the altitude dial, not a free-standing encoder
detent. Until now onDialUp held the live blend wherever the knob stopped (the
"no auto-complete" model), so a grab-and-release could leave the experience
parked mid-morph between two altitudes.

Now a real turn (moved >= 6) auto-scrubs the short way to Math.round(pos) and
settles there (frac 0 locks) — a grab-and-release always lands on a discrete
altitude, exactly like a tap or label click. The tap path (moved < 6 ->
jumpToScale) is unchanged, and reduced motion is handled by autoScrub (jump to
target, no tween).

Adds an altitude-lock e2e asserting a partial drag settles on an integer
altitude after release. Full altitude-lock (14) + a11y + i18n suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:50:51 -07:00
BenStullsBets 00fd9aab17 add sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--2026-06-30T12-00.md + replace placeholder/variant SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md 2026-06-30 10:39:44 -07:00
BenStullsBets 0334fc8bc4 claim human-experience-filter-art session 0036 (placeholder) + sessions.json entry 2026-06-30 10:36:01 -07:00
BenStullsBets a4d3d831b0 Merge remote-tracking branch 'origin/main' into chore/land-isolation 2026-06-30 10:20:33 -07:00
BenStullsBets d711db6a2c fix(guard): scope the edit-block to files INSIDE the repo
The PreToolUse guard blocked ALL edits while in the main checkout — including files
outside the repo (auto-memory, /tmp, scratchpad). Now it only denies edits to files
under the main checkout's working tree; external files are allowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:18:39 -07:00
BenStullsBets 17856cac32 chore(dev): enforce isolated worktrees outside main for every session
Multiple sessions sharing this one checkout clobbered each other (branch switches,
edits landing on the wrong branch). Enforce per-session isolation:
- worktree.bgIsolation=worktree + baseRef=fresh (native bg-session isolation)
- PreToolUse(Edit|Write|NotebookEdit) hook DENIES edits while in the main checkout
- SessionStart hook directs each session to EnterWorktree (fresh worktree, off main)
.claude/hooks/worktree-guard.sh detects main-vs-worktree via git-dir != git-common-dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:15:57 -07:00
BenStullsBets 1a6ffc74a1 feat(load): phase-1 preload gate + eligible-pick pool growth
Gate the universe on one clip per altitude + connecting morphs (~126 MB, ~10-20s),
then grow the pool in the background. The random pick (pickPoolClip/pickRandomMember)
is restricted to fully-loaded clips — base + connecting morphs both ways cached
(HEFPreload.eligibleDestinations) — so a transition never stalls on un-downloaded
media and new clips become reachable only once fully loaded. Pure planner in
preload.js (+ node tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:55:46 -07:00
10 changed files with 389 additions and 20 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Worktree isolation guard. This repo is worked by MULTIPLE concurrent sessions; if
# they share the main checkout they clobber each other (branch switches, lost edits,
# commits on the wrong branch). Every session must work in its OWN git worktree
# OUTSIDE the main directory.
#
# worktree-guard.sh block PreToolUse(Edit|Write|NotebookEdit) — DENY edits to
# files INSIDE the main checkout while in it. Files
# outside the repo (memory, /tmp, scratchpad) are allowed.
# worktree-guard.sh notice SessionStart — tell the agent to enter a worktree.
#
# Isolated = the session sits in a git worktree, where `--git-dir` (…/.git/worktrees/
# <name>) differs from `--git-common-dir` (…/.git). Equal = the shared main checkout.
set -u
mode="${1:-notice}"
gd=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 # not a repo → nothing to guard
gc=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) || exit 0
[ "$gd" = "$gc" ] || exit 0 # already in a worktree → allow
root=$(git rev-parse --show-toplevel 2>/dev/null)
repo=$(basename "${root:-repo}")
guidance="You are in the SHARED main checkout ($root). Multiple sessions use it \
concurrently and WILL clobber each other. Work in an isolated worktree OUTSIDE the \
main directory: use the EnterWorktree tool (preferred — it relocates this session to \
a fresh worktree branched from origin/main), or run \
\`git worktree add -b <branch> ../${repo}-worktrees/<name> origin/main\` and cd there. \
Do NOT edit files in the main checkout."
esc() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
if [ "$mode" = "block" ]; then
# Only guard files INSIDE the main checkout's working tree; allow edits to files
# outside the repo (auto-memory, /tmp, scratchpad, other repos).
fp=$(python3 -c 'import json,sys
try:
d=json.load(sys.stdin); print(d.get("tool_input",{}).get("file_path","") or "")
except Exception: print("")' 2>/dev/null)
case "$fp" in
/*) abs="$fp" ;;
"") abs="$root" ;;
*) abs="$root/$fp" ;;
esac
case "$abs" in
"$root"/*|"$root") : ;; # inside the repo → fall through to deny
*) exit 0 ;; # outside the repo → allow
esac
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(esc "$guidance")"
else
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$(esc "⚠ WORKTREE ISOLATION REQUIRED. $guidance")"
fi
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"worktree": {
"bgIsolation": "worktree",
"baseRef": "fresh"
},
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" notice"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" block"
}
]
}
]
}
}
@@ -0,0 +1,108 @@
# Session 0036.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T06-30 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: a7814ad4-473f-4946-b158-a5fad35b4bf1
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: finalized
## Launch prompt
"Let's publish this to benstull.art. Ideally this would be hosted on Cloudflare
Pages. Cloudflare Pages does have limits though, so we should check that against
this project." (Started conversationally — no tracked init; finalized as
planning-and-executing/yolo.)
## Pre-state
The simulator was a FastAPI server (`simulator/app.py`) serving a ~92 KB static
frontend + 3.9 GB of media (1080p clip bases + 558 already-720p transition morphs +
audio) via dynamic endpoints. Not yet public anywhere. Operator wanted it live at
benstull.art on Cloudflare Pages. Concurrent sessions (0029/0031/0033/0034/0035)
were active on the SAME checkout.
## Arc
**1. Brainstorm → design → plan (Cloudflare publish).** Established the blocker:
Pages has a 25 MiB/file hard limit; the project has 26 files >25 MiB. Designed a
fully-static target: Pages frontend + R2 media (no per-file limit, free egress) +
read-APIs baked to JSON + client-side pick + `/api/alteration` ported to JS.
Operator chose: both tiers irrelevant→dropped; path `/human-experience-simulator`
with apex redirect; no cross-session cache (initially). Spec + plan written under
`docs/superpowers/`. Operator declared the WHOLE project exempt from flotilla/PPE/§9.
**2. Executed the plan (5 tasks).** Client-side alteration engine (`alteration.js`
+ node/py parity tests); config-driven `mediaBase` + baked-JSON boot + client pick +
`crossOrigin` for the WebGL dream shader; `tools/build_static.py` (dist/ + manifest-
only ~2.3 GB media tree + `_redirects`); Cloudflare deploy artifacts; static-build
E2E. Hit + cleanly extracted a parallel session's in-flight "Run simulation" work
that got swept into a commit (operator chose extraction).
**3. Live deploy.** Installed wrangler; operator did `wrangler login`. Created R2
bucket `human-experience-simulator-media`, CORS (wrangler's `{rules:[...]}` schema,
not S3-style), bound `static.benstull.art` via CLI (zone-id from API), parallel
604-file upload (no `wrangler r2 sync` exists). Created + deployed Pages project;
operator bound `benstull.art`. **Live.**
**4. Debugging (systematic).** "Morphs not showing" = upload still in progress
(transitions sort last) → fixed by parallel upload. "Video + audio not playing" →
drove the LIVE site with Playwright: video fine, **audio 404'd** because
`scaleAudioUrl` hardcoded `/media/audio/` instead of `mediaBase()` — fixed.
"Still no video after hard refresh" → **stale cached JS** (Pages caches .js
max-age=14400, ignores `_headers`) → fixed with content-versioned asset URLs
(`app.js?v=<hash>`) + no-cache `_headers`. Private-window confirmed fresh code worked.
**5. Preload algorithm.** "Next altitude's video doesn't start" = a buggy
hold-last-frame settle gate I'd added (gated playLoop behind a canplay that doesn't
re-fire) → reverted. Operator specified the algorithm: gate on 1 clip/altitude +
connecting morphs (~126 MB, ~10-20s — measured), then grow the pool in the
background; the random pick is restricted to fully-loaded (eligible) clips so
navigation never stalls and new clips are reachable only once fully downloaded.
Built `preload.js` (pure planner + node tests), wired phase-1 gate + eligible picks.
**6. Worktree isolation enforcement.** The shared checkout caused branch-switch
chaos (my commits landed on a switched-to `main`). Operator: "make sure every
session works off a fresh worktree not in the main directory." Built
`.claude/settings.json` (worktree.bgIsolation + baseRef:fresh) + a PreToolUse hook
(`worktree-guard.sh`) that DENIES edits to repo files while in the main checkout +
SessionStart notice → EnterWorktree. Proven live (blocked my own edit); fixed an
over-broad version that blocked external files too. Dogfooded the merge of guard +
preload onto origin/main IN an isolated worktree.
## Cut state
- **Live:** https://benstull.art/human-experience-simulator/ — Pages + R2, preload
algorithm, audio fixed, cache-versioned. Operator confirmed video+audio work.
- **origin/main (`a4d3d83`+):** has the Cloudflare publish, preload algorithm, and
worktree-isolation guard, merged cleanly with parallel a11y work.
- Plan archived in-repo (`docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`),
on origin/main. **No `content` repo** → plan stays in-repo (app.json gap).
- Local `main` checkout is behind origin/main (parallel session actively deploying).
- Backup branches on origin: `chore/worktree-isolation`, `fix/preload-gate`,
`design/cloudflare-static-publish` (content all merged to main; safe to delete).
## Pipeline (§9) status
Operator-set Cloudflare exception (no flotilla/PPE). localhost + **E2E green** (33
node tests; 13 altitude-lock + 2 static-build Playwright); no PPE (exempt); **prod =
live** via `make deploy`. Repeatable deploy: `make deploy` / `make deploy-media`
(`deploy/cloudflare/`).
## Operator plate (next session)
- By-eye/ear review of the live site: load time, video, audio, rapid-altitude
navigation smoothness (eligible-pick growth), the preload behavior under real
network. The hold/grow path only triggers when media isn't cached — needs eyes.
- Restart any still-running sessions so they pick up the worktree guard + move to
worktrees. Clean up backup branches if desired.
## Deferred decisions
- Estimated session start time (no tracked init) — used 2026-06-30T06-30.
- Did NOT reconcile local `main` (behind origin/main) — a parallel session is
actively deploying origin/main; left the shared checkout untouched to avoid
disrupting it.
- Cross-session Service-Worker disk cache and a 720p base tier were proposed and
DEFERRED (morphs already 720p; bases dominate load but per-altitude growth covers it).
+3
View File
@@ -103,5 +103,8 @@
},
"0035": {
"title": ""
},
"0036": {
"title": ""
}
}
+29 -1
View File
@@ -276,7 +276,8 @@ test("crossing a detent commits and locks the destination clip", async ({ page }
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
// Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents),
// crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete).
// crossing integer 1 (orbit) so it commits, then hold past it WHILE STILL DRAGGING
// (the live blend holds wherever the knob is held; the snap happens only on release).
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 30, cy + 6, { steps: 14 });
@@ -293,3 +294,30 @@ test("crossing a detent commits and locks the destination clip", async ({ page }
expect(st2.activeClipId).toBe(st.activeClipId);
await page.mouse.up();
});
test("releasing a partial drag snaps to the closest altitude (no held mid-blend)", async ({ page }) => {
// The operator can grab the dial, turn it PART of a detent, and let go between two
// altitudes. On release the knob must auto-scrub to the CLOSEST altitude and settle
// there (integer position, frac 0) — landing on a discrete altitude exactly as if
// they had clicked it, rather than holding the half-finished blend.
await boot(page);
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // ~0.5 detent — parks BETWEEN altitudes
// While held we are mid-blend: a fractional knob position.
await page.waitForFunction(
() => { const p = (window as any).__hefState().pos; return Math.abs(p - Math.round(p)) > 0.05; },
null, { timeout: 5000 },
);
await page.mouse.up();
// After release the position settles on an integer altitude (the snap).
await page.waitForFunction(
() => Number.isInteger((window as any).__hefState().pos),
null, { timeout: 10000 },
);
const st = await page.evaluate(() => (window as any).__hefState());
expect(Number.isInteger(st.pos)).toBe(true); // landed on an altitude, not held mid-blend
expect(st.ringIndex).toBe(st.pos); // committed altitude == the position it landed on
});
+58 -16
View File
@@ -89,15 +89,30 @@ async function loadData() {
// advance (content-pipeline §11.1, Python owns the randomness). The synthesized
// fallback ring has a pool of one, so it just returns that member. Returns a
// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button.
// Lookups for the pure preload planner (HEFPreload): map clip/morph → media file and
// test the in-memory blob cache. A clip is "loaded" only when its file is a cached blob.
function _preloadDeps() {
return {
baseOf: (id) => (clipsById[id] || {}).base_file,
morphFile: (a, b) => morphByPair[`${a}${b}`] || null,
isCached: (f) => !!f && !!mediaBlobs[f],
};
}
function poolIds(scale) {
return ((scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }]).map((m) => m.clip_id);
}
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
// Uniform random pool member, client-side (mirrors hef.selection.pick_clip_id).
// The drag/scroll navigation already resolves picks + morphs client-side via
// scrub.js; this removes the last /api/ring/advance dependency so the build is
// fully static. The synthesized fallback ring has a pool of one → returns it.
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
// Show only a clip whose base is already loaded (eligible). Initially that's the
// phase-1 first member; the pool grows as the background preload finishes. Fallback
// to the first member (guaranteed loaded by the phase-1 gate).
const ids = poolIds(scale);
let elig = HEFPreload.eligibleMembers(ids, _preloadDeps());
if (!elig.length) elig = [ids[0]];
return HEFPreload.pick(elig, Math.random);
}
// Land on the current scale: pick a random pool member and force its media to load.
@@ -180,6 +195,18 @@ function preloadOrder() {
return ordered;
}
// Cache a fixed list of files (bounded concurrency), driving the "Loading Universe…"
// bar. Used for the phase-1 gate (one clip/altitude + connecting morphs) before unlock.
async function cacheMany(files, concurrency = 6) {
const total = files.length || 1;
let done = 0, i = 0;
setLoadingProgress(0, total);
async function worker() {
while (i < files.length) { await cacheClip(files[i++]); done++; setLoadingProgress(done, total); }
}
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
}
// Fetch one media file into the blob cache (idempotent — cached files are skipped).
async function cacheClip(file) {
if (!file || mediaBlobs[file]) return;
@@ -928,11 +955,18 @@ function angDelta(a, b) {
// destination pick (was the server's job in /api/ring/advance; moved here so the
// scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the
// canonical pure helper for the Pi player).
function pickPoolClip(index) {
function pickPoolClip(index, fromId) {
const n = ring.scales.length;
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
const ids = poolIds(s);
// Pick a DESTINATION only among clips fully loaded relative to fromId (base +
// morphs both ways) — so the transition never stalls on un-downloaded media. The
// eligible set grows as the background preload completes. Fallback to the first
// pool member (the phase-1 gate guarantees it + its connecting morphs are loaded).
let elig = fromId ? HEFPreload.eligibleDestinations(ids, fromId, _preloadDeps())
: HEFPreload.eligibleMembers(ids, _preloadDeps());
if (!elig.length) elig = [ids[0]];
return HEFPreload.pick(elig, Math.random);
}
// Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude
@@ -942,8 +976,8 @@ function pickPoolClip(index) {
function rebuildSegment(lo, enteredFrom) {
const n = ring.scales.length;
const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n);
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo);
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1);
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo, activeClipId);
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1, activeClipId);
const file = morphByPair[`${loId}${hiId}`] || null;
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
if (file) {
@@ -1042,7 +1076,11 @@ function onDialUp(e) {
}
return;
}
// No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model).
// Snap on release: a drag is a way of GRABBING the dial, not a free-standing
// encoder detent — so when the operator lets go between altitudes, auto-scrub the
// short way to the CLOSEST one and settle there (frac 0 locks). The result is the
// same as a tap/label-click: a grab-and-release always lands on a discrete altitude.
autoScrub(Math.round(pos));
}
// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
@@ -1515,12 +1553,16 @@ async function main() {
dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels
$("stage").addEventListener("wheel", onWheel, { passive: false });
update(); // render the initial state (both toggles off → black, silent)
// Preload BEFORE interaction: a base for every altitude + all related morphs, so
// turning the knob is always smooth (no on-demand stall, no blank next scene). The
// "Loading Universe…" bar tracks this; the universe stays gated until it's ready.
await preloadAllMedia();
// Phase 1 — gate the universe on a minimal, fully-loaded set: one clip per altitude
// + the morphs connecting them (~126 MB). Fast to start; the knob is smooth from the
// first turn because the random pick only ever lands on loaded clips (eligibility).
await cacheMany(HEFPreload.phase1Files(ring.scales, _preloadDeps()));
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
$("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage
// Phase 2 — grow the pool in the background: more clips per altitude + their morphs.
// Each becomes eligible for the random pick only once fully loaded, so navigation
// keeps using the already-loaded clips until the new ones are ready (no stall).
preloadAllMedia(); // NO await — streams the rest behind the unlocked universe
maybeShowMotionWarning(); // first-visit photosensitivity notice, above the button
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
// outside a user gesture (muted video survives, sound does not), so an auto-start
+1
View File
@@ -130,6 +130,7 @@
<script src="config.js"></script>
<script src="scrub.js"></script>
<script src="alteration.js"></script>
<script src="preload.js"></script>
<script src="flash.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
+52
View File
@@ -0,0 +1,52 @@
// Pure preload planning — no DOM. The boot caches phase1Files() before unlocking the
// universe; the random clip pick is then restricted to eligible clips (fully loaded
// base + connecting morphs), so a transition never stalls on un-downloaded media and
// the pool grows safely as the background preload finishes. UMD so the browser gets
// `HEFPreload` and `node --test` can require() it.
//
// deps shape: { baseOf(clipId)->file, morphFile(fromId,toId)->file|null, isCached(file)->bool }
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFPreload = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
// The minimal set to load before the simulator can run: the FIRST pool member of
// every altitude + the morphs connecting adjacent first-members (both directions,
// cyclic ring). One clip per altitude + corresponding morphs.
function phase1Files(scales, deps) {
const n = scales.length;
const chosen = scales.map((s) => (s.pool && s.pool.length ? s.pool[0].clip_id : s.clip_id));
const files = new Set();
for (const c of chosen) {
const bf = deps.baseOf(c);
if (bf) files.add(bf);
}
for (let i = 0; i < n; i++) {
const a = chosen[i], b = chosen[(i + 1) % n];
for (const f of [deps.morphFile(a, b), deps.morphFile(b, a)]) if (f) files.add(f);
}
return [...files];
}
// Pool members eligible as a DESTINATION from `fromId`: base cached AND the morph
// cached BOTH ways (so the forward transition and a turn-back are ready).
function eligibleDestinations(poolIds, fromId, deps) {
return poolIds.filter((c) =>
deps.isCached(deps.baseOf(c)) &&
deps.isCached(deps.morphFile(fromId, c)) &&
deps.isCached(deps.morphFile(c, fromId)));
}
// Pool members eligible to SHOW at their own altitude (no transition): base cached.
function eligibleMembers(poolIds, deps) {
return poolIds.filter((c) => deps.isCached(deps.baseOf(c)));
}
function pick(list, rnd) {
return list.length ? list[Math.floor(rnd() * list.length)] : null;
}
return { phase1Files, eligibleDestinations, eligibleMembers, pick };
});
+53
View File
@@ -0,0 +1,53 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const P = require("../static/preload.js");
// Tiny fixture: 3-altitude cyclic ring, 2 pool members each. base_file = "<id>.b";
// morph file = "<from>>-<to>" when it exists.
const scales = [
{ id: "a", pool: [{ clip_id: "a1" }, { clip_id: "a2" }] },
{ id: "b", pool: [{ clip_id: "b1" }, { clip_id: "b2" }] },
{ id: "c", pool: [{ clip_id: "c1" }, { clip_id: "c2" }] },
];
const baseOf = (id) => id + ".b";
const morphFile = (a, b) => `${a}>-${b}`; // pretend every pair has a morph
const cached = new Set();
const deps = () => ({ baseOf, morphFile, isCached: (f) => cached.has(f) });
test("phase1Files = first member of each altitude + connecting morphs (both dirs, cyclic)", () => {
const files = P.phase1Files(scales, deps());
// bases: a1.b b1.b c1.b
for (const b of ["a1.b", "b1.b", "c1.b"]) assert.ok(files.includes(b), `missing base ${b}`);
assert.ok(!files.includes("a2.b"), "second members must NOT be in phase 1");
// connecting morphs between adjacent first-members, both directions, cyclic (a-b, b-c, c-a)
for (const m of ["a1>-b1", "b1>-a1", "b1>-c1", "c1>-b1", "c1>-a1", "a1>-c1"])
assert.ok(files.includes(m), `missing morph ${m}`);
assert.equal(files.length, 3 + 6);
});
test("eligibleDestinations requires base + BOTH morphs cached", () => {
cached.clear();
// from a1, candidate b1: nothing cached → not eligible
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), []);
cached.add("b1.b"); cached.add("a1>-b1"); // base + forward only
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), [], "reverse morph still missing");
cached.add("b1>-a1"); // now reverse too
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), ["b1"]);
// b2 fully cached too → both eligible
cached.add("b2.b"); cached.add("a1>-b2"); cached.add("b2>-a1");
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()).sort(), ["b1", "b2"]);
});
test("eligibleMembers requires only the base cached", () => {
cached.clear();
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), []);
cached.add("a1.b");
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), ["a1"]);
});
test("pick returns null on empty, a member otherwise", () => {
assert.equal(P.pick([], Math.random), null);
assert.equal(P.pick(["x"], () => 0), "x");
assert.equal(P.pick(["x", "y", "z"], () => 0.99), "z");
});
+4 -3
View File
@@ -36,8 +36,8 @@ from simulator.app import MEDIA_DIR, create_app
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
"credits.html", "credits.js", "about.html", "flash.js"]
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js",
"style.css", "credits.html", "credits.js", "about.html", "flash.js"]
def _bake_api(app_dir: Path) -> dict:
@@ -84,7 +84,8 @@ def _version_assets(app_dir: Path) -> None:
# = guaranteed-fresh fetch on a normal reload. The HTML itself is served no-cache
# (_headers), but Cloudflare Pages caches .js/.css by type (max-age=14400) and
# ignores _headers for them — so without this, returning visitors run stale JS.
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "style.css", "credits.js", "config.js"]
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js", "flash.js",
"style.css", "credits.js", "config.js"]
tok = {}
for a in assets:
p = app_dir / a