Compare commits

..

3 Commits

Author SHA1 Message Date
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 244 additions and 233 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"
}
]
}
]
}
}
@@ -1,107 +0,0 @@
# Session 0034.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T07-24 (PST)
> End: 2026-06-30T10-01 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 08b95eb0-3ec5-400a-9236-da78f01139a0
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/fix+audio-and-tracking-labels
> Status: FINALIZED
## Launch prompt
> The audio isn't playing at all. Let's fix that and then let's make sure there are
> left and right brain tracking labels for everything. Make sure you're working on a
> worktree. There are other concurrent sessions that may have real merge conflicts to
> handle.
## Pre-state
- The simulator (`simulator/`) is a FastAPI + static-JS experience: an altitude dial
over 6 scales (cosmos/orbit/sky/coast/reef/abyss), 41 rotating-pool clips, a
real-time Kuwahara "dream" shader, two HUD label layers — **Think** (left-brain
factual chips, `#overlay`) and **Feel** (right-brain affect words, `#affect`) —
plus a per-altitude soundtrack with a 010 audio dial and a built-in
`#audio-status` diagnostic.
- s0027 had brought all 41 clips to left+right annotation with 4-lang parity, but the
left labels were thin (many clips 12) and STATIC; affect was authored PER-SCALE
(every clip in an altitude shared 4 feelings).
- Operator was running a stale `:8000` server on the `design/cloudflare-static-publish`
branch (a concurrent session's WIP — "Run simulation" begin-experience flow, +
later a credits page). Project is EXEMPT from the Wiggleverse deploy pipeline
(flotilla/PPE/§9); public target is static Cloudflare. cv2/imageio NOT installed in
this env.
## Session arc
1. **Worktree.** Created native worktree `fix+audio-and-tracking-labels` off
`origin/main` (582183d). All work done here.
2. **Audio (systematic-debugging) — FALSE ALARM.** Could not reproduce "no audio" in
ANY automated condition: Chromium + WebKit, strict autoplay
(`--autoplay-policy=user-gesture-required`), clean-main server AND the operator's
`:8000` WIP server, via Video-toggle / Audio-dial / "Run simulation" button; all 5
soundtracks decode to real non-silent audio (RMS 0.080.24). The raw
native-controls `#aud-test` player was ALSO silent on the operator's Safari while
the element reported playing → isolated it BELOW the app. Operator confirmed:
**their Safari sound was muted.** No code change.
3. **"Labels not showing" on cosmos_tarantula.** Root cause: operator was on `:8000`
(design branch), whose manifest had that clip's `annotations: []`. My branch
renders all labels correctly; labels are knob-gated (none at Think/Feel 0).
4. **Left (Think) enrichment.** Every clip → ≥3 factual labels (added one to each of
23 thin clips; orbit_bluemarble 1→3), full es/fr/ja parity. Pipeline:
`LABELS` in `build_pool_manifest.py` → build → catalogs → `translate_manifest.py
merge`. (commit `8cfceff`)
5. **Motion-tracking.** Converted the 4 still-static abyss creatures (octopus, bigfin
squid, dandelion siphonophore, sea pig) static→tracked, 3-keyframe paths sampled
by eye from frames (cv2 unavailable). Tracked clips 7→11. (`8cfceff`)
6. **Think/Feel overlap avoidance.** `renderAffect` now nudges Feel words off Think
chip plates (obstacles computed from manifest, tracked paths sampled → holds whole
loop, no per-frame jitter; new `window.__hefNoDeconflict` seam). Verified 0
overlaps across all 6 scales incl. a tracked clip over time. (commit `041fcde`)
7. **Per-clip right-brain feelings.** Operator: feelings should match each ACTUAL
video. Added `AFFECT_CLIP` (per-clip override, scale fallback) for all 41 clips →
41 distinct feeling-sets. cosmos done by me as a reviewed sample; the other 5
scales drafted by **5 parallel subagents** (one per scale) that each VIEWED footage
frames and followed the cosmos recipe; I normalized + authored all es/fr/ja for the
19 new nuance keys. 42 distinct feeling keys now. (commits `bf1013b` + `2d54023`)
8. **Merge.** Merged branch → `origin/main` (clean; main had only advanced by
`sessions/` transcript commits). Confirmed all 4 work commits are ancestors of
`origin/main`; another session (`feat/accessibility-pass`) has since merged main +
a11y on top (`380c2d1`).
## Cut state (final)
- All work committed, merged to `origin/main`, pushed. Working tree clean.
- Verification: 34/34 catalog+i18n+build pytest, 16/16 node unit, live render shows
≥3 Think chips + distinct Feel words per clip (incl. new keys) with overlap 0 and no
JS errors. (3 unrelated pytest failures = cv2/ffmpeg absent in this env.)
- Worktree RETAINED (not torn down) because it is serving the operator's by-eye review
at `http://127.0.0.1:8011/author.html`; tear down + stop `:8011` when review done.
- §9 deploy pipeline: N/A — project is exempt (static Cloudflare target).
## Deferred decisions (low-confidence calls)
- **Audio = no code change.** Chose to treat it as environmental after exhaustive
repro rather than guess-patch a working engine; operator confirmed muted Safari.
- **Left-label box positions authored WITHOUT viewing each frame** (23 enriched
clips, except bluemarble) — factual text is grounded, positions are estimates that
want by-eye review.
- **Per-clip feelings drafted from a single MID-FRAME** (motion not seen) via parallel
subagents — first-pass reads; emotions may want tuning once a clip is in motion.
- **Left reef/coast moving-subject clips left STATIC** (reef_redsea/flowergarden
schools, coast_otters/seals) — tracking deferred (diffuse subjects + cv2 absent).
- **Merged directly to `main` via `git push HEAD:main`** (no Gitea PR) at operator's
explicit request; gh is GitHub-only here and no `tea` CLI.
## Operator plate
- Review the first-pass label/track/feeling positions in `/author.html` on `:8011`.
- For Think: check each chip sits on its subject + the 4 abyss tracks follow the
creature. For Feel: check emotions ring true once clips are in motion.
## Next /goal
```
/goal refine the first-pass left-label box positions and the 4 abyss creature tracks per operator by-eye review (grab a frame per clip, nudge each chip onto its subject), and motion-track the remaining moving-subject clips (reef schools, coast animals) — data-only via build_pool_manifest + i18n pipeline
```
@@ -1,102 +0,0 @@
# Session 0035.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-45 (PST)
> End: 2026-06-30T10-08 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: ee3eaaac-d0a4-41a9-aa53-1919052fd8d3
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED**
## Launch prompt
Not a `/goal` launch. Opened conversationally: "We need to make the entire
simulator accessible, assuming it's not being used at a kiosk. What are the
different types of accessibility and your suggestions (and difficulty)?" Then:
build the first pass on a worktree, and add an `about.html` about the project's
intent (accessible to all humans; vantage points from underwater→drones→space
exploration; built with LLMs; honest that it's imperfect but more than the author
alone could make).
Drove through the **superpowers** pipeline (brainstorming → writing-plans →
executing-plans → finishing-a-development-branch), not a wgl init — so the wgl
transcript ID was claimed at finalize (this file).
## Pre-state
- Simulator built for an attended **kiosk**; the Cloudflare static publish
(`design/cloudflare-static-publish`, already merged to `main` mid-session as
`87d605d`) puts it on a public URL where no AT/attendant assumptions hold.
- No `prefers-reduced-motion`, no keyboard on the SVG Altitude dial, several
failing-contrast colors, no screen-reader path, no seizure safeguard — on a
piece that is continuous motion + a "trippy at max" WebGL shader.
## Arc (turn-by-turn)
1. **Analysis** — read `index.html` / `style.css`, grepped `app.js` (zero aria /
keyboard / reduced-motion). Enumerated 7 accessibility types grounded in the
actual UI, with difficulty + a priority table. Recommended WCAG 2.1 AA + a
motion/seizure layer.
2. **Brainstorming** — operator chose: reduced-motion = **freeze-to-stills**;
photosensitivity = **warning gate + flash audit**; about page = **English-first**.
Added the about-page "imperfect but more than alone" beat. Wrote + committed the
design spec.
3. **Worktree** — first tried `EnterWorktree` (branches off `origin/main`); on
inspecting the diff, the work belonged on `design/cloudflare-static-publish`
(correct `app.js`, existing `credits.html` pattern). Operator: "new worktree off
that, keep separate until ready for main." Created
`feat/accessibility-pass` off the design branch under `.claude/worktrees/`.
4. **Plan** — 10 bite-sized TDD tasks, committed.
5. **Execution (TDD, test-first each)** — (1) layout: lang picker below Audio,
globe inline-left; (2) AA contrast + focus-visible + visually-hidden; (3)
`flash.js` pure WCAG-2.3.1 helper + node tests; (4) one-time photosensitivity
gate; (5) reduced-motion freeze (single `playLoop()` guard + instant `autoScrub`);
(6) keyboard/ARIA dial (`role=slider`, Arrow/Home/End, label buttons); (7)
aria-live narration + `aria-hidden` decoratives; (8) wire flash-clamp + document
audit (no breach found); (9) `about.html` + header link + `PUBLIC_ASSETS`.
9 a11y e2e + 2 node tests, all green; fixed a gate-vs-run-sim regression in the
shared `boot()` helper.
6. **Finish** — operator: merge everything to main + push origin. `origin/main`
had advanced 29 commits (static-publish already landed there) → divergent merge;
one `app.js` conflict (main's D3 reverse-landing fix vs my reduced-motion guard)
resolved keeping **both**. Re-verified on merged code (a11y 9/9, altitude-lock
13/13, i18n 1/1, static-build 2/2, flash 2/2). Pushed `ff06783..380c2d1 main`.
Removed the worktree; stopped bg servers.
## Cut state
- **a11y pass + About page MERGED + PUSHED to `origin/main` (`380c2d1`).**
- On `main` but **NOT deployed** — live benstull.art needs a Cloudflare redeploy.
- App has **no content repo** → plan stays in-repo at
`docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md`. No
`submit-plan.sh`. Project is **exempt** from §9/flotilla/PPE (Cloudflare static).
- A **concurrent** commit `1a6ffc7` ("phase-1 preload gate") sits on local `main`
ahead of origin by 1 — **not this session's; left untouched/unpushed** per
operator ("leave anything that isn't yours alone"). Other worktree
`fix+audio-and-tracking-labels` also left alone.
- Checkout now on `main` (was `design/cloudflare-static-publish` at start).
## Deferred decisions
_Autonomous low-confidence calls (no live placeholder existed, so logged here):_
- **SR narration = scale + clip title**, not the tiered factual (Think) HUD label —
simpler/robust; the plan explicitly permitted the degrade. Could enrich later.
- **Flash audit concluded no >3/sec breach** (1200ms per-altitude floor; fades are
single, ≥1.2s apart); `flash.js` wired only as a guard floor (no-op today).
- **Branched off the design branch, not main** — corrected after operator input.
- **Merged the full divergent history to main** (static-publish was already on
origin/main, so net-new was just the a11y work) — on explicit operator instruction.
## Next /goal
```
/goal redeploy benstull.art/human-experience-simulator to Cloudflare so the accessibility pass + About page go live, per cloudflare-deploy-live (build with `python3 -m tools.build_static`, then the R2/Pages deploy gestures)
```
Operator by-eye review of the a11y changes precedes the redeploy. Env gotchas for
the next session: run `build_static` as a **module** (`python3 -m tools.build_static`)
or `tools/http.py` shadows stdlib `http`; uvicorn dies mid-session and the e2e
`webServer` fallback calls `python` (only `python3` here) → start it by hand on :8099;
Playwright `test.use()` inside `test.describe()` breaks collection in PW 1.45.
-6
View File
@@ -97,11 +97,5 @@
},
"0033": {
"title": ""
},
"0034": {
"title": ""
},
"0035": {
"title": ""
}
}
+53 -15
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) {
@@ -1515,12 +1549,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