From 5dd5efcf9911ac5e71e864cb93d02b43726c730e Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 13:26:28 -0500 Subject: [PATCH 01/20] plan: ramble phase 3, the egg supply overhaul --- .../plans/2026-09-09-ramble-eggs-phase3.md | 1938 +++++++++++++++++ 1 file changed, 1938 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md diff --git a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md new file mode 100644 index 00000000..13e4bd25 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md @@ -0,0 +1,1938 @@ +# Ramble — the egg supply overhaul (Phase 3) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eggs stop being free. The auto-minted successor is removed, the shelf auto-promotes into the empty slot, a bird with no eggs at all lays one after sustained care, and a prologue tells a new player who they are — because a new player on a build with no auto-mint and no starter egg has no egg, no bird, and no explanation. + +**Architecture:** Minting becomes deliberate. `ensureIncubatingEgg` — today called from four places, two of them pure reads — is renamed `mintIncubatingEgg` and called from exactly two: the starter grant and laying. Every other site reads with `getIncubatingEgg` and tolerates `null`. Auto-promote moves an egg the user already owns; laying is a day-count ledger in the existing `ramble_wallet` under `kind='layday'`, `delta` always `1`, exactly the shape phase 2 proved for hearts. No new table, no schema change. + +**Tech Stack:** Node 22 ESM, libsql, Leaflet in the panel client, Node test runner via `scripts/run-suite.mjs`. + +**Spec:** `docs/superpowers/specs/2026-09-08-ramble-reward-economy-design.md` — §4 entire (the egg loop), §6.1/§6.4 (ledgers and settings), §7, §8, and phase 3 of §9. Decisions D3 (warmth vanishes), D8 (egg supply), D9 (the floor), D12 (onboarding). + +**Phase 2 handoff (read it):** `docs/superpowers/handoffs/2026-09-09-ramble-hearts-phase2-shipped.md` — especially the three lessons, all of which this plan is shaped to avoid repeating. + +--- + +## Kevin's rulings for this phase (2026-09-09, before planning) + +| # | Question | Ruling | +|---|---|---| +| K1 | What does a player with an auto-minted egg incubating see on upgrade day? | **Nothing special — build no upgrade-day copy.** Kevin is the only player and intends to **reset to a new game** once phases 3 and 4 are done, specifically so he can experience this as a new player. The live egg is grandfathered silently: it stays, it hatches, no successor follows. **Binding consequence:** the starter-egg grant must be derived from replicated egg data ("has any egg ever existed"), never from a flag that survives a data wipe, and the two prologue flags must be reset-clearable — or the reset will not replay the prologue. | +| K2 | `lay.days` — 14 or 10? | **14**, the spec's number. It is a live setting; retuning is a config change. | +| K3 | Does the prologue ship in this phase? | **Yes, inside phase 3.** A new player on a build with no auto-mint and no starter egg has literally no egg and no way to be told why. The prologue is that player's only entry point — a functional dependency, not decoration. | +| K4 | How legible is laying while eggless? | **Named, with a soft count, in words not a bar.** Phase 3 removes the free egg; a floor you cannot see is not reassurance. The eggless card says how many good days you have had. | +| K5 | What is the incubating egg, given you ARE the bird? | **The next you.** You are whoever is active; the incubating egg is the next self coming; the flock is everyone you have been; laying is you laying it yourself. This is the shipped premise — map phase 1, at the operator's request: *"you are not carrying an egg, you ARE one — an egg that wandered off from its nest."* All copy in this phase is written from inside it. | + +--- + +## Three findings from reading the real code, which reshape the phase + +These were found before planning, against the code rather than the spec's description of it. Two contradict the spec. **Do not "fix" them back toward the spec's wording.** + +### Finding 1 — the naive null-tolerance change creates a death spiral. This is the important one. + +`feedAll` (`bundles/ramble/server/feed.js:42`) gates the **pet feed** on the warmth path's verdict: + +```js +const shouldFeedPet = KEYED_TYPES.has(event.type) ? credited === true : true; +// KEYED_TYPES = visit_place, meet_crow, checkin +``` + +So if `creditWarmth` returns `credited: false` when there is no egg — the obvious way to make it tolerate null — then **visiting a new place, meeting a crow and the daily check-in all stop feeding energy**, exactly while the player is eggless. And laying requires the bird to end the day happy *while eggless*. The naive change makes the floor unreachable: no egg → no energy → never happy → never lays. That is precisely the harshness this design exists to avoid, arriving through a side door. + +**Resolution, implemented in Task 2:** `credited` keeps its current meaning — *"this ledger key was new"* — and the ledger row is written whether or not an egg exists. Only the *landing* of warmth is skipped. Warmth genuinely vanishes with no egg (D3 intact, and the key is burned so the same place cannot pay it later), and the pet feed never depends on an egg existing. Task 2 has an executable regression test for exactly this. + +### Finding 2 — it is FIVE call sites, not four, and the fifth is UI + +The spec's four are correct (`eggState` and `flockState` pure reads, `creditWarmth`, `hatchIfReady`). But a fifth affordance depends on the auto-mint, and its own comment at `bundles/ramble/panel/ramble.js:310` says so: + +> *The successor egg is minted the moment one hatches, and the perch swaps to the bird for good — so without this the egg view (and its daily check-in) would be unreachable after the first hatch.* + +`paintPerch` sets `perchTarget = "pet"` permanently once a valid bird exists (`static/ramble.js:874`), so the **"Next egg" card is the only route to the egg view after the first hatch**. Hiding it when eggless deletes the daily check-in — phase 1's defect, repeated exactly. **It must change state, never disappear** (Task 8). + +### Finding 3 — the spec's starter-egg race protection cannot work + +Spec §4.4 says *"Deriving the starter egg's id from the Crow identity makes a simultaneous two-instance first run collapse into a single insert rather than granting two eggs."* `loadOrCreateIdentity` (`servers/sharing/identity.js`) generates a **random 32-byte seed per instance** and derives `crowId` from it — which is exactly how `from_crow_id` distinguishes gift senders. `crowId` is **per-instance, not per-user**, so deriving from it yields two *different* ids on two instances and grants **two** eggs: the outcome it was written to prevent. + +A fixed constant id is also wrong: a contact could gift you *their* starter egg and the ids would collide in `ramble_eggs` (`receivedEggStatement` upserts by `egg_id`). + +**Resolution, implemented in Task 6:** gate the grant on `SELECT COUNT(*) FROM ramble_eggs = 0` with a random `randomUUID()`, and rely on the **existing** convergence rule. If a genuine simultaneous-first-run race ever happened, `applyRambleEgg`'s "one incubating egg" rule (`servers/sharing/instance-sync.js:721`) keeps the older as incubating and **shelves** the younger with its warmth intact — a spare egg on the shelf, not a duplicate disaster. That machinery is already built and already tested; this phase adds nothing to it. + +--- + +## Global Constraints + +- **Base:** `origin/main` @`fa472645`. Worktree `/home/kh0pp/crow-wt-ramble-eggs`, branch `feat/ramble-eggs`, already created. **Never `git checkout` in `~/crow`** — a gateway checkout parked off `main` silently disables fleet auto-update. **Never `cp -a` a worktree** — its `.git` is a pointer file and the copy commits to the real branch. +- **Node/test harness:** `export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH` before any node/npm command. Run tests ONLY as `node scripts/run-suite.mjs tests/.test.js` from the worktree, in the FOREGROUND. **NEVER run bare `node --test`** — it writes to the LIVE production database. Never boot a gateway or MCP server without a scratch `CROW_DATA_DIR`. +- **⚠ The worktree needs `node_modules`.** It is a symlink to the main checkout's: `ln -s /home/kh0pp/crow/node_modules node_modules`. It is already in place. **`git status` shows it as untracked** — `.gitignore`'s trailing-slash `node_modules/` does not match a symlink. This is exactly why every commit in this plan uses positional paths; a bare `git add -A` would commit the symlink. +- **Suite baseline:** **4340 pass / 0 fail at `fa472645`**, measured in this worktree with the symlink in place. The suite must not regress. (A run without `node_modules` reports ~552 failures — that is a missing symlink, not a regression.) +- **No schema change, no migration, no `SCHEMA_GENERATION` bump.** Laying rides `ramble_wallet` (`kind`, `key`, `delta`, `created_at`, `lamport_ts`); prologue flags ride `ramble_settings`. `scripts/init-db.js` is **not modified**. `bundles/ramble/server/init-tables.js` is modified for **a comment only** (Task 3) — its SQL must be byte-identical. +- **Ledgers, not balances (spec §6.1):** a lay-day is an append-only row under a natural idempotent key (the local day). Never store a day count. +- **A `layday` row's `delta` is ALWAYS the integer `1`.** `applyRambleWallet` resolves conflicts with `MAX(delta)`, which is only convergent when the value cannot differ between instances for the same key. Phase 2 shipped a bug here precisely because seed's `delta` was a live setting. **Never write a negative delta** — `MAX(delta)` would resolve `-10`/`-5` to `-5`. +- **You are never simultaneously birdless and eggless.** `hatchIfReady` sets `active_egg_id` on the first hatch and eggless can only occur after a hatch, so the eggless state always has a bird to show. Copy may rely on this; code should still not crash if it is false. +- **Panel client rules, test-enforced:** `bundles/ramble/panel/static/ramble.js` must keep **ZERO backticks** (one truncates the served script; the slip is markdown habit in a code comment), **EXACTLY TWO** engine markup sinks, `textContent` only, and no emoji. `setAttribute`/`removeAttribute`/`className`/Leaflet layer calls are not markup sinks. Build every new node with `createElement` + `textContent`; put new *markup* in the server-rendered shell `panel/ramble.js` and toggle it with `hidden`. +- **Invisible characters:** write any bidi/control character as a `\u` escape, never a raw byte. +- **Commits:** subject-only message, positional paths (`git commit -m "..."`, never `git add -A`), `git add` new files first. **NO AI-attribution trailers of any kind.** +- **Bundle bump:** `bundles/ramble/manifest.json` AND `bundles/ramble/package.json` `0.10.0` -> `0.11.0`; then `npm run build-registry`. Without the bump, `repairInstalledBundleAssets` never refreshes the installed copy on grackle and the deploy silently ships nothing. +- **⚠ There is NO CI gate for guide-doc i18n parity.** `tests/i18n-global-parity.test.js` covers the translation-key mechanism only; nothing diffs `docs/guide/*.md` against `docs/es/guide/*.md`. Phase 2's plan claimed such a gate exists. It does not. Update `docs/es/guide/ramble.md` by hand and check it by hand. +- **`gh` is NOT installed on crow.** Open the PR through the `github` MCP server; poll CI with `curl` against `/commits//check-runs` (contexts `suite`, `static-checks`, `audit`). +- **Privacy (spec §2.4, §7):** nothing in this phase may add an egg, a lay-day or a balance to any contact-facing payload. `delivery.js`, `trades.js` and `nostr-map.js` keep their existing wire shapes. + +## Deviations from the spec, recorded + +1. **The starter-egg id is a random UUID gated on an empty egg table, not derived from the Crow identity.** See Finding 3 — the spec's mechanism is impossible because `crowId` is per-instance. Say this in the PR body so a reviewer reads it as a correction, not drift. +2. **`creditWarmth` still writes its ledger row when there is no egg.** See Finding 1. The spec does not describe the interaction with `feedAll`'s `shouldFeedPet` gate at all; without this, laying is unreachable. +3. **Auto-promote runs lazily on read paths as well as on hatch.** Spec §4.2 says "when the incubating slot empties" without saying who notices. A slot can empty from a sync arrival that no local code path observes, so the read paths carry a *guarded* promote. This is a write during a GET and a reviewer should challenge it — the justification is that it moves an egg the user already owns rather than conjuring one, and Task 3 has a test asserting a read with nothing promotable writes nothing. + +--- + +## File structure + +**Create** +- `bundles/ramble/server/egg-locks.js` — `OPEN_SQL`, `isEggLocked`, `lockedEggIds`, moved verbatim from `trades.js`. A leaf module with no imports. **Why it exists:** auto-promote must skip an egg named by an open swap, but `trades.js` imports `startOfLocalDay` from `eggs.js`, so `eggs.js` cannot import `trades.js` without a cycle. +- `tests/ramble-eggs-supply.test.js` — null tolerance, the feed decoupling, auto-promote, convergence. +- `tests/ramble-laying.test.js` — the lay-day ledger and the floor. +- `tests/ramble-prologue.test.js` — the starter grant and the two flags. + +**Modify** +- `bundles/ramble/server/trades.js` — import the three lock symbols from `egg-locks.js` and re-export `isEggLocked`/`lockedEggIds` so existing consumers are untouched. +- `bundles/ramble/server/eggs.js` — the bulk: rename, null tolerance, promote, laying, starter grant, prologue flags. +- `bundles/ramble/server/flock.js` — drop the mint from `flockState`; import the locks from the leaf. +- `bundles/ramble/server/pet.js` — call the lay-day recorder after mood is known. +- `bundles/ramble/server/feed.js` — `readPet` carries the egg summary and lay progress. +- `bundles/ramble/server/init-tables.js` — **comment only** (the `shelf_origin` comment says `'user'` must never be auto-promoted; that rule is the *sync layer's*, and phase 3 adds an app-level promote that deliberately does). +- `bundles/ramble/panel/routes.js` — null-tolerant egg/pet/flock responses; the two prologue routes. +- `bundles/ramble/server/server.js` — `ramble_egg_state` / `ramble_pet_state` MCP tools must agree with the HTTP routes. +- `bundles/ramble/panel/ramble.js` — the eggless states on the egg view and the Next egg card; the prologue overlay markup; the framing copy fixes. +- `bundles/ramble/panel/static/ramble.js` — null-tolerant painters, the prologue, the AR fix, the perch labels. +- `bundles/ramble/panel/static/ramble.css` — prologue overlay and eggless-card rules. +- `tests/ramble-eggs.test.js`, `tests/ramble-flock.test.js`, `tests/ramble-trades.test.js`, `tests/ramble-sync.test.js`, `tests/ramble-panel.test.js`, `tests/ramble-tools.test.js` — updated in the task that changes the behaviour they cover. +- `bundles/ramble/manifest.json`, `bundles/ramble/package.json`, `registry/add-ons.json`. +- `docs/guide/ramble.md`, `docs/es/guide/ramble.md`. + +**Explicitly NOT modified:** `scripts/init-db.js`, `servers/sharing/instance-sync.js`, `servers/shared/sync-stamp.js`, `servers/sharing/profile-avatar.js`, `bundles/ramble/server/delivery.js`, `bundles/ramble/server/nostr-map.js`, `bundles/ramble/server/hearts.js`, `bundles/ramble/server/cells.js`. + +--- + +## Task 1: `egg-locks.js` — break the cycle before anything needs it + +**Files:** +- Create: `bundles/ramble/server/egg-locks.js` +- Modify: `bundles/ramble/server/trades.js` +- Modify: `bundles/ramble/server/flock.js` (import site only) + +**Interfaces:** +- Produces: `OPEN_SQL` (string), `isEggLocked(db, eggId) -> Promise`, `lockedEggIds(db) -> Promise>`. +- Consumed by: Task 3 (`eggs.js` auto-promote), and by `trades.js`/`flock.js` unchanged in behaviour. + +**Why:** `trades.js` line 32 imports `startOfLocalDay` from `eggs.js`. Task 3 needs the lock predicate inside `eggs.js`. Importing `trades.js` from `eggs.js` would make `eggs -> trades -> eggs`. ESM tolerates some cycles, but a reviewer will and should reject one; a leaf module is the honest fix and keeps a single source of truth for the lock rule. + +- [ ] **Step 1: Find the current definitions** + +```bash +cd /home/kh0pp/crow-wt-ramble-eggs +grep -n "OPEN_SQL\|isEggLocked\|lockedEggIds" bundles/ramble/server/trades.js bundles/ramble/server/flock.js +``` + +Record the exact `OPEN_SQL` text — it must move **verbatim**. Changing the open-trade predicate would silently change which eggs are giftable. + +- [ ] **Step 2: Create the leaf module** + +Create `bundles/ramble/server/egg-locks.js`, pasting the real `OPEN_SQL` from Step 1 in place of the placeholder comment: + +```js +/** + * Ramble egg locks — is this egg spoken for by an open swap? + * + * A LEAF module: it imports nothing, deliberately. The rule lived in + * trades.js, but trades.js imports `startOfLocalDay` from eggs.js, and phase + * 3's auto-promote (eggs.js) must skip a locked egg — so keeping it there + * would force an eggs -> trades -> eggs cycle. trades.js re-exports both + * helpers, so every existing consumer is unchanged and there is still exactly + * one definition of "locked". + */ + +// Verbatim from trades.js:68. An "open" trade is one that still has a claim on +// the egg; changing this set would silently change which eggs are giftable. +export const OPEN_SQL = "state IN ('proposed', 'accepted')"; + +export async function lockedEggIds(db) { + const { rows } = await db.execute({ + sql: `SELECT my_egg_id FROM ramble_trades WHERE my_egg_id IS NOT NULL AND ${OPEN_SQL}`, + args: [], + }); + return new Set(rows.map((r) => r.my_egg_id)); +} + +export async function isEggLocked(db, eggId) { + const { rows } = await db.execute({ + sql: `SELECT 1 FROM ramble_trades WHERE my_egg_id = ? AND ${OPEN_SQL} LIMIT 1`, + args: [eggId], + }); + return rows.length > 0; +} +``` + +- [ ] **Step 3: Point `trades.js` at it and re-export** + +In `bundles/ramble/server/trades.js`: delete the local `OPEN_SQL`, `lockedEggIds` and `isEggLocked` definitions, and add to the import block: + +```js +import { OPEN_SQL, isEggLocked, lockedEggIds } from "./egg-locks.js"; + +// Re-exported so existing importers (flock.js, panel/routes.js) need no change +// and there is still one definition of "locked". +export { isEggLocked, lockedEggIds }; +``` + +**⚠ `LOCK_GUARD_SQL` (line 69) and `GIFTABLE_GUARD_SQL` (line 71) sit immediately below the old `OPEN_SQL` and interpolate it at module load.** They stay in `trades.js` untouched — the imported `OPEN_SQL` feeds them exactly as the local const did. Deleting the const without adding the import breaks the module at load, not at test time. Leave every other use of `OPEN_SQL` inside `trades.js` exactly as it was. + +- [ ] **Step 4: Point `flock.js` at the leaf directly** + +```js +// was: import { isEggLocked, lockedEggIds } from "./trades.js"; +import { isEggLocked, lockedEggIds } from "./egg-locks.js"; +``` + +- [ ] **Step 5: Run the affected suites — this task must be behaviour-neutral** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +cd /home/kh0pp/crow-wt-ramble-eggs +node scripts/run-suite.mjs tests/ramble-trades.test.js +node scripts/run-suite.mjs tests/ramble-flock.test.js +``` + +Expected: both PASS with the same counts as on `origin/main`. A pure move must change no test. + +- [ ] **Step 6: Commit** + +```bash +git add bundles/ramble/server/egg-locks.js +git commit bundles/ramble/server/egg-locks.js bundles/ramble/server/trades.js bundles/ramble/server/flock.js \ + -m "ramble: move the egg-lock predicate to a leaf module" +``` + +--- + +## Task 2: the egg may be absent — reads stop minting, and the feed stops depending on it + +**Files:** +- Modify: `bundles/ramble/server/eggs.js` +- Create: `tests/ramble-eggs-supply.test.js` +- Modify: `tests/ramble-eggs.test.js`, `tests/ramble-flock.test.js`, `tests/ramble-trades.test.js`, `tests/ramble-sync.test.js` (import rename only) + +**Interfaces:** +- Produces: `mintIncubatingEgg(db, { now, emit }) -> Promise` (renamed from `ensureIncubatingEgg`, body unchanged), `getIncubatingEgg(db) -> Promise` (now exported). +- `eggState(db, { now })` now returns `{ egg: null | {...}, checklist: {...} }`. +- `creditWarmth` return shape is unchanged; its `credited` semantics are preserved deliberately (Finding 1). + +**Why the rename:** the whole point of this phase is that minting is now deliberate. A function still called `ensureIncubatingEgg` invites a future caller to re-introduce exactly the bug being removed. Tests use it as a fixture and keep working under the new name. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ramble-eggs-supply.test.js`: + +```js +/** + * Spec 2026-09-08 §4.1 — the auto-minted egg is gone, and NOTHING recreates + * it by being looked at. + * + * ⚠ The test that matters most here is "walking still feeds you with no egg". + * feedAll gates the PET feed on creditWarmth's `credited`, so making + * creditWarmth report not-credited when there is no egg would stop energy + * arriving exactly while the player is eggless — and laying (Task 5) needs + * happy days while eggless. That is a death spiral, not a rough edge. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createClient } from "@libsql/client"; +import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; +import { + mintIncubatingEgg, getIncubatingEgg, eggState, creditWarmth, localDay, +} from "../bundles/ramble/server/eggs.js"; +import { feedAll } from "../bundles/ramble/server/feed.js"; + +const T0 = Date.UTC(2026, 8, 9, 12, 0, 0); + +async function freshDb() { + const db = createClient({ url: ":memory:" }); + await initRambleTables(db); + return db; +} + +async function eggCount(db) { + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_eggs", args: [] }); + return Number(rows[0].n); +} + +test("eggState on a fresh db creates NO egg and reports egg: null", async () => { + const db = await freshDb(); + const state = await eggState(db, { now: T0 }); + assert.equal(state.egg, null, "no egg exists, so none is reported"); + assert.equal(await eggCount(db), 0, "a pure read must not mint"); + assert.ok(state.checklist, "the checklist still renders with no egg"); +}); + +test("eggState is still a pure read when an egg DOES exist", async () => { + const db = await freshDb(); + await mintIncubatingEgg(db, { now: T0 }); + const state = await eggState(db, { now: T0 }); + assert.ok(state.egg, "the egg is reported"); + assert.equal(state.egg.warmth, 0); + assert.equal(state.egg.percent, 0); + assert.equal(await eggCount(db), 1, "reading twice must not mint a second"); + await eggState(db, { now: T0 }); + assert.equal(await eggCount(db), 1); +}); + +test("creditWarmth with no egg: the ledger row is written, warmth vanishes (D3)", async () => { + const db = await freshDb(); + const out = await creditWarmth(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 }); + assert.equal(out.credited, true, "credited means THE KEY WAS NEW, not that an egg received it"); + assert.equal(out.warmth, 0); + assert.equal(out.hatched, null); + assert.equal(await eggCount(db), 0, "crediting warmth must never mint an egg"); + + const { rows } = await db.execute({ + sql: "SELECT count(*) AS n FROM ramble_credits WHERE kind = 'visit_place'", args: [], + }); + assert.equal(Number(rows[0].n), 1, "the key is burned: D3 says the warmth is wasted, not banked"); + + // Same place again in the same week is still a no-op. + const again = await creditWarmth(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 }); + assert.equal(again.credited, false); +}); + +test("REGRESSION: walking, meeting a crow and checking in ALL still feed energy with no egg", async () => { + const db = await freshDb(); + const before = await feedAll(db, { type: "checkin" }, { now: T0 }); + assert.equal(await eggCount(db), 0, "feeding must not mint an egg"); + assert.ok(before.pet, "a pet is always returned"); + assert.ok(before.pet.energy > 0, "the check-in fed the bird even with no egg"); + + const place = await feedAll(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 + 1000 }); + const crow = await feedAll(db, { type: "meet_crow", persona: "abc123" }, { now: T0 + 2000 }); + assert.ok(place.pet.energy >= before.pet.energy, "a new place fed the bird"); + assert.ok(crow.pet.energy >= place.pet.energy, "meeting a crow fed the bird"); + assert.equal(await eggCount(db), 0); +}); + +test("an unknown or pet-only event is still a pure read with no egg", async () => { + const db = await freshDb(); + await creditWarmth(db, { type: "chore" }, { now: T0 }); + await creditWarmth(db, { type: "nonsense" }, { now: T0 }); + assert.equal(await eggCount(db), 0); + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_credits", args: [] }); + assert.equal(Number(rows[0].n), 0, "chore/unknown never touch the ledger"); +}); + +test("getIncubatingEgg is a plain read that returns null rather than throwing", async () => { + const db = await freshDb(); + assert.equal(await getIncubatingEgg(db), null); + const egg = await mintIncubatingEgg(db, { now: T0 }); + const read = await getIncubatingEgg(db); + assert.equal(read.egg_id, egg.egg_id); +}); +``` + +- [ ] **Step 2: Run it to watch it fail** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +cd /home/kh0pp/crow-wt-ramble-eggs +node scripts/run-suite.mjs tests/ramble-eggs-supply.test.js +``` + +Expected: FAIL — `mintIncubatingEgg` is not exported. + +- [ ] **Step 3: Rename the mint and export the read** + +In `bundles/ramble/server/eggs.js`: + +```js +/** + * Insert a fresh incubating egg. THE ONLY MINTING PRIMITIVE — as of phase 3 + * it is called from exactly two places, the starter grant and laying, and + * both are deliberate acts. It was called `ensureIncubatingEgg` and was + * invoked from four sites, two of them pure reads (`eggState` on every + * GET /api/ramble/egg, `flockState` on every flock screen), so merely looking + * at a screen recreated the egg. Read with `getIncubatingEgg` instead; the + * name is "mint" so that a future caller has to mean it. + * + * The INSERT ... SELECT ... WHERE NOT EXISTS guard (rather than a unique + * index) makes this race-free within one process on a single SQLite + * connection: two overlapping calls each attempt the guarded insert, only one + * succeeds, and both re-select the same egg. + */ +export async function mintIncubatingEgg(db, { now, emit } = {}) { + // ...body unchanged from ensureIncubatingEgg... +} +``` + +Change `async function getIncubatingEgg(db)` to `export async function getIncubatingEgg(db)`. + +- [ ] **Step 4: Make `eggState` a pure read** + +```js +export async function eggState(db, { now } = {}) { + const egg = await getIncubatingEgg(db); + const weights = await readWarmthWeights(db); + const percent = egg && weights.hatch_at > 0 + ? Math.max(0, Math.min(100, Math.round((egg.warmth / weights.hatch_at) * 100))) + : 0; + // ...the three checklist queries are unchanged... + return { + egg: egg + ? { egg_id: egg.egg_id, warmth: egg.warmth, hatch_at: weights.hatch_at, percent } + : null, + checklist: { new_places_week: newPlacesWeek, first_mark: firstMark, checked_in_today: checkedInToday }, + }; +} +``` + +- [ ] **Step 5: Decouple `creditWarmth` from the egg's existence** + +Replace the `const egg = await ensureIncubatingEgg(...)` line and the warmth update that follows: + +```js + // ⚠ NOT a mint. With no egg the ledger row is STILL written and `credited` + // is still true, because `credited` means "this key was new" and + // feedAll's `shouldFeedPet` gate reads it: reporting not-credited here + // would stop new places, crows and check-ins from feeding the bird for as + // long as the player is eggless — and laying needs happy days while + // eggless. The warmth itself vanishes (spec D3) and the key is burned, so + // the same place cannot bank warmth for a later egg. + const egg = await getIncubatingEgg(db); + + if (key) { + const { rowsAffected } = await db.execute({ + sql: "INSERT OR IGNORE INTO ramble_credits (kind, key, credited_at) VALUES (?, ?, ?)", + args: [key.kind, key.key, now], + }); + if (rowsAffected === 0) { + const current = await getIncubatingEgg(db); + return { credited: false, warmth: current ? current.warmth : 0, hatched: null }; + } + } + + if (!egg) return { credited: true, warmth: 0, hatched: null }; + + const weights = await readWarmthWeights(db); + const delta = weights[event.type] ?? 0; + const newWarmth = Math.max(0, Math.min(weights.hatch_at, egg.warmth + delta)); + // ...the UPDATE, re-select, emit and hatchIfReady tail are unchanged... +``` + +Also update `notCredited()` to use `getIncubatingEgg` (it already did, via the private name). + +- [ ] **Step 6: Update the four test files' imports** + +```bash +cd /home/kh0pp/crow-wt-ramble-eggs +sed -i 's/\bensureIncubatingEgg\b/mintIncubatingEgg/g' \ + tests/ramble-eggs.test.js tests/ramble-flock.test.js tests/ramble-trades.test.js tests/ramble-sync.test.js +grep -rn "ensureIncubatingEgg" tests/ bundles/ | grep -v node_modules +``` + +Expected from the grep: **no hits**. `servers/sharing/instance-sync.js:726` mentions the old name in a doc comment — leave that file alone; Task 3 fixes the comment in `init-tables.js` only. If the grep shows `instance-sync.js`, that is expected and must NOT be edited. + +- [ ] **Step 7: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-eggs-supply.test.js +node scripts/run-suite.mjs tests/ramble-eggs.test.js +``` + +Expected: both PASS. `tests/ramble-eggs.test.js` has a test asserting two `mintIncubatingEgg` calls return the same egg — that still holds. + +- [ ] **Step 8: Commit** + +```bash +git add tests/ramble-eggs-supply.test.js +git commit bundles/ramble/server/eggs.js tests/ramble-eggs-supply.test.js tests/ramble-eggs.test.js \ + tests/ramble-flock.test.js tests/ramble-trades.test.js tests/ramble-sync.test.js \ + -m "ramble: looking at a screen no longer mints an egg" +``` + +--- + +## Task 3: auto-promote — the shelf refills the empty slot + +**Files:** +- Modify: `bundles/ramble/server/eggs.js`, `bundles/ramble/server/flock.js`, `bundles/ramble/server/init-tables.js` (comment only) +- Modify: `tests/ramble-eggs-supply.test.js` + +**Interfaces:** +- Produces: `promoteFromShelf(db, { now, emit }) -> Promise` — promotes the oldest non-locked `shelf`/`received` egg into the incubating slot, or returns null. +- Consumed by: `hatchIfReady` (eggs.js), `eggState`/`flockState`/`petState` read paths (Task 4/6), `panel/routes.js`. + +**The ordering rule, which is the whole design:** both instances must pick the **same** egg with no round trip. The order is `created_at ASC, egg_id ASC` — a total order and a pure function of replicated rows, exactly the order `RAMBLE_EGG_REPROMOTE_SQL` already uses for the sync layer's own re-promote. + +**⚠ This is NOT the sync layer's re-promote.** `servers/sharing/instance-sync.js:713` deliberately promotes only `shelf_origin = 'sync'` eggs, with a comment that `'user'` eggs "must never be drafted back in". That rule is correct **for sync**, which is a convergence tie-break carrying no user intent. The app-level promote here is a game rule and *does* take user eggs — that is the entire release valve of spec §4.2. **Do not modify `instance-sync.js`.** + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/ramble-eggs-supply.test.js`: + +```js +import { promoteFromShelf, hatchIfReady } from "../bundles/ramble/server/eggs.js"; + +async function shelveEgg(db, eggId, createdAt, { status = "shelf", origin = "user" } = {}) { + await db.execute({ + sql: `INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, created_at) VALUES (?, ?, ?, 0, ?)`, + args: [eggId, status, origin, createdAt], + }); +} + +async function statusOf(db, eggId) { + const { rows } = await db.execute({ sql: "SELECT status, shelf_origin FROM ramble_eggs WHERE egg_id = ?", args: [eggId] }); + return rows[0] ?? null; +} + +test("promoteFromShelf takes the OLDEST shelf egg and clears shelf_origin", async () => { + const db = await freshDb(); + await shelveEgg(db, "younger", T0 + 5000); + await shelveEgg(db, "older", T0); + + const promoted = await promoteFromShelf(db, { now: T0 + 9000 }); + assert.equal(promoted.egg_id, "older", "oldest created_at wins"); + assert.equal((await statusOf(db, "older")).status, "incubating"); + assert.equal((await statusOf(db, "older")).shelf_origin, null, + "an incubating egg carries no shelf origin, same as the manual incubate path"); + assert.equal((await statusOf(db, "younger")).status, "shelf", "only one is drafted"); +}); + +test("promoteFromShelf breaks a created_at tie by the lower egg_id, so two instances agree", async () => { + const db = await freshDb(); + await shelveEgg(db, "bbb", T0); + await shelveEgg(db, "aaa", T0); + const promoted = await promoteFromShelf(db, { now: T0 }); + assert.equal(promoted.egg_id, "aaa"); +}); + +test("promoteFromShelf takes a RECEIVED (gifted) egg too", async () => { + const db = await freshDb(); + await shelveEgg(db, "gift", T0, { status: "received" }); + const promoted = await promoteFromShelf(db, { now: T0 }); + assert.equal(promoted.egg_id, "gift"); +}); + +test("promoteFromShelf SKIPS an egg spoken for by an open swap", async () => { + const db = await freshDb(); + await shelveEgg(db, "locked-one", T0); + await shelveEgg(db, "free-one", T0 + 1000); + // ⚠ 'proposed', not 'offered'. OPEN_SQL is "state IN ('proposed','accepted')", + // so a made-up state would leave the egg UNLOCKED and this test would be + // asserting nothing about locking. counterpart/role/expires_at are NOT NULL + // with no defaults — omitting them fails on the constraint, not the feature. + await db.execute({ + sql: `INSERT INTO ramble_trades + (trade_id, counterpart, role, my_egg_id, state, created_at, updated_at, expires_at) + VALUES ('t1', 'npub-them', 'proposer', 'locked-one', 'proposed', ?, ?, ?)`, + args: [T0, T0, T0 + 7 * 86400000], + }); + const promoted = await promoteFromShelf(db, { now: T0 + 2000 }); + assert.equal(promoted.egg_id, "free-one", "an egg promised to a contact is not drafted"); + assert.equal((await statusOf(db, "locked-one")).status, "shelf"); +}); + +test("promoteFromShelf is a NO-OP when the slot is full, and when there is nothing to promote", async () => { + const db = await freshDb(); + assert.equal(await promoteFromShelf(db, { now: T0 }), null, "empty shelf, empty slot"); + assert.equal(await eggCount(db), 0, "a no-op promote writes NOTHING — this is what makes it safe on a GET"); + + const sitting = await mintIncubatingEgg(db, { now: T0 }); + await shelveEgg(db, "waiting", T0 - 5000); + assert.equal(await promoteFromShelf(db, { now: T0 }), null, "the slot is occupied"); + assert.equal((await statusOf(db, "waiting")).status, "shelf"); + assert.equal((await getIncubatingEgg(db)).egg_id, sitting.egg_id); +}); + +test("hatching promotes from the shelf instead of minting a successor", async () => { + const db = await freshDb(); + const egg = await mintIncubatingEgg(db, { now: T0 }); + await shelveEgg(db, "next-you", T0 + 100); + await db.execute({ sql: "UPDATE ramble_eggs SET warmth = 100 WHERE egg_id = ?", args: [egg.egg_id] }); + + const hatched = await hatchIfReady(db, { now: T0 + 1000 }); + assert.ok(hatched, "it hatched"); + assert.equal(await eggCount(db), 2, "NO successor was minted"); + assert.equal((await getIncubatingEgg(db)).egg_id, "next-you", "the shelf refilled the slot"); +}); + +test("hatching with an EMPTY shelf leaves the slot empty — no free egg", async () => { + const db = await freshDb(); + const egg = await mintIncubatingEgg(db, { now: T0 }); + await db.execute({ sql: "UPDATE ramble_eggs SET warmth = 100 WHERE egg_id = ?", args: [egg.egg_id] }); + + const hatched = await hatchIfReady(db, { now: T0 + 1000 }); + assert.ok(hatched); + assert.equal(await getIncubatingEgg(db), null, "this is the whole phase: no successor appears"); + assert.equal(await eggCount(db), 1); +}); + +test("two instances promote the SAME egg independently, with no round trip", async () => { + const a = await freshDb(); + const b = await freshDb(); + for (const db of [a, b]) { + await shelveEgg(db, "zzz", T0); + await shelveEgg(db, "aaa", T0); // same created_at: the tie-break decides + await shelveEgg(db, "mmm", T0 + 1); + } + const pa = await promoteFromShelf(a, { now: T0 + 100 }); + const pb = await promoteFromShelf(b, { now: T0 + 100 }); + assert.equal(pa.egg_id, pb.egg_id, "the order is a pure function of replicated rows"); + assert.equal(pa.egg_id, "aaa"); +}); +``` + +- [ ] **Step 2: Run it to watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-eggs-supply.test.js +``` + +Expected: FAIL — `promoteFromShelf` is not exported. + +- [ ] **Step 3: Implement `promoteFromShelf` in `eggs.js`** + +Add the import at the top of `bundles/ramble/server/eggs.js`: + +```js +import { lockedEggIds } from "./egg-locks.js"; +``` + +Then: + +```js +/** + * Refill an empty incubating slot from the shelf (spec §4.2). This is the + * release valve that makes D3 — warmth vanishing when there is no egg — + * tolerable: the user is only ever eggless when they genuinely have none. + * + * Order is `created_at ASC, egg_id ASC`: a TOTAL order and a pure function of + * rows that replicate, so two instances reach the same answer independently + * with nothing to exchange and nothing to emit beyond the row itself. + * + * ⚠ NOT the same mechanism as `RAMBLE_EGG_REPROMOTE_SQL` in + * servers/sharing/instance-sync.js, which promotes ONLY `shelf_origin='sync'` + * eggs and says a 'user' egg "must never be drafted back in". That is correct + * FOR SYNC: it is a convergence tie-break carrying no user intent, and + * drafting a deliberately-parked egg on a sync apply would override a choice + * the user made. This one is a game rule and DOES take user eggs — that is + * the point of §4.2. Do not unify them. + * + * An egg named by an open swap is skipped: it is promised to a contact, and + * incubating it would let the user spend it twice. + * + * Writes NOTHING when the slot is occupied or nothing is promotable, which is + * what makes it safe to call from a read path. + */ +export async function promoteFromShelf(db, { now, emit } = {}) { + void now; + if (await getIncubatingEgg(db)) return null; + + const locked = await lockedEggIds(db); + const { rows } = await db.execute({ + sql: `SELECT egg_id FROM ramble_eggs + WHERE status IN ('shelf', 'received') + ORDER BY created_at ASC, egg_id ASC`, + args: [], + }); + const next = rows.find((r) => !locked.has(r.egg_id)); + if (!next) return null; + + // Guarded exactly like mintIncubatingEgg: the "one incubating egg" rule is + // a query against the table's contents, not a schema constraint, so two + // overlapping promotes must not both succeed. + const { rowsAffected } = await db.execute({ + sql: `UPDATE ramble_eggs SET status = 'incubating', shelf_origin = NULL + WHERE egg_id = ? AND status IN ('shelf', 'received') + AND NOT EXISTS (SELECT 1 FROM ramble_eggs WHERE status = 'incubating')`, + args: [next.egg_id], + }); + if (rowsAffected === 0) return null; + + const promoted = await getIncubatingEgg(db); + if (promoted) await safeEmit(emit, "ramble_eggs", "update", promoted); + return promoted; +} +``` + +- [ ] **Step 4: Rewire `hatchIfReady`** + +Replace the successor mint at the end of `hatchIfReady`: + +```js + // Phase 3: the successor egg is NOT minted. The shelf refills the slot if + // it can; otherwise the player is genuinely eggless and the panel says so. + await promoteFromShelf(db, { now, emit }); + + return hatchedEgg; +``` + +Delete the now-unused `const nextEgg = ...; void nextEgg;` lines. Update `hatchIfReady`'s doc comment, whose second paragraph describes minting a successor — it must now describe promoting. + +- [ ] **Step 5: Stop `flockState` from minting** + +In `bundles/ramble/server/flock.js`, delete `await ensureIncubatingEgg(db, { now });` from `flockState` and replace it with: + +```js + // Phase 3: a flock screen is a READ. It used to mint the incubating egg, + // so opening this view recreated one. It may still promote, because a slot + // can empty from a sync arrival that no local code path observed — that + // moves an egg the user already owns rather than conjuring one, and + // promoteFromShelf writes nothing when there is nothing to promote. + await promoteFromShelf(db, { now }); +``` + +Update the import: drop `ensureIncubatingEgg`, add `promoteFromShelf`. + +- [ ] **Step 6: Correct the stale comment in `init-tables.js` — COMMENT ONLY** + +The `shelf_origin` comment currently reads "and it must NEVER be auto-promoted". Phase 3 adds an auto-promote that deliberately does. Leaving it would make a reader conclude the new promote is a bug — the phase 2 lesson about a wrong-but-checkable justification, in reverse. Change that clause to: + +```js + // Phase 2: WHY an egg is on the shelf. 'sync' = a convergence loser (the + // sync layer may re-promote it when the incubating slot empties); 'user' = + // the user put it there (claimed from a nest, or swapped out by incubate) + // and THE SYNC LAYER must never draft it back in. Phase 3's app-level + // auto-promote (eggs.js promoteFromShelf, spec §4.2) DOES take 'user' eggs + // deliberately — that is the release valve; the two are different + // mechanisms with different triggers. Phase 1 only ever shelved convergence + // losers, so a NULL shelf row on disk is one of those: backfill it to 'sync' + // (idempotent, and a 'user' row is never NULL so it is never touched). +``` + +Then prove the SQL did not move: + +```bash +git diff bundles/ramble/server/init-tables.js | grep -E "^[+-]" | grep -viE "^[+-]\s*(//|\*|/\*)" | grep -v "^[+-][+-]" +``` + +Expected: **no output.** Any line here means executable SQL changed and the change must be reverted. + +- [ ] **Step 7: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-eggs-supply.test.js +node scripts/run-suite.mjs tests/ramble-flock.test.js +node scripts/run-suite.mjs tests/ramble-sync.test.js +``` + +Expected: all PASS. `tests/ramble-flock.test.js` may have a test asserting a successor appears after a hatch — if so, it is asserting the behaviour this phase removes: update it to assert the slot is empty, and say so in the commit. + +- [ ] **Step 8: Commit** + +```bash +git commit bundles/ramble/server/eggs.js bundles/ramble/server/flock.js \ + bundles/ramble/server/init-tables.js tests/ramble-eggs-supply.test.js tests/ramble-flock.test.js \ + -m "ramble: the shelf refills the incubating slot, and hatching mints nothing" +``` + +--- + +## Task 4: laying — the floor, as a replicated day ledger + +**Files:** +- Modify: `bundles/ramble/server/eggs.js`, `bundles/ramble/server/pet.js` +- Create: `tests/ramble-laying.test.js` + +**Interfaces:** +- Produces: + - `LAY_DAYS_DEFAULT = 14`, `LAYDAY_KIND = "layday"`, `LAY_KIND = "lay"` + - `readLaySettings(db) -> Promise<{ layDays }>` + - `hasAnyEggAnywhere(db) -> Promise` — an incubating, shelf or received egg (a hatched bird is not an egg) + - `layProgress(db) -> Promise<{ days, needed }>` + - `recordHappyDay(db, { now, mood, emit }) -> Promise<{ recorded, laid }>` +- Consumed by: `pet.js` (Task 4), `panel/routes.js` (Task 6), the panel (Task 8). + +**The reset, without deleting ledger rows:** a lay-day is `ramble_wallet(kind='layday', key=, delta=1)`. When the bird lays, a `ramble_wallet(kind='lay', key=, delta=1)` row records it. Progress counts `layday` rows with `created_at` **greater than** the newest `lay` row's `created_at` (all of them if none). Nothing is ever deleted, the count still resets, and both instances derive the same number from the same replicated rows. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ramble-laying.test.js`: + +```js +/** + * Spec 2026-09-08 §4.3 — the laying floor. + * + * The count accrues ONLY while the user has no eggs at all. Were it always + * accruing, a player would run dry and lay almost immediately, undercutting + * nests as the real supply. + * + * Rows live in ramble_wallet with delta ALWAYS the literal 1: applyRambleWallet + * resolves conflicts with MAX(delta), which is only convergent when the value + * cannot differ between instances for the same key. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createClient } from "@libsql/client"; +import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; +import { + mintIncubatingEgg, getIncubatingEgg, recordHappyDay, layProgress, + hasAnyEggAnywhere, readLaySettings, LAY_DAYS_DEFAULT, +} from "../bundles/ramble/server/eggs.js"; + +const DAY = 86400000; +const T0 = Date.UTC(2026, 8, 9, 12, 0, 0); + +async function freshDb() { + const db = createClient({ url: ":memory:" }); + await initRambleTables(db); + return db; +} +async function eggCount(db) { + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_eggs", args: [] }); + return Number(rows[0].n); +} +async function setLayDays(db, n) { + await db.execute({ sql: "INSERT INTO ramble_settings (key, value) VALUES ('lay.days', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", args: [String(n)] }); +} + +test("the default is the spec's 14", async () => { + const db = await freshDb(); + assert.equal(LAY_DAYS_DEFAULT, 14); + assert.deepEqual(await readLaySettings(db), { layDays: 14 }); + await setLayDays(db, 10); + assert.deepEqual(await readLaySettings(db), { layDays: 10 }); + await setLayDays(db, 0); + assert.deepEqual(await readLaySettings(db), { layDays: 14 }, "a junk setting falls back"); +}); + +test("hasAnyEggAnywhere counts eggs, not birds", async () => { + const db = await freshDb(); + assert.equal(await hasAnyEggAnywhere(db), false); + await mintIncubatingEgg(db, { now: T0 }); + assert.equal(await hasAnyEggAnywhere(db), true); + + const db2 = await freshDb(); + await db2.execute({ + sql: `INSERT INTO ramble_eggs (egg_id, status, species, seed, warmth, created_at, hatched_at) + VALUES ('bird', 'hatched', 'wren', 7, 100, ?, ?)`, args: [T0, T0], + }); + assert.equal(await hasAnyEggAnywhere(db2), false, "a hatched bird is not an egg you are warming"); +}); + +test("a happy day accrues ONLY while eggless, and only once per local day", async () => { + const db = await freshDb(); + const first = await recordHappyDay(db, { now: T0, mood: "happy" }); + assert.equal(first.recorded, true); + assert.equal((await layProgress(db)).days, 1); + + const again = await recordHappyDay(db, { now: T0 + 3600000, mood: "happy" }); + assert.equal(again.recorded, false, "same local day"); + assert.equal((await layProgress(db)).days, 1); + + await recordHappyDay(db, { now: T0 + DAY, mood: "tired" }); + assert.equal((await layProgress(db)).days, 1, "a tired day does not count"); + + await mintIncubatingEgg(db, { now: T0 + 2 * DAY }); + await recordHappyDay(db, { now: T0 + 2 * DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 1, "with an egg in hand, nothing accrues"); +}); + +test("days need NOT be consecutive", async () => { + const db = await freshDb(); + await setLayDays(db, 3); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "alarmed" }); + await recordHappyDay(db, { now: T0 + 5 * DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 2, "one bad day did not erase the streak"); + assert.equal(await eggCount(db), 0); +}); + +test("at the threshold the bird lays, and the count resets", async () => { + const db = await freshDb(); + await setLayDays(db, 3); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "happy" }); + assert.equal(await eggCount(db), 0, "not yet"); + + const out = await recordHappyDay(db, { now: T0 + 2 * DAY, mood: "happy" }); + assert.equal(out.laid, true); + assert.equal(await eggCount(db), 1); + const egg = await getIncubatingEgg(db); + assert.ok(egg, "the laid egg goes straight into the empty slot"); + assert.equal(egg.warmth, 0); + + assert.equal((await layProgress(db)).days, 0, "the count reset without deleting a single row"); + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_wallet WHERE kind = 'layday'", args: [] }); + assert.equal(Number(rows[0].n), 3, "the ledger is append-only"); +}); + +test("after laying, the count starts again only once the player is eggless again", async () => { + const db = await freshDb(); + await setLayDays(db, 2); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "happy" }); + assert.equal(await eggCount(db), 1); + + await recordHappyDay(db, { now: T0 + 2 * DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 0, "holding an egg, nothing accrues"); + + await db.execute({ sql: "DELETE FROM ramble_eggs", args: [] }); // stand-in for hatching it away + await recordHappyDay(db, { now: T0 + 3 * DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 1, "eggless again: the counter resumes from zero"); +}); + +test("every layday row carries delta exactly 1, whatever lay.days is set to", async () => { + const db = await freshDb(); + await setLayDays(db, 25); + await recordHappyDay(db, { now: T0, mood: "happy" }); + const { rows } = await db.execute({ sql: "SELECT delta FROM ramble_wallet WHERE kind = 'layday'", args: [] }); + assert.equal(Number(rows[0].delta), 1, "MAX(delta) is only convergent on a constant"); +}); + +test("layProgress reports what the panel needs", async () => { + const db = await freshDb(); + assert.deepEqual(await layProgress(db), { days: 0, needed: 14 }); + await recordHappyDay(db, { now: T0, mood: "happy" }); + assert.deepEqual(await layProgress(db), { days: 1, needed: 14 }); +}); +``` + +- [ ] **Step 2: Run it to watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-laying.test.js +``` + +Expected: FAIL — none of the laying exports exist. + +- [ ] **Step 3: Implement laying in `eggs.js`** + +```js +export const LAY_DAYS_DEFAULT = 14; +export const LAYDAY_KIND = "layday"; +export const LAY_KIND = "lay"; + +function intSetting(raw, fallback, min) { + if (raw == null) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n >= min ? n : fallback; +} + +/** `lay.days` (>= 1, default 14), read live so balance is a config change. */ +export async function readLaySettings(db) { + return { layDays: intSetting(await readSetting(db, "lay.days"), LAY_DAYS_DEFAULT, 1) }; +} + +/** + * Does the user hold an egg ANYWHERE — the slot, the shelf, or a gift not yet + * dealt with? A hatched bird is not an egg: you are not warming it. + */ +export async function hasAnyEggAnywhere(db) { + const { rows } = await db.execute({ + sql: `SELECT 1 FROM ramble_eggs WHERE status IN ('incubating', 'shelf', 'received') LIMIT 1`, + args: [], + }); + return rows.length > 0; +} + +/** + * Happy days banked since the last lay. The count RESETS without deleting a + * row: `lay` rows mark each laying, and only `layday` rows newer than the most + * recent one count. Both instances derive the same number from the same + * replicated rows, and the ledger stays append-only (spec §6.1). + */ +export async function layProgress(db) { + const { layDays } = await readLaySettings(db); + const { rows: lastLay } = await db.execute({ + sql: `SELECT MAX(created_at) AS at FROM ramble_wallet WHERE kind = ?`, args: [LAY_KIND], + }); + const since = Number(lastLay[0]?.at ?? 0) || 0; + const { rows } = await db.execute({ + sql: `SELECT count(*) AS n FROM ramble_wallet WHERE kind = ? AND created_at > ?`, + args: [LAYDAY_KIND, since], + }); + return { days: Number(rows[0]?.n ?? 0), needed: layDays }; +} + +/** + * Count today toward laying, and lay if the threshold is reached (spec §4.3). + * + * Called from the pet's read and feed paths, so "ends the day happy" is really + * "was observed happy on this local day". The alternative — judging the last + * observation of the day — would punish opening the app after a good walk. + * + * Accrues ONLY while the user holds no egg anywhere. Were it always accruing, + * a player would run dry and lay at once, and the floor would become the main + * supply instead of a backstop. + * + * ⚠ delta is the literal 1. See applyRambleWallet's MAX(delta) rule. + */ +export async function recordHappyDay(db, { now = Date.now(), mood, emit } = {}) { + if (mood !== "happy") return { recorded: false, laid: false }; + if (await hasAnyEggAnywhere(db)) return { recorded: false, laid: false }; + + const key = localDay(now); + const { rowsAffected } = await db.execute({ + sql: `INSERT OR IGNORE INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?)`, + args: [LAYDAY_KIND, key, now], + }); + if (rowsAffected === 0) return { recorded: false, laid: false }; + await safeEmit(emit, "ramble_wallet", "insert", { kind: LAYDAY_KIND, key, delta: 1, created_at: now }); + + const { days, needed } = await layProgress(db); + if (days < needed) return { recorded: true, laid: false }; + + await db.execute({ + sql: `INSERT OR IGNORE INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?)`, + args: [LAY_KIND, key, now], + }); + await safeEmit(emit, "ramble_wallet", "insert", { kind: LAY_KIND, key, delta: 1, created_at: now }); + await mintIncubatingEgg(db, { now, emit }); + return { recorded: true, laid: true }; +} +``` + +- [ ] **Step 4: Call it from `pet.js`, after the mood is known** + +`pet.js` already imports `localDay` from `eggs.js`; extend that import with `recordHappyDay`. In **`petState`**, after decay has been applied and `mood` computed, and after the decay row is persisted: + +```js + // Phase 3 (spec §4.3): a day counts when the bird is OBSERVED happy while + // wholly eggless. Idempotent per local day, and a no-op the moment the + // player holds any egg — so this is cheap on every poll. + await recordHappyDay(db, { now, mood, emit }); +``` + +And in **`feed`**, after `const mood = moodFor(energy);` and the row is written: + +```js + await recordHappyDay(db, { now, mood, emit }); +``` + +Both call sites are needed: a player who walks (feed) and a player who only looks (petState) have both been seen that day, and `paintPet` polls `/api/ramble/pet` on a player who never posts a fix. + +- [ ] **Step 5: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-laying.test.js +node scripts/run-suite.mjs tests/ramble-pet.test.js +node scripts/run-suite.mjs tests/ramble-eggs-supply.test.js +``` + +Expected: all PASS. If `tests/ramble-pet.test.js` now fails on an egg count, it is because a happy fresh pet with no egg lays after `lay.days` — check the fixture's clock rather than weakening the mechanism. + +- [ ] **Step 6: Commit** + +```bash +git add tests/ramble-laying.test.js +git commit bundles/ramble/server/eggs.js bundles/ramble/server/pet.js tests/ramble-laying.test.js \ + -m "ramble: an eggless bird lays after sustained care" +``` + +--- + +## Task 5: the starter egg and the two prologue flags + +**Files:** +- Modify: `bundles/ramble/server/eggs.js` +- Create: `tests/ramble-prologue.test.js` + +**Interfaces:** +- Produces: + - `PROLOGUE_INTRO_KEY = "prologue.intro.seen"`, `PROLOGUE_HATCH_KEY = "prologue.hatch.seen"` + - `readPrologue(db) -> Promise<{ intro_seen, hatch_seen, granted }>` + - `setPrologueSeen(db, which, { emit }) -> Promise` — `which` is `"intro"` or `"hatch"` + - `grantStarterEgg(db, { now, emit }) -> Promise` +- Consumed by: `panel/routes.js` (Task 6), the panel (Task 8). + +**⚠ Finding 3 applies here.** Gate on an empty egg table with a random UUID. Do **not** derive the id from `crowId` — it is per-instance, so two instances would produce two different ids and two eggs. + +**⚠ K1 applies here.** The grant condition must be readable from replicated egg data alone, so that wiping the game state genuinely replays the prologue. A "starter granted" boolean that survived a wipe would silently make the reset useless. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ramble-prologue.test.js`: + +```js +/** + * Spec 2026-09-08 §4.4 — one starter egg, once ever, as a narrative gift. + * + * ⚠ The grant condition is "has any egg EVER existed", read from ramble_eggs + * itself, NOT a flag. Kevin intends to reset his game state to a new game once + * phases 3 and 4 land, specifically to play the prologue as a new player; a + * flag that outlived the wipe would silently make that reset useless. + * + * ⚠ The spec's own race protection ("derive the id from the Crow identity") + * cannot work: loadOrCreateIdentity generates a random per-INSTANCE seed, so + * crowId differs between a user's own Crows and deriving from it would grant + * TWO eggs. A random uuid plus the existing applyRambleEgg convergence rule + * (older survives incubating, younger is shelved with its warmth) is the + * graceful answer. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createClient } from "@libsql/client"; +import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; +import { + grantStarterEgg, readPrologue, setPrologueSeen, getIncubatingEgg, mintIncubatingEgg, +} from "../bundles/ramble/server/eggs.js"; + +const T0 = Date.UTC(2026, 8, 9, 12, 0, 0); + +async function freshDb() { + const db = createClient({ url: ":memory:" }); + await initRambleTables(db); + return db; +} +async function eggCount(db) { + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_eggs", args: [] }); + return Number(rows[0].n); +} + +test("a fresh player is granted exactly one starter egg, into the slot", async () => { + const db = await freshDb(); + assert.deepEqual(await readPrologue(db), { intro_seen: false, hatch_seen: false, granted: false }); + + const egg = await grantStarterEgg(db, { now: T0 }); + assert.ok(egg, "granted"); + assert.equal(await eggCount(db), 1); + assert.equal((await getIncubatingEgg(db)).egg_id, egg.egg_id); + assert.equal(egg.warmth, 0); + assert.equal((await readPrologue(db)).granted, true); +}); + +test("the grant is once EVER — a hatched bird still counts as an egg having existed", async () => { + const db = await freshDb(); + await grantStarterEgg(db, { now: T0 }); + assert.equal(await grantStarterEgg(db, { now: T0 + 1000 }), null, "twice is a no-op"); + assert.equal(await eggCount(db), 1); + + await db.execute({ sql: "UPDATE ramble_eggs SET status = 'hatched', species = 'wren', seed = 7, hatched_at = ?", args: [T0] }); + assert.equal(await grantStarterEgg(db, { now: T0 + 2000 }), null, + "having hatched and become eggless must NOT re-grant — that would restore the free egg"); + assert.equal(await eggCount(db), 1); +}); + +test("an existing player who already has an egg is never granted one", async () => { + const db = await freshDb(); + await mintIncubatingEgg(db, { now: T0 }); + assert.equal(await grantStarterEgg(db, { now: T0 + 1000 }), null); + assert.equal(await eggCount(db), 1); +}); + +test("wiping the eggs makes the prologue replayable — K1's reset", async () => { + const db = await freshDb(); + await grantStarterEgg(db, { now: T0 }); + await setPrologueSeen(db, "intro"); + await setPrologueSeen(db, "hatch"); + assert.deepEqual(await readPrologue(db), { intro_seen: true, hatch_seen: true, granted: true }); + + // A game reset clears both the eggs and the two flags. + await db.execute({ sql: "DELETE FROM ramble_eggs", args: [] }); + await db.execute({ sql: "DELETE FROM ramble_settings WHERE key LIKE 'prologue.%'", args: [] }); + + assert.deepEqual(await readPrologue(db), { intro_seen: false, hatch_seen: false, granted: false }); + assert.ok(await grantStarterEgg(db, { now: T0 + 5000 }), "the prologue genuinely replays"); +}); + +test("the two flags are independent and survive as replicated settings", async () => { + const db = await freshDb(); + await setPrologueSeen(db, "intro"); + assert.deepEqual(await readPrologue(db), { intro_seen: true, hatch_seen: false, granted: false }); + await setPrologueSeen(db, "hatch"); + assert.equal((await readPrologue(db)).hatch_seen, true); + + const { rows } = await db.execute({ sql: "SELECT key FROM ramble_settings WHERE key LIKE 'prologue.%' ORDER BY key", args: [] }); + assert.deepEqual(rows.map((r) => r.key), ["prologue.hatch.seen", "prologue.intro.seen"]); +}); + +test("setPrologueSeen refuses an unknown beat rather than writing junk", async () => { + const db = await freshDb(); + await assert.rejects(() => setPrologueSeen(db, "nonsense")); +}); +``` + +- [ ] **Step 2: Run it to watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-prologue.test.js +``` + +Expected: FAIL — the prologue exports do not exist. + +- [ ] **Step 3: Implement in `eggs.js`** + +```js +export const PROLOGUE_INTRO_KEY = "prologue.intro.seen"; +export const PROLOGUE_HATCH_KEY = "prologue.hatch.seen"; +const PROLOGUE_KEYS = { intro: PROLOGUE_INTRO_KEY, hatch: PROLOGUE_HATCH_KEY }; + +/** + * Mirrors grid.js's private writeSetting rather than importing it: eggs.js has + * no other reason to depend on grid.js, and the SQL is one statement. + */ +async function writeSetting(db, key, value, { emit } = {}) { + await db.execute({ + sql: `INSERT INTO ramble_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + args: [key, value], + }); + await safeEmit(emit, "ramble_settings", "update", { key, value }); +} + +/** + * Has ANY egg ever existed on this fleet — including one that has since + * hatched. Read from ramble_eggs, which replicates, so the answer is the same + * on every one of the user's Crows and a data wipe genuinely resets it (K1). + */ +async function anyEggEverExisted(db) { + const { rows } = await db.execute({ sql: "SELECT 1 FROM ramble_eggs LIMIT 1", args: [] }); + return rows.length > 0; +} + +export async function readPrologue(db) { + return { + intro_seen: (await readSetting(db, PROLOGUE_INTRO_KEY)) === "1", + hatch_seen: (await readSetting(db, PROLOGUE_HATCH_KEY)) === "1", + granted: await anyEggEverExisted(db), + }; +} + +export async function setPrologueSeen(db, which, { emit } = {}) { + const key = PROLOGUE_KEYS[which]; + if (!key) throw new Error(`unknown prologue beat: ${which}`); + await writeSetting(db, key, "1", { emit }); +} + +/** + * One starter egg, once ever (spec §4.4, D12), granted as a narrative gift. + * + * ⚠ The spec says to derive the id from the Crow identity so a simultaneous + * two-instance first run collapses into one insert. That cannot work: + * loadOrCreateIdentity generates a RANDOM PER-INSTANCE seed, so crowId differs + * between the user's own Crows and deriving from it would grant two eggs — the + * outcome it was meant to prevent. A fixed constant is worse still: a contact + * could gift you their starter egg and the ids would collide in the + * receivedEggStatement upsert. + * + * So: a random uuid, gated on the egg table being empty. If a genuine race + * ever happened, applyRambleEgg's existing "one incubating egg" rule keeps the + * older and SHELVES the younger with its warmth intact — a spare egg, not a + * duplicate disaster. That machinery is already built and tested. + */ +export async function grantStarterEgg(db, { now = Date.now(), emit } = {}) { + if (await anyEggEverExisted(db)) return null; + return mintIncubatingEgg(db, { now, emit }); +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-prologue.test.js +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/ramble-prologue.test.js +git commit bundles/ramble/server/eggs.js tests/ramble-prologue.test.js \ + -m "ramble: one starter egg, once ever, and the two prologue flags" +``` + +--- + +## Task 6: the routes and the MCP tools speak the new shape + +**Files:** +- Modify: `bundles/ramble/panel/routes.js`, `bundles/ramble/server/feed.js`, `bundles/ramble/server/server.js` +- Modify: `tests/ramble-panel.test.js`, `tests/ramble-tools.test.js` + +**Interfaces:** +- `GET /api/ramble/egg` -> `{ egg: null | {...}, checklist: {...}, lay: { days, needed } }` +- `GET /api/ramble/pet` -> adds `pet.egg` (`null` or `{ percent, ... }`) and `pet.lay` +- `GET /api/ramble/prologue` -> `{ intro_seen, hatch_seen, granted }` +- `POST /api/ramble/prologue/intro` -> `{ egg: row|null, intro_seen: true }` — grants and flags +- `POST /api/ramble/prologue/hatch` -> `{ hatch_seen: true }` + +**⚠ Both prologue POSTs must be idempotent** — the panel fires them from a dismiss button that a double-tap can send twice. + +- [ ] **Step 1: Write the failing route tests** + +Add to `tests/ramble-panel.test.js` (follow the file's existing harness — a scratch `CROW_DATA_DIR`, never the live db): + +```js +test("GET /api/ramble/egg reports egg: null on a fresh install and mints nothing", async () => { + const r = await get("/api/ramble/egg"); + assert.equal(r.status, 200); + assert.equal(r.body.egg, null); + assert.ok(r.body.checklist, "the checklist still renders"); + assert.deepEqual(r.body.lay, { days: 0, needed: 14 }); + const again = await get("/api/ramble/egg"); + assert.equal(again.body.egg, null, "reading twice did not conjure one"); +}); + +test("GET /api/ramble/pet carries a null egg and lay progress", async () => { + const r = await get("/api/ramble/pet"); + assert.equal(r.status, 200); + assert.equal(r.body.egg, null); + assert.ok(r.body.lay, "the pet page needs the count for the eggless card"); +}); + +test("POST /api/ramble/prologue/intro grants once and is idempotent", async () => { + const first = await post("/api/ramble/prologue/intro", {}); + assert.equal(first.status, 200); + assert.ok(first.body.egg, "the starter egg arrives with the first beat"); + assert.equal(first.body.intro_seen, true); + + const second = await post("/api/ramble/prologue/intro", {}); + assert.equal(second.status, 200); + assert.equal(second.body.egg, null, "a double-tap grants nothing further"); + + const state = await get("/api/ramble/prologue"); + assert.equal(state.body.intro_seen, true); + assert.equal(state.body.granted, true); + + const egg = await get("/api/ramble/egg"); + assert.ok(egg.body.egg, "and the egg view now has something to show"); +}); + +test("POST /api/ramble/prologue/hatch flags the second beat and is idempotent", async () => { + assert.equal((await post("/api/ramble/prologue/hatch", {})).status, 200); + assert.equal((await post("/api/ramble/prologue/hatch", {})).status, 200); + assert.equal((await get("/api/ramble/prologue")).body.hatch_seen, true); +}); +``` + +- [ ] **Step 2: Run it to watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: FAIL — the prologue routes 404 and `lay` is absent. + +- [ ] **Step 3: Load the new module surface and add the routes** + +In `bundles/ramble/panel/routes.js`, wherever `mods.eggsMod` is assembled, make sure `layProgress`, `readPrologue`, `setPrologueSeen`, `grantStarterEgg` and `promoteFromShelf` are reachable. Then extend the egg route and add the prologue routes: + +```js + router.get("/api/ramble/egg", handle(async (req, res) => { + // A slot can empty from a sync arrival no local path observed; this moves + // an egg the user already owns and writes nothing when there is none. + await mods.eggsMod.promoteFromShelf(db, { now: Date.now(), emit }); + const state = await mods.eggsMod.eggState(db, { now: Date.now() }); + const lay = await mods.eggsMod.layProgress(db); + res.json({ ...state, lay }); + })); + + router.get("/api/ramble/prologue", handle(async (req, res) => { + res.json(await mods.eggsMod.readPrologue(db)); + })); + + router.post("/api/ramble/prologue/intro", handle(async (req, res) => { + const egg = await mods.eggsMod.grantStarterEgg(db, { now: Date.now(), emit }); + await mods.eggsMod.setPrologueSeen(db, "intro", { emit }); + res.json({ egg: egg ? { egg_id: egg.egg_id, warmth: egg.warmth } : null, intro_seen: true }); + })); + + router.post("/api/ramble/prologue/hatch", handle(async (req, res) => { + await mods.eggsMod.setPrologueSeen(db, "hatch", { emit }); + res.json({ hatch_seen: true }); + })); +``` + +Keep the existing `GET /api/ramble/egg` handler's other behaviour (auth, error handling) exactly as it was — copy the surrounding shape from the file rather than the sketch above. + +- [ ] **Step 4: Carry the egg summary and lay progress on the pet read** + +In `bundles/ramble/server/feed.js`, `readPet` already returns the pet row shape the panel uses. Extend it so the pet response carries a **nullable** egg summary and the lay count — `paintPet` reads `pet.egg.percent` today and must not throw on `null`: + +```js + const eggSummary = await eggState(db, { now }); + const lay = await layProgress(db); + return { + ...pet, + // null when the player is genuinely eggless. paintPet must not assume one. + egg: eggSummary.egg ? { ...eggSummary.egg } : null, + lay, + }; +``` + +Import `eggState` and `layProgress` from `./eggs.js` in `feed.js`. + +- [ ] **Step 5: Keep the MCP tools in step with the HTTP routes** + +In `bundles/ramble/server/server.js`, the `ramble_egg_state` and `ramble_pet_state` tools call the same functions. They pick up `egg: null` and `lay` for free from the spread — **verify that by reading the code**, and if either tool destructures `egg` or assumes it, fix it there. A tool and a route that disagree about the same egg is the defect phase 2 caught late. + +- [ ] **Step 6: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +node scripts/run-suite.mjs tests/ramble-tools.test.js +``` + +Expected: both PASS. + +- [ ] **Step 7: Commit** + +```bash +git commit bundles/ramble/panel/routes.js bundles/ramble/server/feed.js bundles/ramble/server/server.js \ + tests/ramble-panel.test.js tests/ramble-tools.test.js \ + -m "ramble: the routes answer for a player with no egg" +``` + +--- + +## Task 7: the five eggless surfaces + +**Files:** +- Modify: `bundles/ramble/panel/ramble.js`, `bundles/ramble/panel/static/ramble.js`, `bundles/ramble/panel/static/ramble.css` +- Modify: `tests/ramble-panel.test.js` + +**⚠ Finding 2 governs this task.** The **Next egg card must never be hidden** — it is the only route to the egg view and its daily check-in once a bird exists. It changes state. + +**Copy, under K5 ("the next you"):** + +| Surface | With an egg | With none | +|---|---|---| +| Egg view line | unchanged | "No one on the way just now." | +| Egg view sub-line | unchanged | "Nests hold them. So do friends." | +| Next egg card | percent + ring | "Nothing warming just now." + the lay line | +| Lay line, 0 days | — | "Keep yourself happy and you'll manage one yourself, in time." | +| Lay line, N days | — | "You've had N good days — keep it up and you'll manage one yourself." | +| Perch status line | "Your egg is N% warm." | omit the sentence entirely | +| AR view | egg art | the egg element hidden | + +- [ ] **Step 1: Write the failing panel-source tests** + +Add to `tests/ramble-panel.test.js`: + +```js +test("the Next egg card is never hidden — it is the only route to the check-in", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.ok(!/setHidden\(\s*\$\("rb-pet-nextegg"\)/.test(src), + "hiding it would delete the daily check-in for an eggless player (phase 1's defect)"); + assert.ok(src.includes("rb-nextegg-empty"), "it changes state instead"); +}); + +test("the eggless copy is present and written from inside the premise", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.ok(src.includes("No one on the way just now.")); + assert.ok(src.includes("Nothing warming just now.")); + assert.ok(src.includes("good days"), "K4: the soft count is named"); +}); + +test("the AR view hides the egg rather than drawing a phantom seed-0 one", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + const ar = src.slice(src.indexOf("function startAr()"), src.indexOf("function closeAr()")); + assert.ok(/setHidden\(\s*\$\("rb-ar-egg"\)/.test(ar), + "seedFromEggId(null) is 0, so an unguarded draw shows an egg that does not exist"); +}); + +test("the panel client still obeys its rules", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.equal((src.match(/`/g) || []).length, 0, "ZERO backticks: one truncates the served script"); + assert.equal((src.match(/innerHTML/g) || []).length, 2, "EXACTLY two markup sinks"); +}); +``` + +- [ ] **Step 2: Run it to watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: FAIL on the eggless-copy and AR assertions. + +- [ ] **Step 3: Add the eggless markup to the server-rendered shell** + +New *markup* goes in `bundles/ramble/panel/ramble.js`, not the client — that is how the sink count stays at two. Inside `#rb-pet-nextegg`, after the existing `.rb-row`, add: + +```html + + +``` + +And on the egg view, after `#rb-egg-line`, add: + +```html + +``` + +- [ ] **Step 4: Make the painters null-tolerant** + +In `bundles/ramble/panel/static/ramble.js`, `paintEgg`: + +```js + function paintEgg(state) { + if (!state || hatchLock) return; + var egg = state.egg; /* NULL when genuinely eggless */ + var list = state.checklist || {}; + + var has = !!egg; + eggPercent = has && typeof egg.percent === "number" ? egg.percent : 0; + eggSeedId = has ? egg.egg_id : null; + + setRing($("rb-egg-ring"), eggPercent); + setText($("rb-egg-percent"), has ? Math.round(eggPercent) + "%" : "—"); + setText($("rb-egg-line"), has + ? "warmth " + (egg.warmth || 0) + " of " + (egg.hatch_at || 0) + + " · it warms every time you get somewhere new" + : "No one on the way just now."); + setHidden($("rb-egg-empty"), has); + + var art = $("rb-egg-art"); + if (art) { setHidden(art, !has); if (has) drawEggArt(art, egg.egg_id); } + /* ...the rest of the function is unchanged... */ + } +``` + +`paintPet`, replacing the Next egg block: + +```js + /* The next you, and the ONLY route back to the egg view (and its daily + * check-in) once the perch belongs to a hatched bird. It changes state + * when there is no egg; it is never hidden. */ + var nextEgg = pet.egg || null; + var hasNext = !!nextEgg; + var nextPct = hasNext && typeof nextEgg.percent === "number" ? nextEgg.percent : 0; + if (hasNext) { eggPercent = nextPct; eggSeedId = nextEgg.egg_id; } + else { eggPercent = 0; eggSeedId = null; } + + setRing($("rb-nextegg-ring"), nextPct); + setText($("rb-nextegg-percent"), hasNext ? Math.round(nextPct) + "%" : "—"); + var nextArt = $("rb-nextegg-art"); + if (nextArt) { setHidden(nextArt, !hasNext); if (hasNext) drawEggArt(nextArt, nextEgg.egg_id); } + setHidden($("rb-nextegg-empty"), hasNext); + + var lay = pet.lay || null; + var layEl = $("rb-nextegg-lay"); + if (layEl) { + setHidden(layEl, hasNext || !lay); + if (!hasNext && lay) { + setText(layEl, lay.days > 0 + ? "You've had " + lay.days + " good " + (lay.days === 1 ? "day" : "days") + + " — keep it up and you'll manage one yourself." + : "Keep yourself happy and you'll manage one yourself, in time."); + } + } +``` + +`statusLine`, dropping the warmth sentence when there is nothing to warm: + +```js + if (perchTarget === "egg") { + /* No egg at all: say nothing about warmth rather than claiming 0%. */ + line = eggSeedId ? "Your egg is " + Math.round(eggPercent) + "% warm." : "Quiet around here right now."; + } else if (lastMarks.length === 0) { +``` + +`startAr`, guarding the draw: + +```js + /* seedFromEggId(null) is 0, so an unguarded call draws a phantom egg that + * does not exist. Hide the element instead. */ + var arEgg = $("rb-ar-egg"); + setHidden(arEgg, !eggSeedId); + if (eggSeedId) drawEggArt(arEgg, eggSeedId); +``` + +The framing fix (K5), in `paintHereArt` and `paintPerchGo`: + +```js + el.setAttribute("aria-label", "You"); /* was "You, and your bird" / "You, and your egg" */ +``` +```js + go.textContent = "You"; /* was "Your bird" / "Your egg" */ +``` + +- [ ] **Step 5: Style the eggless card** + +Append to `bundles/ramble/panel/static/ramble.css` — follow the file's existing token usage; do not invent variables: + +```css +#rb-nextegg-empty, +#rb-nextegg-lay, +#rb-egg-empty { margin-top: 6px; } +``` + +- [ ] **Step 6: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: PASS, including the zero-backticks and exactly-two-sinks assertions. + +- [ ] **Step 7: Commit** + +```bash +git commit bundles/ramble/panel/ramble.js bundles/ramble/panel/static/ramble.js \ + bundles/ramble/panel/static/ramble.css tests/ramble-panel.test.js \ + -m "ramble: the panel answers for a player with no egg" +``` + +--- + +## Task 8: the prologue + +**Files:** +- Modify: `bundles/ramble/panel/ramble.js`, `bundles/ramble/panel/static/ramble.js`, `bundles/ramble/panel/static/ramble.css` +- Modify: `tests/ramble-panel.test.js` + +**The copy, approved 2026-09-09.** Written from inside the premise (K5): you ARE the egg. Match the panel's register — plain, warm, slightly hushed. **The writing is a deliverable, not decoration (§4.4).** Use they/them for the bird, as the rest of the panel does. + +**Beat one** — shown when `intro_seen` is false AND `granted` is false: + +> You are an egg. +> +> You wandered off from your nest. Nobody is coming to look for you — that's alright. It happens more than you'd think. +> +> Nobody knows what's inside you yet. Not even you. +> +> Go somewhere. That's how eggs get warm. + +Button: **Go** + +**Beat two** — shown when a hatch has just been revealed AND `hatch_seen` is false: + +> You're out. A {species} — the only one rolled quite like you. +> +> You live on what you do: new streets, new faces, the small daily things. Keep moving and you stay bright. Go still long enough and you droop. +> +> That's all that happens. Nothing here is ever lost. + +Button: **Have a look at yourself** + +The last line is load-bearing: it states D2's no-fail-state promise in the game's own voice, which is the one thing a new player most needs told and which otherwise lives only in a guide doc. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/ramble-panel.test.js`: + +```js +test("both prologue beats are present, in the game's voice", () => { + const shell = readFileSync("bundles/ramble/panel/ramble.js", "utf8"); + assert.ok(shell.includes("You are an egg.")); + assert.ok(shell.includes("wandered off from your nest")); + assert.ok(shell.includes("Nobody knows what’s inside you yet") || + shell.includes("Nobody knows what's inside you yet")); + assert.ok(shell.includes("Nothing here is ever lost."), + "the no-fail-state promise lives in the game, not only in the docs"); + assert.ok(shell.includes("rb-prologue"), "the overlay exists in the server-rendered shell"); +}); + +test("the prologue is skippable and both beats dismiss", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.ok(src.includes("/api/ramble/prologue/intro")); + assert.ok(src.includes("/api/ramble/prologue/hatch")); +}); +``` + +- [ ] **Step 2: Run it to watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: FAIL — no prologue markup. + +- [ ] **Step 3: Add the overlay to the shell** + +In `bundles/ramble/panel/ramble.js`, near the other overlays, add — note `’` for apostrophes, matching the file's existing entity style, and no emoji: + +```html + + + +``` + +- [ ] **Step 4: Wire it in the client** + +In `bundles/ramble/panel/static/ramble.js` — `textContent` only, no backticks: + +```js + /* ------------------------------------------------------------ prologue */ + + function showPrologue(which) { + var root = $("rb-prologue"); + if (!root) return; + setHidden($("rb-prologue-intro"), which !== "intro"); + setHidden($("rb-prologue-hatch"), which !== "hatch"); + setHidden(root, false); + } + + function hidePrologue() { setHidden($("rb-prologue"), true); } + + /* Beat one is for a player who has never had an egg at all. Both the button + * and a dismissal grant it, so skipping the words never costs the egg. */ + function maybeIntro() { + return jsonFetch("/api/ramble/prologue").then(function (p) { + if (p && !p.intro_seen && !p.granted) showPrologue("intro"); + }).catch(function () { /* the prologue is never load-bearing */ }); + } + + var goBtn = $("rb-prologue-go"); + if (goBtn) goBtn.addEventListener("click", function () { + hidePrologue(); + jsonFetch("/api/ramble/prologue/intro", { method: "POST", body: {} }) + .then(function () { refreshEgg(); refreshPet(); }) + .catch(function () { /* the next load retries */ }); + }); + + /* Beat two rides the existing hatch reveal: the bird is already on screen, + * so this names what just happened rather than interrupting it. */ + function maybeHatchBeat(bird) { + jsonFetch("/api/ramble/prologue").then(function (p) { + if (!p || p.hatch_seen) return; + var lead = $("rb-prologue-hatch-lead"); + if (lead && bird && bird.species) { + setText(lead, "You're out. A " + bird.species + " — the only one rolled quite like you."); + } + showPrologue("hatch"); + }).catch(function () { /* cosmetic */ }); + } + + var seenBtn = $("rb-prologue-seen"); + if (seenBtn) seenBtn.addEventListener("click", function () { + hidePrologue(); + jsonFetch("/api/ramble/prologue/hatch", { method: "POST", body: {} }) + .then(function () { showView("pet"); }) + .catch(function () { /* the next load retries */ }); + }); +``` + +Call `maybeIntro()` once from the panel's existing start-up sequence, beside the other first-load fetches. Call `maybeHatchBeat(hatched)` from wherever the hatch reveal is dismissed, passing the hatched bird — grep for `hatchLock` to find that path and hook the existing dismissal rather than adding a second one. + +- [ ] **Step 5: Style the overlay** + +Append to `ramble.css`, reusing the tokens the panel's other overlays use — read one and copy its variables rather than inventing any: + +```css +.rb-prologue { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; + background: rgba(0, 0, 0, 0.55); padding: 24px; } +.rb-prologue-card { max-width: 30rem; border-radius: 14px; padding: 22px 20px; + background: var(--rb-card); color: var(--rb-fg); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); } +.rb-prologue-card p { margin: 0 0 12px; line-height: 1.5; } +.rb-prologue-lead { font-size: 1.15rem; font-weight: 600; } +@media (prefers-reduced-motion: no-preference) { + .rb-prologue { animation: rb-fade-in 240ms ease-out; } +} +``` + +If `--rb-card` / `--rb-fg` / `rb-fade-in` do not exist in the file, substitute the real token and keyframe names it already defines. **Check before writing.** + +- [ ] **Step 6: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git commit bundles/ramble/panel/ramble.js bundles/ramble/panel/static/ramble.js \ + bundles/ramble/panel/static/ramble.css tests/ramble-panel.test.js \ + -m "ramble: the prologue, in the game's own voice" +``` + +--- + +## Task 9: the docs, the version and the registry + +**Files:** +- Modify: `docs/guide/ramble.md`, `docs/es/guide/ramble.md`, `bundles/ramble/manifest.json`, `bundles/ramble/package.json`, `registry/add-ons.json` + +- [ ] **Step 1: Rewrite the guide's egg section** + +`docs/guide/ramble.md` opens its egg section with **"Every instance always has one egg incubating."** That is exactly what this phase makes false. Replace that paragraph with the new supply, and add the settings row: + +```markdown +## Your egg and your bird + +You are the egg. Real-world activity credits **warmth** toward it; at the hatch threshold you hatch +into a bird, and the incubating egg after that is the **next you**. + +Eggs come from three places, and none of them is free: + +- **Nests** — walk to one on the map, one claim per local day, within 75 m. +- **Gifts and swaps** from contacts. +- **Laying** — while you hold no egg at all, every local day you end happy counts one. At + `lay.days` (default 14) you lay one yourself. Days need not be consecutive, and the count only + runs while you are eggless, so this is a floor rather than a faucet. + +When the incubating slot empties, the oldest egg on your shelf is promoted into it automatically — +so you are only ever eggless when you genuinely have none. **Warmth earned with no egg at all +vanishes**; that is deliberate, and auto-promote is what keeps it rare. + +| Setting | Default | Governs | +|---|---|---| +| `lay.days` | 14 | Happy days while eggless before you lay one yourself | +``` + +- [ ] **Step 2: Mirror it in Spanish, by hand** + +**⚠ There is NO CI gate for guide-doc i18n parity** — `tests/i18n-global-parity.test.js` covers the translation-key mechanism only, and nothing diffs `docs/guide/` against `docs/es/guide/`. Phase 2's plan wrongly claimed otherwise and parity had to be done by hand. Update `docs/es/guide/ramble.md` to match, then verify by eye: + +```bash +diff <(grep -c '^' docs/guide/ramble.md) <(grep -c '^' docs/es/guide/ramble.md) || true +grep -n "lay.days" docs/guide/ramble.md docs/es/guide/ramble.md +``` + +Both files must mention `lay.days`, and neither may still claim an egg always exists. + +- [ ] **Step 3: Bump the version in BOTH files and rebuild the registry** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +cd /home/kh0pp/crow-wt-ramble-eggs +sed -i 's/"version": "0\.10\.0"/"version": "0.11.0"/' bundles/ramble/manifest.json bundles/ramble/package.json +grep -n '"version"' bundles/ramble/manifest.json bundles/ramble/package.json +npm run build-registry +git diff --stat registry/add-ons.json +``` + +Both must read `0.11.0`. **Without the bump `repairInstalledBundleAssets` never refreshes grackle's installed copy and the deploy silently ships nothing.** + +- [ ] **Step 4: Commit** + +```bash +git commit docs/guide/ramble.md docs/es/guide/ramble.md \ + bundles/ramble/manifest.json bundles/ramble/package.json registry/add-ons.json \ + -m "ramble 0.11.0: eggs are earned" +``` + +--- + +## Final verification + +- [ ] **The whole suite, in the foreground** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +cd /home/kh0pp/crow-wt-ramble-eggs +npm test 2>&1 | tail -25 +``` + +Expected: **4340 + the new tests**, 0 fail. A drop below 4340 means something was deleted, not fixed. **If the run reports ~552 failures, `node_modules` is missing** — re-create the symlink; it is not a regression. + +- [ ] **Confirm no schema change slipped in** + +```bash +git diff origin/main --stat -- scripts/init-db.js servers/sharing/instance-sync.js \ + servers/shared/sync-stamp.js servers/sharing/profile-avatar.js +``` + +Expected: **empty**. And for `init-tables.js`, prove the change was comment-only: + +```bash +git diff origin/main bundles/ramble/server/init-tables.js | grep -E "^[+-]" | grep -viE "^[+-]\s*(//|\*|/\*)" | grep -v "^[+-][+-]" +``` + +Expected: **no output**. If either check fails the design drifted; stop before going near a database. If both are clean, `scripts/schema-migration-dryrun.sh` has nothing to say — note that in the PR rather than skipping it silently. + +- [ ] **Confirm nothing mints an egg by accident any more** + +```bash +grep -rn "mintIncubatingEgg" bundles/ | grep -v node_modules +``` + +Expected: the definition, plus **exactly two** call sites — `grantStarterEgg` and `recordHappyDay`, both in `eggs.js`. Any third caller is the bug this phase exists to remove. + +- [ ] **Confirm the panel client rules held** + +```bash +grep -c '`' bundles/ramble/panel/static/ramble.js # must print 0 +grep -c 'innerHTML' bundles/ramble/panel/static/ramble.js +git show origin/main:bundles/ramble/panel/static/ramble.js | grep -c innerHTML +``` + +Zero backticks; the two `innerHTML` counts must match. + +- [ ] **Whole-branch adversarial review — NOT OPTIONAL** + +Dispatch a **fresh** reviewer over the entire branch diff against `origin/main`, not per-task. In phase 1 this gate caught three blocking defects that eight per-task reviews and four plan-review rounds all missed. **Do not hand the reviewer this plan's code as ground truth** — when a plan carries complete code, the per-task reviews are checking the plan, so this is the only independent check. Point it at the diff and the spec. Direct it at exactly this class: + +1. **What did we remove that was carrying something else?** The auto-minted egg was carrying the Next egg card, the perch's status line, the AR egg, and the egg view's daily check-in. Enumerate every affordance, not just the ones this plan named. Is the check-in still reachable for an eggless player who denies geolocation? +2. **Is there any path that still mints an egg implicitly?** Including sync applies, gift/trade receipt, and the MCP tools. +3. **Does anything converge wrongly across the user's own instances?** Auto-promote must pick the same egg on both with no round trip; every `layday` row must be `delta = 1`; nothing may write a negative delta. +4. **Can a player get permanently stuck with no egg and no route to one?** Walk the eggless state end to end: energy still arrives, happy days accrue, laying fires, the panel says what is happening. +5. **What does a player mid-flight see?** grackle has a live auto-minted egg and a live pet row. Nothing may be blank, wrong, or retroactively punishing. +6. **Is any egg, lay-day or balance now reachable by a contact?** + +Fix everything it finds ON THE BRANCH before opening the PR. **Re-review every fix round** — three of phase 2's round-2 findings were in code written to fix round 1. + +- [ ] **Open the PR** + +`gh` is not installed. Use the `github` MCP server. The body must state, at minimum: +- Phase 3 of the reward-economy spec — **the risky phase**, and why the four parts ship together. +- **No schema change, no migration, no `SCHEMA_GENERATION` bump** — laying rides `ramble_wallet`, prologue flags ride `ramble_settings`. +- **Finding 1**: `feedAll` gates the pet feed on `credited`, so warmth and energy had to be decoupled or laying would be unreachable. +- **Finding 3 / Deviation 1**: the spec's starter-egg race protection is impossible because `crowId` is per-instance; a random uuid plus the existing convergence rule replaces it. +- **Deviation 3**: auto-promote runs on read paths, and why that is not the same defect as auto-minting. +- **K1**: the grant is derived from replicated egg data so a game-state reset replays the prologue. +- **K5**: all copy is written from "you ARE the egg; the incubating egg is the next you". +- Ramble is installed on **grackle only** — crow primary and r4 have no Ramble bundle. + +- [ ] **Wait for CI green before merging** + +```bash +curl -s https://api.github.com/repos/kh0pper/crow/commits//check-runs \ + | python3 -c "import json,sys; [print(r['name'], r['status'], r['conclusion']) for r in json.load(sys.stdin)['check_runs']]" +``` + +Every run must be `completed` / `success`. Contexts: `suite`, `static-checks`, `audit`. An **empty** result on a current sha means something is wrong, not that the run is clean. + +- [ ] **Deploy** + +1. **Read `/home/kh0pp/CROW-SCHEDULE.md` first.** Note the Wednesday GPU benchmark chain holds crow 17:00 → 06:55; this deploy targets **grackle** and starts no model, so it does not conflict — but re-read rather than trusting this line. +2. Ramble is installed on **grackle only**. crow primary auto-restarts on the `~/crow` HEAD change and has nothing Ramble-shaped to pick up; r4 needs no action. +3. On grackle, resolve the database before touching it — **it is `~/crow/data/crow.db`, NOT `~/.crow/crow.db`** (that file is 0 bytes); bundles still install under `~/.crow/bundles/`: + ```bash + sudo lsof -p $(pgrep -f 'servers/gateway') | grep '\.db' + ``` +4. Back it up, then restart the gateway. +5. Verify after the restart: + - the installed copy refreshed `0.10.0` -> `0.11.0` and `server/egg-locks.js` is present, + - `SELECT status, count(*) FROM ramble_eggs GROUP BY status` — the live incubating egg is still there (K1: grandfathered, untouched), + - `SELECT count(*) FROM ramble_wallet WHERE kind IN ('layday','lay')` — expect 0, + - `SELECT count(*) FROM ramble_cells` — expect the existing 27, unchanged, + - `PRAGMA integrity_check` is ok, + - the journal shows `[proxy] addon ramble: connected` with no Ramble errors. +6. Clear the schedule entry if one was registered. + +- [ ] **Hand back to Kevin** + +Say plainly: what shipped; that his current egg is grandfathered and will be the last one that arrives on its own; that after it hatches the slot stays empty unless the shelf has one; what the eggless card will say; that the prologue will not fire for him until the game-state reset, and what that reset needs to clear (`ramble_eggs` and the two `prologue.%` settings rows); the phone smoke test still outstanding from phase 2 (walk to one of the 14 heart pips); and anything the whole-branch review found. + +--- + +## Out of scope — do not build these here + +- The **game-state reset** itself. Kevin wants it "once these phases are complete" (after phase 4), and no reset affordance exists in Ramble today. This phase only guarantees a reset would *work* — the grant reads replicated egg data and the flags are ordinary settings rows. +- A shop, a wardrobe, an accessory catalogue, or any spend path (phase 4). +- The **pedometer / steps arc** (queued after phase 4, 2026-09-09). It would add an energy source and therefore make happy days easier, retuning this phase's floor — `lay.days` is a live setting, so that is a config change, not a redesign. +- D2's sad portrait to contacts, and any change to `servers/sharing/profile-avatar.js`. +- Sweeping the pet page's leftover keeper framing ("Still an egg", "My bird", the chore card). This phase fixes the framing only on surfaces it already rewrites; the rest is a copy pass of its own. +- Fixing phase 1 and 2's known follow-ups: `unlockedCellsNear`'s full-table read, the seed cooldown's global UTC bucket, the AR view carrying no hearts or seed, `.rb-mapbar` wrapping on a phone. None is made worse by this phase. From a54b891ab8b58600b9983c293e4ca82d9119e995 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 13:44:04 -0500 Subject: [PATCH 02/20] plan: fix nine issues from the first adversarial review --- .../plans/2026-09-09-ramble-eggs-phase3.md | 461 +++++++++++++++--- 1 file changed, 383 insertions(+), 78 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md index 13e4bd25..240ff210 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md +++ b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md @@ -70,7 +70,7 @@ A fixed constant id is also wrong: a contact could gift you *their* starter egg - **No schema change, no migration, no `SCHEMA_GENERATION` bump.** Laying rides `ramble_wallet` (`kind`, `key`, `delta`, `created_at`, `lamport_ts`); prologue flags ride `ramble_settings`. `scripts/init-db.js` is **not modified**. `bundles/ramble/server/init-tables.js` is modified for **a comment only** (Task 3) — its SQL must be byte-identical. - **Ledgers, not balances (spec §6.1):** a lay-day is an append-only row under a natural idempotent key (the local day). Never store a day count. - **A `layday` row's `delta` is ALWAYS the integer `1`.** `applyRambleWallet` resolves conflicts with `MAX(delta)`, which is only convergent when the value cannot differ between instances for the same key. Phase 2 shipped a bug here precisely because seed's `delta` was a live setting. **Never write a negative delta** — `MAX(delta)` would resolve `-10`/`-5` to `-5`. -- **You are never simultaneously birdless and eggless.** `hatchIfReady` sets `active_egg_id` on the first hatch and eggless can only occur after a hatch, so the eggless state always has a bird to show. Copy may rely on this; code should still not crash if it is false. +- **⚠ A player CAN be simultaneously birdless and eggless, and it is the most important state in the phase.** An earlier draft of this plan asserted the opposite. It is false: a brand-new player between first load and tapping the prologue's **Go** button has neither, and so does anyone whose `POST /api/ramble/prologue/intro` failed. That window *is* the new-player experience this phase exists to build. Every surface must render it without a phantom egg (see Task 7's `hereArt` and AR fixes) and without a crash. - **Panel client rules, test-enforced:** `bundles/ramble/panel/static/ramble.js` must keep **ZERO backticks** (one truncates the served script; the slip is markdown habit in a code comment), **EXACTLY TWO** engine markup sinks, `textContent` only, and no emoji. `setAttribute`/`removeAttribute`/`className`/Leaflet layer calls are not markup sinks. Build every new node with `createElement` + `textContent`; put new *markup* in the server-rendered shell `panel/ramble.js` and toggle it with `hidden`. - **Invisible characters:** write any bidi/control character as a `\u` escape, never a raw byte. - **Commits:** subject-only message, positional paths (`git commit -m "..."`, never `git add -A`), `git add` new files first. **NO AI-attribution trailers of any kind.** @@ -83,7 +83,8 @@ A fixed constant id is also wrong: a contact could gift you *their* starter egg 1. **The starter-egg id is a random UUID gated on an empty egg table, not derived from the Crow identity.** See Finding 3 — the spec's mechanism is impossible because `crowId` is per-instance. Say this in the PR body so a reviewer reads it as a correction, not drift. 2. **`creditWarmth` still writes its ledger row when there is no egg.** See Finding 1. The spec does not describe the interaction with `feedAll`'s `shouldFeedPet` gate at all; without this, laying is unreachable. -3. **Auto-promote runs lazily on read paths as well as on hatch.** Spec §4.2 says "when the incubating slot empties" without saying who notices. A slot can empty from a sync arrival that no local code path observes, so the read paths carry a *guarded* promote. This is a write during a GET and a reviewer should challenge it — the justification is that it moves an egg the user already owns rather than conjuring one, and Task 3 has a test asserting a read with nothing promotable writes nothing. +3. **A two-instance fleet can lay two eggs from one lay, and that is accepted.** Both instances eggless, both observing the same happy days, both crossing the threshold before a sync: each writes the same `lay:` key locally (so `rowsAffected` is 1 on both) and each mints an egg with its own UUID. `applyRambleEgg` then shelves one, so the user ends with one incubating and one spare on the shelf. Making this impossible would need a deterministic egg id derived from the lay day — which reintroduces exactly the cross-user collision that rules a constant id out for the starter egg (a contact who laid on the same date could gift you a colliding row). **Ramble is installed on grackle only**, so this is currently unreachable; the graceful degradation is the existing convergence rule, and the cost of the alternative is a real collision for a hypothetical one. Revisit if a second instance ever installs the bundle. +4. **Auto-promote runs lazily on read paths as well as on hatch, in a weaker "speculative" mode.** Spec §4.2 says "when the incubating slot empties" without saying who notices. A slot can empty from a sync arrival that no local code path observes, so the read paths carry a *guarded* promote. It is still a write during a GET, and the first draft of this plan defended it only as "it moves an egg the user already owns" — which is true and beside the point. The real hazard, found in review: `applyRambleEgg` carries an explicit carve-out (`instance-sync.js:855-860`) refusing to re-promote on a peer's user-shelve, because the replacement egg's row "follows in the same drain" and promoting there would draft an old egg that then out-ranks the user's real choice on both instances. A GET landing in that window would do exactly that. So the read-path promote marks `shelf_origin = 'sync'` (which ranks *below* any NULL-origin egg, so a real choice out-ranks it on arrival) and **never emits** — the same discipline the sync layer's own re-promote uses. Task 3 tests both modes. --- @@ -149,8 +150,8 @@ Create `bundles/ramble/server/egg-locks.js`, pasting the real `OPEN_SQL` from St * trades.js, but trades.js imports `startOfLocalDay` from eggs.js, and phase * 3's auto-promote (eggs.js) must skip a locked egg — so keeping it there * would force an eggs -> trades -> eggs cycle. trades.js re-exports both - * helpers, so every existing consumer is unchanged and there is still exactly - * one definition of "locked". + * helpers, so its existing importer (flock.js, the only one) is unchanged and + * there is still exactly one definition of "locked". */ // Verbatim from trades.js:68. An "open" trade is one that still has a claim on @@ -307,18 +308,37 @@ test("creditWarmth with no egg: the ledger row is written, warmth vanishes (D3)" test("REGRESSION: walking, meeting a crow and checking in ALL still feed energy with no egg", async () => { const db = await freshDb(); + // ⚠ EXACT VALUES, NOT `> 0` OR `>=`. `ramble_pet.energy` DEFAULTS TO 60 + // (init-tables.js:105), so `energy > 0` is true whether or not anything was + // fed, and `>=` is true when the feed was SKIPPED and the value did not + // move. An earlier draft of this very test asserted exactly that and would + // have passed against the death spiral it exists to prevent — phase 2's + // vacuous-fixture lesson, on the one test that most needed to be sharp. + // Deltas (pet.js FEED_DELTAS): checkin +5, visit_place +15, meet_crow +20, + // against the base ceiling of 100. const before = await feedAll(db, { type: "checkin" }, { now: T0 }); assert.equal(await eggCount(db), 0, "feeding must not mint an egg"); - assert.ok(before.pet, "a pet is always returned"); - assert.ok(before.pet.energy > 0, "the check-in fed the bird even with no egg"); + assert.equal(before.pet.energy, 65, "60 + 5: the check-in fed the bird with no egg"); const place = await feedAll(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 + 1000 }); + assert.equal(place.pet.energy, 80, "65 + 15: a new place fed the bird"); + const crow = await feedAll(db, { type: "meet_crow", persona: "abc123" }, { now: T0 + 2000 }); - assert.ok(place.pet.energy >= before.pet.energy, "a new place fed the bird"); - assert.ok(crow.pet.energy >= place.pet.energy, "meeting a crow fed the bird"); + assert.equal(crow.pet.energy, 100, "80 + 20: meeting a crow fed the bird"); assert.equal(await eggCount(db), 0); }); +test("NEGATIVE CONTROL: a repeat visit_place does not feed, so the test above can fail", async () => { + // Without this, an implementation that fed unconditionally would also pass + // the test above. `shouldFeedPet` must still honour the dedup key. + const db = await freshDb(); + const first = await feedAll(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 }); + assert.equal(first.pet.energy, 75, "60 + 15"); + const repeat = await feedAll(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 + 1000 }); + assert.equal(repeat.credited, false, "same cell, same ISO week"); + assert.equal(repeat.pet.energy, 75, "a not-credited keyed event must NOT feed"); +}); + test("an unknown or pet-only event is still a pure read with no egg", async () => { const db = await freshDb(); await creditWarmth(db, { type: "chore" }, { now: T0 }); @@ -463,10 +483,24 @@ git commit bundles/ramble/server/eggs.js tests/ramble-eggs-supply.test.js tests/ **Files:** - Modify: `bundles/ramble/server/eggs.js`, `bundles/ramble/server/flock.js`, `bundles/ramble/server/init-tables.js` (comment only) - Modify: `tests/ramble-eggs-supply.test.js` +- **Modify (these WILL go red — they assert the behaviour this task removes):** `tests/ramble-eggs.test.js`, `tests/ramble-flock.test.js`, `tests/ramble-tools.test.js`, `tests/ramble-panel.test.js` + +**⚠ Task 3 breaks seven existing assertions. They are not "maybe" — each was checked against the code. Rewrite each to assert the NEW contract; do not weaken or delete a test to make it pass:** + +| File:line | What it asserts today | What it becomes | +|---|---|---| +| `tests/ramble-eggs.test.js:49` | `[["hatched",1],["incubating",1]]`, titled *"…and starts the next egg"* | `[["hatched",1]]` — no successor. Retitle. | +| `tests/ramble-eggs.test.js:58` | `s.egg.egg_id` after a hatch | `assert.equal(s.egg, null)` | +| `tests/ramble-eggs.test.js:71` | `before.egg.warmth` after a hatch | null-guard, or shelve an egg first so one exists | +| `tests/ramble-eggs.test.js:82` | `before.egg.warmth` after a hatch | same | +| `tests/ramble-flock.test.js:150` | *"a successor egg was minted"* | the slot is empty after a hatch with an empty shelf | +| `tests/ramble-flock.test.js:172` | *"the incubating egg is ensured and listed first"* | mint one explicitly as a fixture, then assert ordering | +| `tests/ramble-tools.test.js:177` | `s.eggs[0].status === "incubating"` | mint a fixture, or assert the eggless shape | +| `tests/ramble-panel.test.js:1227` | `flock.eggs.find(e => e.status === "incubating")` — the file header (line 53) says it "churns hatches and later asserts an incubating egg exists" | give the fixture an explicit egg | **Interfaces:** -- Produces: `promoteFromShelf(db, { now, emit }) -> Promise` — promotes the oldest non-locked `shelf`/`received` egg into the incubating slot, or returns null. -- Consumed by: `hatchIfReady` (eggs.js), `eggState`/`flockState`/`petState` read paths (Task 4/6), `panel/routes.js`. +- Produces: `promoteFromShelf(db, { now, emit, speculative }) -> Promise` — promotes the oldest non-locked `shelf`/`received` egg into the incubating slot, or returns null. +- Consumed by: `hatchIfReady` (eggs.js, `speculative: false`), `flockState` (eggs.js, `speculative: true`), and `GET /api/ramble/egg` in `panel/routes.js` (Task 6, `speculative: true`). **`petState` does NOT promote** — an earlier draft's interface block claimed it did while no task added it. **The ordering rule, which is the whole design:** both instances must pick the **same** egg with no round trip. The order is `created_at ASC, egg_id ASC` — a total order and a pure function of replicated rows, exactly the order `RAMBLE_EGG_REPROMOTE_SQL` already uses for the sync layer's own re-promote. @@ -500,10 +534,32 @@ test("promoteFromShelf takes the OLDEST shelf egg and clears shelf_origin", asyn assert.equal(promoted.egg_id, "older", "oldest created_at wins"); assert.equal((await statusOf(db, "older")).status, "incubating"); assert.equal((await statusOf(db, "older")).shelf_origin, null, - "an incubating egg carries no shelf origin, same as the manual incubate path"); + "a deliberate promote carries no shelf origin, same as the manual incubate path"); assert.equal((await statusOf(db, "younger")).status, "shelf", "only one is drafted"); }); +test("a SPECULATIVE promote marks 'sync' and never emits, so it cannot out-rank a real choice", async () => { + const db = await freshDb(); + await shelveEgg(db, "parked", T0); + const emitted = []; + const emit = (table, op, row) => { emitted.push([table, op, row.egg_id]); }; + + const promoted = await promoteFromShelf(db, { now: T0 + 1000, emit, speculative: true }); + assert.equal(promoted.egg_id, "parked"); + assert.equal((await statusOf(db, "parked")).shelf_origin, "sync", + "'sync' ranks BELOW a NULL-origin egg in applyRambleEgg, so the user's real choice wins"); + assert.deepEqual(emitted, [], + "a guess must not replicate — instance-sync.js:855 refuses to draft during an incubate swap"); +}); + +test("a DELIBERATE promote does emit, because the user really moved to a new egg", async () => { + const db = await freshDb(); + await shelveEgg(db, "next", T0); + const emitted = []; + await promoteFromShelf(db, { now: T0 + 1000, emit: (t, o, r) => emitted.push([t, o, r.egg_id]) }); + assert.deepEqual(emitted, [["ramble_eggs", "update", "next"]]); +}); + test("promoteFromShelf breaks a created_at tie by the lower egg_id, so two instances agree", async () => { const db = await freshDb(); await shelveEgg(db, "bbb", T0); @@ -538,6 +594,21 @@ test("promoteFromShelf SKIPS an egg spoken for by an open swap", async () => { assert.equal((await statusOf(db, "locked-one")).status, "shelf"); }); +test("an EXPIRED but unswept trade still locks its egg — do not 'fix' the predicate", async () => { + // expireTrades runs on the 15 s drain tick, so there is a window where a + // lapsed offer is still 'proposed'. The slot stays empty until the sweep. + // Harmless, and pinned here so nobody later widens OPEN_SQL to "fix" it. + const db = await freshDb(); + await shelveEgg(db, "only-one", T0); + await db.execute({ + sql: `INSERT INTO ramble_trades + (trade_id, counterpart, role, my_egg_id, state, created_at, updated_at, expires_at) + VALUES ('t-expired', 'npub-them', 'proposer', 'only-one', 'proposed', ?, ?, ?)`, + args: [T0, T0, T0 - 1000], // already past expires_at, not yet swept + }); + assert.equal(await promoteFromShelf(db, { now: T0 + 5000 }), null); +}); + test("promoteFromShelf is a NO-OP when the slot is full, and when there is nothing to promote", async () => { const db = await freshDb(); assert.equal(await promoteFromShelf(db, { now: T0 }), null, "empty shelf, empty slot"); @@ -629,8 +700,30 @@ Then: * * Writes NOTHING when the slot is occupied or nothing is promotable, which is * what makes it safe to call from a read path. + * + * ⚠ TWO MODES, and the difference is a real hazard, not a style choice. + * + * `speculative: false` (the hatch path) is a deliberate local event: the egg + * takes `shelf_origin = NULL` — the TOP class in applyRambleEgg's convergence + * rule — and the row is emitted, because the user really did just move to a + * new egg. + * + * `speculative: true` (the read paths) is a guess. The slot may be empty only + * because a peer's drain is mid-flight: applyRambleEgg carries an explicit + * carve-out (instance-sync.js:855-860) saying a peer's USER shelve is half of + * an "incubate" swap and the replacement row "follows in the same drain", so + * re-promoting there "would draft an old convergence loser into the slot, + * which then out-ranks the user's real choice by created_at on both sides". + * A GET landing in that window would do exactly what that carve-out refuses + * to do. So a speculative promote: + * - marks the egg `shelf_origin = 'sync'`, which ranks BELOW any NULL-origin + * egg, so the user's real choice out-ranks it the moment it arrives; and + * - NEVER emits, so it stays a local re-derivation each instance performs + * for itself — the same discipline the sync layer's own re-promote uses. + * This is deliberately the same semantics as RAMBLE_EGG_REPROMOTE_SQL, + * extended to 'user' eggs so §4.2's release valve still works. */ -export async function promoteFromShelf(db, { now, emit } = {}) { +export async function promoteFromShelf(db, { now, emit, speculative = false } = {}) { void now; if (await getIncubatingEgg(db)) return null; @@ -648,15 +741,16 @@ export async function promoteFromShelf(db, { now, emit } = {}) { // a query against the table's contents, not a schema constraint, so two // overlapping promotes must not both succeed. const { rowsAffected } = await db.execute({ - sql: `UPDATE ramble_eggs SET status = 'incubating', shelf_origin = NULL + sql: `UPDATE ramble_eggs SET status = 'incubating', shelf_origin = ? WHERE egg_id = ? AND status IN ('shelf', 'received') AND NOT EXISTS (SELECT 1 FROM ramble_eggs WHERE status = 'incubating')`, - args: [next.egg_id], + args: [speculative ? "sync" : null, next.egg_id], }); if (rowsAffected === 0) return null; const promoted = await getIncubatingEgg(db); - if (promoted) await safeEmit(emit, "ramble_eggs", "update", promoted); + // A speculative promote never emits — see the two-modes note above. + if (promoted && !speculative) await safeEmit(emit, "ramble_eggs", "update", promoted); return promoted; } ``` @@ -685,7 +779,9 @@ In `bundles/ramble/server/flock.js`, delete `await ensureIncubatingEgg(db, { now // can empty from a sync arrival that no local code path observed — that // moves an egg the user already owns rather than conjuring one, and // promoteFromShelf writes nothing when there is nothing to promote. - await promoteFromShelf(db, { now }); + // SPECULATIVE: marks 'sync' and does not emit, so a GET landing mid-drain + // cannot out-rank a deliberate incubate made on another instance. + await promoteFromShelf(db, { now, speculative: true }); ``` Update the import: drop `ensureIncubatingEgg`, add `promoteFromShelf`. @@ -718,17 +814,21 @@ Expected: **no output.** Any line here means executable SQL changed and the chan ```bash node scripts/run-suite.mjs tests/ramble-eggs-supply.test.js +node scripts/run-suite.mjs tests/ramble-eggs.test.js node scripts/run-suite.mjs tests/ramble-flock.test.js +node scripts/run-suite.mjs tests/ramble-tools.test.js +node scripts/run-suite.mjs tests/ramble-panel.test.js node scripts/run-suite.mjs tests/ramble-sync.test.js ``` -Expected: all PASS. `tests/ramble-flock.test.js` may have a test asserting a successor appears after a hatch — if so, it is asserting the behaviour this phase removes: update it to assert the slot is empty, and say so in the commit. +Expected: all PASS, after the seven assertions in the table above have been rewritten. **Do not proceed to Task 4 with any of these red** — a later task's failure is much harder to attribute once several are broken at once. - [ ] **Step 8: Commit** ```bash git commit bundles/ramble/server/eggs.js bundles/ramble/server/flock.js \ - bundles/ramble/server/init-tables.js tests/ramble-eggs-supply.test.js tests/ramble-flock.test.js \ + bundles/ramble/server/init-tables.js tests/ramble-eggs-supply.test.js \ + tests/ramble-eggs.test.js tests/ramble-flock.test.js tests/ramble-tools.test.js tests/ramble-panel.test.js \ -m "ramble: the shelf refills the incubating slot, and hatching mints nothing" ``` @@ -773,8 +873,9 @@ import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { mintIncubatingEgg, getIncubatingEgg, recordHappyDay, layProgress, - hasAnyEggAnywhere, readLaySettings, LAY_DAYS_DEFAULT, + hasAnyEggAnywhere, readLaySettings, LAY_DAYS_DEFAULT, localDay, } from "../bundles/ramble/server/eggs.js"; +import { applyRambleWallet } from "../servers/sharing/instance-sync.js"; const DAY = 86400000; const T0 = Date.UTC(2026, 8, 9, 12, 0, 0); @@ -892,6 +993,61 @@ test("layProgress reports what the panel needs", async () => { await recordHappyDay(db, { now: T0, mood: "happy" }); assert.deepEqual(await layProgress(db), { days: 1, needed: 14 }); }); + +/* ------------------------------------------------------- multi-instance + * Spec §8: "every currency ledger replicates, so both need multi-instance + * tests, not single-database ones." A sync defect already cost this project + * real data; prose review is not sufficient here. The pattern below follows + * tests/ramble-cells-sync.test.js. + */ + +test("MULTI-INSTANCE: a peer's layday rows converge to the same count", async () => { + const a = await freshDb(); + const b = await freshDb(); + await setLayDays(a, 5); await setLayDays(b, 5); + + const rows = []; + const emit = (table, op, row) => { if (table === "ramble_wallet") rows.push(row); }; + await recordHappyDay(a, { now: T0, mood: "happy", emit }); + await recordHappyDay(a, { now: T0 + DAY, mood: "happy", emit }); + + for (const r of rows) await applyRambleWallet(b, "insert", r, 1); + assert.equal((await layProgress(b)).days, 2, "b sees a's days"); + assert.equal((await layProgress(a)).days, 2, "and a is unchanged"); +}); + +test("MULTI-INSTANCE: created_at going BACKWARDS on apply must not change the count", async () => { + // applyRambleWallet does created_at = MIN(local, incoming). This is exactly + // why layProgress orders by `key` and not by `created_at`: an apply can move + // a row's timestamp across the reset boundary, and clock skew between the + // user's own machines is enough to do it. + const db = await freshDb(); + await setLayDays(db, 99); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "happy" }); + const before = (await layProgress(db)).days; + + await applyRambleWallet(db, "insert", + { kind: "layday", key: localDay(T0 + DAY), delta: 1, created_at: 0 }, 9); + + assert.equal((await layProgress(db)).days, before, + "a rewritten created_at must not move a day in or out of the count"); +}); + +test("MULTI-INSTANCE: a peer's lay row resets this instance's count too", async () => { + const db = await freshDb(); + await setLayDays(db, 99); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 2); + + // The peer laid on the later day. + await applyRambleWallet(db, "insert", + { kind: "lay", key: localDay(T0 + DAY), delta: 1, created_at: T0 + DAY }, 5); + + assert.equal((await layProgress(db)).days, 0, + "both instances agree the count is spent, with nothing deleted"); +}); ``` - [ ] **Step 2: Run it to watch it fail** @@ -934,19 +1090,27 @@ export async function hasAnyEggAnywhere(db) { /** * Happy days banked since the last lay. The count RESETS without deleting a - * row: `lay` rows mark each laying, and only `layday` rows newer than the most - * recent one count. Both instances derive the same number from the same - * replicated rows, and the ledger stays append-only (spec §6.1). + * row: `lay` rows mark each laying, and only `layday` rows AFTER the most + * recent one count. The ledger stays append-only (spec §6.1). + * + * ⚠ ORDERED BY `key`, NEVER BY `created_at`. Both are tempting; only one + * converges. `applyRambleWallet` resolves a conflict with + * `created_at = MIN(local, incoming)` (instance-sync.js:620), so a sync apply + * can move a row's timestamp BACKWARDS — across the reset boundary, in either + * direction — and clock skew between the user's machines is enough to do it + * on its own. `key` is the local day (`YYYY-MM-DD`), it is half the primary + * key, it sorts lexically in true date order, and `applyRambleWallet` never + * rewrites it. Comparing keys therefore yields the same number on every + * instance from the same rows. It also excludes the lay-day itself, which is + * correct: the day you laid is spent. */ export async function layProgress(db) { const { layDays } = await readLaySettings(db); - const { rows: lastLay } = await db.execute({ - sql: `SELECT MAX(created_at) AS at FROM ramble_wallet WHERE kind = ?`, args: [LAY_KIND], - }); - const since = Number(lastLay[0]?.at ?? 0) || 0; const { rows } = await db.execute({ - sql: `SELECT count(*) AS n FROM ramble_wallet WHERE kind = ? AND created_at > ?`, - args: [LAYDAY_KIND, since], + sql: `SELECT count(*) AS n FROM ramble_wallet + WHERE kind = ? + AND key > COALESCE((SELECT MAX(key) FROM ramble_wallet WHERE kind = ?), '')`, + args: [LAYDAY_KIND, LAY_KIND], }); return { days: Number(rows[0]?.n ?? 0), needed: layDays }; } @@ -963,6 +1127,15 @@ export async function layProgress(db) { * supply instead of a backstop. * * ⚠ delta is the literal 1. See applyRambleWallet's MAX(delta) rule. + * + * ⚠ Called ONLY from the write paths (`feed`, and `doChore` through it) — + * never from `petState`. `pet.js:18` records the invariant: "petState's + * decay-on-read write never emits, because a GET must never queue a sync op", + * and `petState` has no `emit` in scope to pass. A day is therefore earned by + * DOING something — a walk, a chore, a check-in — not by opening the app, + * which is also the truer reading of §4.3's "sustained care". A player who + * denies geolocation can still bank days: chores and the daily check-in both + * run through `feed`. */ export async function recordHappyDay(db, { now = Date.now(), mood, emit } = {}) { if (mood !== "happy") return { recorded: false, laid: false }; @@ -979,34 +1152,38 @@ export async function recordHappyDay(db, { now = Date.now(), mood, emit } = {}) const { days, needed } = await layProgress(db); if (days < needed) return { recorded: true, laid: false }; - await db.execute({ + // ⚠ The mint is gated on the `lay` row being NEW. Without checking + // rowsAffected the dedup key just written would be decorative, and two + // overlapping calls would each mint an egg. + const { rowsAffected: laidNow } = await db.execute({ sql: `INSERT OR IGNORE INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?)`, args: [LAY_KIND, key, now], }); + if (laidNow === 0) return { recorded: true, laid: false }; + await safeEmit(emit, "ramble_wallet", "insert", { kind: LAY_KIND, key, delta: 1, created_at: now }); await mintIncubatingEgg(db, { now, emit }); return { recorded: true, laid: true }; } ``` -- [ ] **Step 4: Call it from `pet.js`, after the mood is known** +- [ ] **Step 4: Call it from `pet.js`'s WRITE path only** -`pet.js` already imports `localDay` from `eggs.js`; extend that import with `recordHappyDay`. In **`petState`**, after decay has been applied and `mood` computed, and after the decay row is persisted: +`pet.js` already imports `localDay` from `eggs.js`; extend that import with `recordHappyDay`. In **`feed`** only, after `const mood = moodFor(energy);` and the pet row has been written: ```js - // Phase 3 (spec §4.3): a day counts when the bird is OBSERVED happy while - // wholly eggless. Idempotent per local day, and a no-op the moment the - // player holds any egg — so this is cheap on every poll. + // Phase 3 (spec §4.3): a day counts when the bird is happy while wholly + // eggless. Idempotent per local day, and a no-op the moment the player + // holds any egg. `doChore` reaches this through feed(), so chores and the + // daily check-in both count. await recordHappyDay(db, { now, mood, emit }); ``` -And in **`feed`**, after `const mood = moodFor(energy);` and the row is written: +**⚠ Do NOT also call it from `petState`.** Three reasons, and the first is fatal on its own: -```js - await recordHappyDay(db, { now, mood, emit }); -``` - -Both call sites are needed: a player who walks (feed) and a player who only looks (petState) have both been seen that day, and `paintPet` polls `/api/ramble/pet` on a player who never posts a fix. +1. `petState(db, { now = Date.now() } = {})` has **no `emit` in scope** (`pet.js:223`). The call would be a `ReferenceError` on every `GET /api/ramble/pet` and every `ramble_pet_state` MCP call — for every player, egg or not. +2. `pet.js:13-21` documents the invariant it would break: *"`petState`'s decay-on-read write never emits, because a GET must never queue a sync op."* Neither caller passes an `emit` (`routes.js` calls `petState(db, { now })`; `server.js:305` calls `petState(db)`), so even adding the parameter would silently produce lay-days that never replicate. +3. It is the better game rule anyway: a day is earned by **doing** something, not by opening the app. That is the truer reading of §4.3's "sustained care", and a player who denies geolocation is not shut out — chores and the daily check-in both run through `feed`. - [ ] **Step 5: Run the tests** @@ -1319,7 +1496,10 @@ In `bundles/ramble/panel/routes.js`, wherever `mods.eggsMod` is assembled, make router.get("/api/ramble/egg", handle(async (req, res) => { // A slot can empty from a sync arrival no local path observed; this moves // an egg the user already owns and writes nothing when there is none. - await mods.eggsMod.promoteFromShelf(db, { now: Date.now(), emit }); + // SPECULATIVE and therefore NOT emitting — a GET must never queue a sync + // op, and a promote landing mid-drain must not out-rank a deliberate + // incubate made on another instance. Note there is no `emit` argument. + await mods.eggsMod.promoteFromShelf(db, { now: Date.now(), speculative: true }); const state = await mods.eggsMod.eggState(db, { now: Date.now() }); const lay = await mods.eggsMod.layProgress(db); res.json({ ...state, lay }); @@ -1343,26 +1523,49 @@ In `bundles/ramble/panel/routes.js`, wherever `mods.eggsMod` is assembled, make Keep the existing `GET /api/ramble/egg` handler's other behaviour (auth, error handling) exactly as it was — copy the surrounding shape from the file rather than the sketch above. -- [ ] **Step 4: Carry the egg summary and lay progress on the pet read** +- [ ] **Step 4: Fix `GET /api/ramble/pet`, which 500s for every eggless player** -In `bundles/ramble/server/feed.js`, `readPet` already returns the pet row shape the panel uses. Extend it so the pet response carries a **nullable** egg summary and the lay count — `paintPet` reads `pet.egg.percent` today and must not throw on `null`: +**⚠ This is the single highest-severity line in the phase.** `bundles/ramble/panel/routes.js:816` currently reads: ```js - const eggSummary = await eggState(db, { now }); - const lay = await layProgress(db); - return { - ...pet, - // null when the player is genuinely eggless. paintPet must not assume one. - egg: eggSummary.egg ? { ...eggSummary.egg } : null, - lay, - }; + egg: { percent: egg.egg.percent }, ``` -Import `eggState` and `layProgress` from `./eggs.js` in `feed.js`. +where `egg` is `await mods.eggsMod.eggState(db, { now })` (line 812). The moment Task 2 makes `eggState` return `egg: null`, that dereference throws, `handle()` turns it into a 500, and the **pet view dies entirely** — which is the only view an eggless player with a bird can reach from the perch. Replace lines 812-820's egg line with: -- [ ] **Step 5: Keep the MCP tools in step with the HTTP routes** +```js + const eggSummary = await mods.eggsMod.eggState(db, { now }); + const lay = await mods.eggsMod.layProgress(db); + res.json({ + ...pet, + bird, + // null when genuinely eggless — paintPet must not assume one exists. + egg: eggSummary.egg ?? null, + lay, + seed: await mods.walletMod.seedBalance(db), + hearts: await mods.heartsMod.heartsBalance(db), + energy_max_cap: (await mods.heartsMod.readHeartSettings(db)).cap, + }); +``` -In `bundles/ramble/server/server.js`, the `ramble_egg_state` and `ramble_pet_state` tools call the same functions. They pick up `egg: null` and `lay` for free from the spread — **verify that by reading the code**, and if either tool destructures `egg` or assumes it, fix it there. A tool and a route that disagree about the same egg is the defect phase 2 caught late. +**⚠ Do NOT touch `readPet` in `bundles/ramble/server/feed.js`.** An earlier draft of this plan directed the change there, wrongly: `readPet` is private to `feedAll` and is used only on its not-credited branch (`feed.js:48`) — nothing on the `/api/ramble/pet` path calls it, so editing it would leave line 816 unpatched. Worse, adding keys to `readPet` breaks a green test: `tests/ramble-feed.test.js:68` asserts `Object.keys(repeat.pet)` equals `Object.keys(first.pet)`, where `first.pet` comes from `petFeed` and `repeat.pet` from `readPet`. Adding `egg`/`lay` to one and not the other is exactly the divergence that test exists to catch. + +- [ ] **Step 5: Fix the MCP tool, which breaks the same way** + +`bundles/ramble/server/server.js:306` **explicitly dereferences** `egg.egg.percent` — it does not pick the new shape up "for free from the spread". Patch it exactly as the route above: + +```js + egg: eggSummary.egg ?? null, + lay: await layProgress(db), +``` + +A tool and a route that disagree about the same egg is the defect phase 2 caught late. After patching, grep for any other dereference of `.egg.` that assumes non-null: + +```bash +grep -rn "\.egg\.\(percent\|warmth\|egg_id\|hatch_at\)" bundles/ramble/ | grep -v node_modules +``` + +Every hit must be null-guarded or provably reached only when an egg exists. - [ ] **Step 6: Run the tests** @@ -1386,8 +1589,8 @@ git commit bundles/ramble/panel/routes.js bundles/ramble/server/feed.js bundles/ ## Task 7: the five eggless surfaces **Files:** -- Modify: `bundles/ramble/panel/ramble.js`, `bundles/ramble/panel/static/ramble.js`, `bundles/ramble/panel/static/ramble.css` -- Modify: `tests/ramble-panel.test.js` +- Modify: `bundles/ramble/panel/ramble.js`, `bundles/ramble/panel/static/ramble.js`, `bundles/ramble/panel/static/ramble-ar.js`, `bundles/ramble/panel/static/ramble.css` +- Modify: `tests/ramble-panel.test.js`, `tests/ramble-ar.test.js` **⚠ Finding 2 governs this task.** The **Next egg card must never be hidden** — it is the only route to the egg view and its daily check-in once a bird exists. It changes state. @@ -1402,6 +1605,12 @@ git commit bundles/ramble/panel/routes.js bundles/ramble/server/feed.js bundles/ | Lay line, N days | — | "You've had N good days — keep it up and you'll manage one yourself." | | Perch status line | "Your egg is N% warm." | omit the sentence entirely | | AR view | egg art | the egg element hidden | +| Check-in confirmation | "Checked in. That is today's warmth." | "Checked in. Nothing to warm yet — but it counted." | +| Map marker | walking egg | plain dot when there is no bird AND no egg | + +**S2 — the check-in must stop lying.** `static/ramble.js:1244` says *"Checked in. That is today's warmth."* With no egg, `credited` is true but the warmth vanished (D3). K4 requires the eggless state to be legible, and this is the one screen that would actively contradict it. Branch the message on whether an egg exists. + +**S3 — the perch door keeps an affordance.** An earlier draft relabelled both the marker's `aria-label` and the world view's GPS-independent button to bare `"You"`. That is inside K5 but strips the button of any sense of where it goes. Use `"How you're doing"` for the button and `"You"` for the marker's `aria-label`. - [ ] **Step 1: Write the failing panel-source tests** @@ -1422,17 +1631,19 @@ test("the eggless copy is present and written from inside the premise", () => { assert.ok(src.includes("good days"), "K4: the soft count is named"); }); -test("the AR view hides the egg rather than drawing a phantom seed-0 one", () => { - const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); - const ar = src.slice(src.indexOf("function startAr()"), src.indexOf("function closeAr()")); - assert.ok(/setHidden\(\s*\$\("rb-ar-egg"\)/.test(ar), - "seedFromEggId(null) is 0, so an unguarded draw shows an egg that does not exist"); +test("the AR renderer hides the egg when there is neither bird nor egg", () => { + // In the RENDERER, not startAr: ramble-ar.js repaints every frame and would + // otherwise un-hide the egg whenever there is no valid bird. + const src = readFileSync("bundles/ramble/panel/static/ramble-ar.js", "utf8"); + assert.ok(/setHidden\(e\.egg,\s*valid\s*\|\|\s*!.*hasEgg/.test(src), + "seedFromEggId(null) is 0, so an unguarded frame shows an egg that does not exist"); }); -test("the panel client still obeys its rules", () => { +test("the map marker does not draw a phantom egg for a player who has neither", () => { const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); - assert.equal((src.match(/`/g) || []).length, 0, "ZERO backticks: one truncates the served script"); - assert.equal((src.match(/innerHTML/g) || []).length, 2, "EXACTLY two markup sinks"); + const fn = src.slice(src.indexOf("function hereArt()"), src.indexOf("function paintHereArt()")); + assert.ok(/else if \(eggSeedId\)/.test(fn), + "hereArt must fall through to the plain dot when there is no bird and no egg"); }); ``` @@ -1527,23 +1738,52 @@ In `bundles/ramble/panel/static/ramble.js`, `paintEgg`: } else if (lastMarks.length === 0) { ``` -`startAr`, guarding the draw: +**The sixth surface — your own map marker.** `hereArt()` at `static/ramble.js:236-241` draws the walking egg whenever `perchTarget !== "pet"`, and `seedFromEggId(null)` returns `0`, so a player with no egg *and* no bird gets a phantom seed-0 egg walking around as their location marker. Guard it: + +```js + } else if (eggSeedId) { + /* The WALKING egg — legs and all. You are not carrying it, you are it. */ + svg.setAttribute("class", "rb-here-egg"); + svg.setAttribute("viewBox", "0 0 120 168"); + drawWalkingEggSeed(svg, seedFromEggId(eggSeedId)); + } else { + /* No bird and no egg — the window between a first load and the + * prologue's Go button. seedFromEggId(null) is 0, so drawing here + * would show a phantom egg that does not exist. Fall through to the + * plain dot, which hereIcon already handles. */ + return null; + } +``` + +**The AR view (`rb-ar-egg`) must be fixed in the RENDERER, not in `startAr`.** `ramble-ar.js:378` runs `if (e.egg) setHidden(e.egg, valid);` on every frame, where `valid` means "a valid bird exists" — so it *un-hides* the egg whenever there is no bird, overriding anything `startAr` set within one animation frame. Add `bundles/ramble/panel/static/ramble-ar.js` to this task's files and thread a `hasEgg` flag onto the render frame: ```js - /* seedFromEggId(null) is 0, so an unguarded call draws a phantom egg that - * does not exist. Hide the element instead. */ - var arEgg = $("rb-ar-egg"); - setHidden(arEgg, !eggSeedId); - if (eggSeedId) drawEggArt(arEgg, eggSeedId); + // ramble-ar.js, paintBird: + if (e.bird) setHidden(e.bird, !valid); + // Phase 3: with no bird AND no egg there is nothing to draw. Without the + // hasEgg term this un-hides a phantom seed-0 egg every frame. + if (e.egg) setHidden(e.egg, valid || !frame.hasEgg); ``` +Set `hasEgg` where the frame is built from the pet state, and update `tests/ramble-ar.test.js` to cover the no-bird-no-egg case. **Do not assert the presence of a line in `startAr`** — an earlier draft of this plan did exactly that, and the assertion passed against a fix that the renderer immediately undid. + The framing fix (K5), in `paintHereArt` and `paintPerchGo`: ```js el.setAttribute("aria-label", "You"); /* was "You, and your bird" / "You, and your egg" */ ``` ```js - go.textContent = "You"; /* was "Your bird" / "Your egg" */ + go.textContent = "How you're doing"; /* was "Your bird" / "Your egg" */ +``` + +And the check-in confirmation at `static/ramble.js:1244` (S2) — the exact surrounding code must be read first; the branch is on whether the response carried an egg: + +```js + /* With no egg the credit was real but the warmth had nowhere to land + * (D3). Saying "that is today's warmth" would be false on the one + * screen K4 requires to be legible. */ + setText(el, hasEgg ? "Checked in. That is today's warmth." + : "Checked in. Nothing to warm yet — but it counted."); ``` - [ ] **Step 5: Style the eggless card** @@ -1721,7 +1961,17 @@ In `bundles/ramble/panel/static/ramble.js` — `textContent` only, no backticks: }); ``` -Call `maybeIntro()` once from the panel's existing start-up sequence, beside the other first-load fetches. Call `maybeHatchBeat(hatched)` from wherever the hatch reveal is dismissed, passing the hatched bird — grep for `hatchLock` to find that path and hook the existing dismissal rather than adding a second one. +Call `maybeIntro()` once from the panel's existing start-up sequence, beside the other first-load fetches. + +**The hatch beat's hook point, precisely** — an earlier draft said "grep for `hatchLock`", which points at the wrong function. `clearHatch()` takes no argument, has no access to the hatched bird (`shownHatch` holds a string key, not `{species, seed}`), and fires on *any* view change, so hooking it would show the beat at random moments with no species name. Hook the **`rb-meet-bird` button handler** instead, and stash the bird in a module var when the hatch is handled: + +```js + var lastHatched = null; /* set by handleHatched, read by the beat */ +``` + +In `handleHatched(h)`, add `lastHatched = h;`. Then in the `rb-meet-bird` click handler, after its existing work, call `maybeHatchBeat(lastHatched)`. + +**If the player closes the tab after seeing beat one but before tapping Go**, `intro_seen` and `granted` are both still false, so `maybeIntro()` shows it again on the next load — intended. The egg arrives only when they engage, and there is exactly one button, so there is no dismissal path that skips the grant. - [ ] **Step 5: Style the overlay** @@ -1791,6 +2041,20 @@ vanishes**; that is deliberate, and auto-promote is what keeps it rare. | `lay.days` | 14 | Happy days while eggless before you lay one yourself | ``` +- [ ] **Step 1b: Fix the SECOND false sentence, which is easy to miss** + +`docs/guide/ramble.md:88` ends with **"A new egg starts incubating immediately."** — falsified by this phase just as line 68 is. Replace that clause with: + +```markdown +the bird's look is unique to that seed. If an egg is waiting on your shelf it moves into the slot; otherwise nothing new starts, and the next one has to be found, given, or laid. +``` + +The Spanish mirror at `docs/es/guide/ramble.md:88` ends with **"Un huevo nuevo empieza a incubar de inmediato."** and needs the same correction: + +```markdown +el aspecto del pájaro es único para esa semilla. Si tienes un huevo esperando en la repisa, pasa al hueco; si no, no empieza nada nuevo, y el siguiente hay que encontrarlo, recibirlo o ponerlo. +``` + - [ ] **Step 2: Mirror it in Spanish, by hand** **⚠ There is NO CI gate for guide-doc i18n parity** — `tests/i18n-global-parity.test.js` covers the translation-key mechanism only, and nothing diffs `docs/guide/` against `docs/es/guide/`. Phase 2's plan wrongly claimed otherwise and parity had to be done by hand. Update `docs/es/guide/ramble.md` to match, then verify by eye: @@ -1835,7 +2099,11 @@ cd /home/kh0pp/crow-wt-ramble-eggs npm test 2>&1 | tail -25 ``` -Expected: **4340 + the new tests**, 0 fail. A drop below 4340 means something was deleted, not fixed. **If the run reports ~552 failures, `node_modules` is missing** — re-create the symlink; it is not a regression. +Expected: **0 fail**, and a total of roughly 4340 plus the new tests. + +**⚠ Do not use "≥ 4340" as a mechanical gate.** Task 3 legitimately rewrites seven existing assertions (see its table), which changes what is counted; a naive floor would either fire spuriously or hide a real deletion. The honest check is: **every test file this branch touched must be named in the PR body with what changed and why**, and the only assertions that changed meaning are the ones in Task 3's table. Any *other* count movement is unexplained and must be chased. + +**If the run reports ~552 failures, `node_modules` is missing** — re-create the symlink; it is not a regression. - [ ] **Confirm no schema change slipped in** @@ -1863,12 +2131,10 @@ Expected: the definition, plus **exactly two** call sites — `grantStarterEgg` - [ ] **Confirm the panel client rules held** ```bash -grep -c '`' bundles/ramble/panel/static/ramble.js # must print 0 -grep -c 'innerHTML' bundles/ramble/panel/static/ramble.js -git show origin/main:bundles/ramble/panel/static/ramble.js | grep -c innerHTML +node scripts/run-suite.mjs tests/ramble-panel.test.js ``` -Zero backticks; the two `innerHTML` counts must match. +The backtick and markup-sink invariants are **already enforced** by that file (the sink check at `tests/ramble-panel.test.js:776-779` strips comments and matches `/\.innerHTML\s*=|\bhtml:\s/g`, asserting 2). Do **not** add a raw `grep -c innerHTML` gate: the file contains the literal string `innerHTML` exactly twice today — once in the header comment at line 7 and once in the real sink at line 859 — while the *second* real sink is `html: nestEggHtml(` and contains no `innerHTML` at all. A raw grep reads 2 for the wrong reasons and would not notice a third real sink. - [ ] **Whole-branch adversarial review — NOT OPTIONAL** @@ -1924,7 +2190,19 @@ Every run must be `completed` / `success`. Contexts: `suite`, `static-checks`, ` - [ ] **Hand back to Kevin** -Say plainly: what shipped; that his current egg is grandfathered and will be the last one that arrives on its own; that after it hatches the slot stays empty unless the shelf has one; what the eggless card will say; that the prologue will not fire for him until the game-state reset, and what that reset needs to clear (`ramble_eggs` and the two `prologue.%` settings rows); the phone smoke test still outstanding from phase 2 (walk to one of the 14 heart pips); and anything the whole-branch review found. +Say plainly: what shipped; that his current egg is grandfathered and will be the last one that arrives on its own; that after it hatches the slot stays empty unless the shelf has one; what the eggless card will say; the phone smoke test still outstanding from phase 2 (walk to one of the 14 heart pips); and anything the whole-branch review found. + +**The game-state reset needs more than two tables — spell it out.** The prologue will not fire for Kevin until he resets, and clearing only `ramble_eggs` plus the `prologue.%` settings would start the "new game" with a full energy bar, a dangling `active_egg_id`, and a part-finished lay count. The full set is: + +```sql +DELETE FROM ramble_eggs; -- every egg and bird +DELETE FROM ramble_pet; -- energy, mood, chores, active_egg_id, weekly counters +DELETE FROM ramble_credits; -- the warmth no-double-count ledger +DELETE FROM ramble_wallet WHERE kind IN ('layday','lay'); -- the lay count (KEEP seed and heart rows unless he wants those reset too) +DELETE FROM ramble_settings WHERE key LIKE 'prologue.%'; -- both dismissal flags +``` + +**⚠ And it must be done fleet-wide, or it undoes itself.** `ramble_eggs`, `ramble_pet`, `ramble_settings` and `ramble_wallet` are all in `SYNCED_TABLES`, so wiping one instance while another holds the rows means the next drain re-populates it. Ramble is currently installed on **grackle only**, so today this is a single-machine operation — but say so, because that stops being true the moment a second instance installs the bundle. Ask before running it; this is destructive and it is his save file. --- @@ -1936,3 +2214,30 @@ Say plainly: what shipped; that his current egg is grandfathered and will be the - D2's sad portrait to contacts, and any change to `servers/sharing/profile-avatar.js`. - Sweeping the pet page's leftover keeper framing ("Still an egg", "My bird", the chore card). This phase fixes the framing only on surfaces it already rewrites; the rest is a copy pass of its own. - Fixing phase 1 and 2's known follow-ups: `unlockedCellsNear`'s full-table read, the seed cooldown's global UTC bucket, the AR view carrying no hearts or seed, `.rb-mapbar` wrapping on a phone. None is made worse by this phase. + +--- + +## Review + +**Reviewer:** adversarial staff-engineer pass (Plan subagent), 2026-09-09, against the real code rather than the plan's description of it. + +**Verdict: REVISE** — nine critical issues. The architecture and all three Findings were independently confirmed correct; the failures were all *mechanical accuracy*. Every issue is fixed in this document. + +| # | Issue | Resolution | +|---|---|---| +| C1 | `GET /api/ramble/pet` would 500 for every eggless player: `routes.js:816` is `egg: { percent: egg.egg.percent }`. The plan patched `feed.js`'s `readPet` instead, which is private to `feedAll` and **is not on that code path at all** — so the real line would have stayed unpatched. This is the pet view, the only view an eggless player can reach from the perch. | Task 6 Step 4 now edits `routes.js:812-820` directly. | +| C2 | Editing `readPet` would break a green test the plan never listed: `tests/ramble-feed.test.js:68` asserts `Object.keys(repeat.pet)` equals `Object.keys(first.pet)`, and only `readPet` feeds one side. | `readPet` is now explicitly not touched, with the reason recorded so it is not "helpfully" changed later. | +| C3 | `recordHappyDay(db, { now, mood, emit })` inside `petState` is a **`ReferenceError`** — `petState(db, { now })` has no `emit` in scope — and `pet.js:18` documents "a GET must never queue a sync op". Neither caller passes an emit, so even adding the parameter would produce lay-days that never replicate. | The `petState` call site is removed entirely. Days are earned on the write path (`feed`, and `doChore` through it), which is also the truer reading of §4.3's "sustained care". A geolocation-denying player still banks days via chores and the check-in. | +| C4a | **`layProgress` did not converge.** `applyRambleWallet` does `created_at = MIN(local, incoming)` (`instance-sync.js:620`), so a sync apply can move a row's timestamp backwards across the reset boundary; clock skew between the user's own machines does it too. | Ordering moved to `key` (the `YYYY-MM-DD` local day) — half the primary key, lexically date-ordered, and never rewritten by the apply. Three multi-instance tests added, including one that applies a `created_at: 0` row and asserts the count does not move. | +| C4b | The mint ran unconditionally after `INSERT OR IGNORE` on the `lay` row, so the dedup key it had just written was decorative. | Gated on `rowsAffected === 1`. The remaining cross-instance case (two eggless instances crossing the threshold before a sync) is recorded as **Deviation 3** and accepted: it yields one spare shelf egg via the existing convergence rule, and the alternative — a deterministic egg id — reintroduces the cross-user collision that rules out a constant id for the starter egg. | +| C5 | Task 3 left the suite red and listed **none** of the four test files it breaks. Seven specific assertions, verified by grep, not hypothesised. | Task 3 now lists `ramble-eggs`, `ramble-flock`, `ramble-tools` and `ramble-panel`, carries a line-by-line table of what each assertion becomes, runs all of them in Step 7, and commits them. | +| C6 | **The plan's flagship regression test was vacuous.** `ramble_pet.energy` DEFAULTS TO 60 (`init-tables.js:105`), so `energy > 0` holds whether or not anything was fed, and the two `>=` comparisons hold when the feed is skipped — the test passed against the exact death spiral it existed to prevent. Phase 2's vacuous-fixture lesson, on the one test that most needed to be sharp. | Exact values: `60 → 65 → 80 → 100` with `assert.equal`, plus a negative control proving a repeat `visit_place` does not feed (without which an unconditionally-feeding implementation would also pass). | +| C7 | A **sixth** egg surface, missed: `hereArt()` (`static/ramble.js:236-241`) draws the walking egg from `seedFromEggId(null)` → seed 0, so a player with neither bird nor egg gets a phantom egg as their own map marker. And the plan's Global Constraint "you are never simultaneously birdless and eggless" is **false on the build this phase creates** — that is precisely the new-player window between first load and tapping **Go**. | `hereArt` falls through to the plain dot; the constraint is corrected and now flags that state as the most important one in the phase. | +| C8 | The AR fix was **dead on arrival**: `ramble-ar.js:378` runs `if (e.egg) setHidden(e.egg, valid)` every frame and un-hides the egg whenever there is no valid bird, overriding `startAr` within one frame. `ramble-ar.js` was not even in the file list. Worse, the accompanying test asserted the *presence of the ineffective line* — passing against a wrong implementation. | The guard moves into `paintBird` with a `hasEgg` term on the render frame; `ramble-ar.js` and `tests/ramble-ar.test.js` join Task 7; the test now asserts the renderer's behaviour. | +| C9 | Promote-on-read could **silently undo a deliberate `incubate` made on another instance**. `applyRambleEgg` carries an explicit carve-out (`instance-sync.js:855-860`) refusing to re-promote on a peer's user-shelve because the replacement row "follows in the same drain"; a GET landing in that window promoted the parked egg, set `shelf_origin = NULL` (the *top* convergence class), **and emitted** — beating the user's real choice on both machines. The two call sites also disagreed about `emit`. | `promoteFromShelf` gains an explicit `speculative` mode: read paths mark `shelf_origin = 'sync'` (which ranks below any NULL-origin egg) and **never emit**; only the hatch path clears the origin and emits. Both modes are tested. Deviation 4 rewritten to argue the real hazard rather than the irrelevant one. | + +**Suggestions adopted:** the raw `grep -c innerHTML` gate dropped in favour of the existing comment-stripping check at `tests/ramble-panel.test.js:776-779` (the literal string appears twice for the wrong reasons — a header comment and one real sink — while the second real sink is `html:` and contains no `innerHTML`); the check-in confirmation no longer tells an eggless player "that is today's warmth" when the warmth vanished; the perch door reads "How you're doing" rather than a bare "You", keeping the affordance; `docs/guide/ramble.md:88` and its Spanish mirror added to Task 9 ("A new egg starts incubating immediately." is falsified just as line 68 is); the false claim that `panel/routes.js` imports the lock helpers corrected before it could ship in a comment (`flock.js` is the only importer); Task 8's hatch-beat hook specified as the `rb-meet-bird` handler with a `lastHatched` module var, since `clearHatch()` takes no argument, holds only a string key, and fires on any view change; a pinned test for the expired-but-unswept trade window; the full fleet-wide game-state reset enumerated for the hand-back, since clearing only eggs and flags would start the "new game" with a full energy bar and a part-finished lay count. + +**Baseline gate softened, deliberately (reviewer Q5).** "A drop below 4340 means something was deleted" would misfire once Task 3 legitimately rewrites seven assertions. The gate is now: 0 fail, and every touched test file named in the PR with what changed — the only assertions permitted to change meaning are the ones in Task 3's table. + +**Confirmed by the reviewer, needing no change:** Finding 1 is real (`feed.js:42`, `KEYED_TYPES` at `:14`) and chore/mark paths are genuinely unaffected — `chore` is not in `ACCEPTED_TYPES` and reaches `pet.js:feed` directly, while `mark_left`/`unlock_mark` are unkeyed so `shouldFeedPet` is unconditionally true. `ensureIncubatingEgg` has exactly the four claimed call sites, and nothing in `instance-sync.js`, `trades.js`, `delivery.js`, `claimNest` or `server.js` mints. Finding 2's quoted comment is real (`panel/ramble.js:311-313`) and the Next-egg card genuinely is the only route to the check-in. Finding 3 is real (`identity.js:136` — `randomBytes(32)` per instance). Task 3's trade fixtures are schema-valid and `OPEN_SQL` really is `"state IN ('proposed','accepted')"`. The `delta = 1` discipline matches `hearts.js`. The `eggs -> trades -> eggs` cycle Task 1 breaks is real and `flock.js:22` is the only external importer. Same-instance concurrent double-lay is already impossible via `INSERT OR IGNORE`. Privacy holds — no egg, lay-day or balance reaches a contact-facing payload. No schema change is needed. `mods.eggsMod` is a namespace import, so new exports are reachable automatically. From 336af48f33028d07e98b436f2ec65bb5d889c216 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:01:44 -0500 Subject: [PATCH 03/20] plan: fix thirteen issues from the second adversarial review --- .../plans/2026-09-09-ramble-eggs-phase3.md | 296 ++++++++++++------ 1 file changed, 207 insertions(+), 89 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md index 240ff210..07561b6d 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md +++ b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md @@ -84,7 +84,7 @@ A fixed constant id is also wrong: a contact could gift you *their* starter egg 1. **The starter-egg id is a random UUID gated on an empty egg table, not derived from the Crow identity.** See Finding 3 — the spec's mechanism is impossible because `crowId` is per-instance. Say this in the PR body so a reviewer reads it as a correction, not drift. 2. **`creditWarmth` still writes its ledger row when there is no egg.** See Finding 1. The spec does not describe the interaction with `feedAll`'s `shouldFeedPet` gate at all; without this, laying is unreachable. 3. **A two-instance fleet can lay two eggs from one lay, and that is accepted.** Both instances eggless, both observing the same happy days, both crossing the threshold before a sync: each writes the same `lay:` key locally (so `rowsAffected` is 1 on both) and each mints an egg with its own UUID. `applyRambleEgg` then shelves one, so the user ends with one incubating and one spare on the shelf. Making this impossible would need a deterministic egg id derived from the lay day — which reintroduces exactly the cross-user collision that rules a constant id out for the starter egg (a contact who laid on the same date could gift you a colliding row). **Ramble is installed on grackle only**, so this is currently unreachable; the graceful degradation is the existing convergence rule, and the cost of the alternative is a real collision for a hypothetical one. Revisit if a second instance ever installs the bundle. -4. **Auto-promote runs lazily on read paths as well as on hatch, in a weaker "speculative" mode.** Spec §4.2 says "when the incubating slot empties" without saying who notices. A slot can empty from a sync arrival that no local code path observes, so the read paths carry a *guarded* promote. It is still a write during a GET, and the first draft of this plan defended it only as "it moves an egg the user already owns" — which is true and beside the point. The real hazard, found in review: `applyRambleEgg` carries an explicit carve-out (`instance-sync.js:855-860`) refusing to re-promote on a peer's user-shelve, because the replacement egg's row "follows in the same drain" and promoting there would draft an old egg that then out-ranks the user's real choice on both instances. A GET landing in that window would do exactly that. So the read-path promote marks `shelf_origin = 'sync'` (which ranks *below* any NULL-origin egg, so a real choice out-ranks it on arrival) and **never emits** — the same discipline the sync layer's own re-promote uses. Task 3 tests both modes. +4. **Auto-promote runs ONLY on hatch — the read paths deliberately do not promote.** Spec §4.2 says "when the incubating slot empties" without saying who notices, and two drafts of this plan had the read paths notice. Both were wrong: a write during a GET races `applyRambleEgg`'s `isUserShelve` carve-out (`instance-sync.js:855-860`), and the attempted mitigation (marking such a promote `'sync'`) launders provenance that `flock.js:126`'s nest shelf cap, `flock.js:253`'s `shelf_count`, `RAMBLE_EGG_REPROMOTE_SQL` and `static/ramble.js:1776` all read. Deleting the read-path promote costs one case — a gift arriving while wholly eggless waits on the shelf for a manual "incubate" — and that is arguably the better interaction. --- @@ -222,7 +222,21 @@ git commit bundles/ramble/server/egg-locks.js bundles/ramble/server/trades.js bu **Files:** - Modify: `bundles/ramble/server/eggs.js` - Create: `tests/ramble-eggs-supply.test.js` -- Modify: `tests/ramble-eggs.test.js`, `tests/ramble-flock.test.js`, `tests/ramble-trades.test.js`, `tests/ramble-sync.test.js` (import rename only) +- Modify (import rename only): `tests/ramble-flock.test.js`, `tests/ramble-trades.test.js`, `tests/ramble-sync.test.js` +- **Modify (these go RED at THIS task, because `eggState` stops minting):** `tests/ramble-eggs.test.js`, `tests/ramble-panel.test.js`, `tests/ramble-tools.test.js` + +**⚠ The nullable egg breaks assertions here, in Task 2 — not in Task 3.** An earlier draft attributed all of them to Task 3 and listed only some. `POST /api/ramble/area` and `GET /api/ramble/egg` no longer mint, so every one of these dereferences a `null`: + +| File:line | Assertion | +|---|---| +| `tests/ramble-panel.test.js:470, 482, 500` | `eggBefore.egg.warmth` | +| `tests/ramble-panel.test.js:519-522` | `typeof body.egg.egg_id`, `body.egg.hatch_at === 100` | +| `tests/ramble-panel.test.js:544` | `const { egg } = …; egg.hatch_at === 100000` | +| `tests/ramble-panel.test.js:598` | `typeof body.egg.percent` | +| `tests/ramble-panel.test.js:609` | `after.egg.warmth === before.egg.warmth + 15` | +| `tests/ramble-tools.test.js:132-133, 160, 167, 225` | `payload.egg.percent`, `after.egg.warmth - before.egg.warmth`, `state.egg.percent`, `eggAfter.egg.warmth` | + +Give each fixture an explicit egg with `mintIncubatingEgg` — these tests are about warmth accrual, not about egg supply, so an explicit fixture is the right repair rather than weakening the assertion. (`tests/ramble-panel.test.js:911` asserts the source literal `"eggPercent = pet.egg.percent"`, which Task 7's `paintPet` rewrite deletes — that one belongs to Task 7, and is listed there.) **Interfaces:** - Produces: `mintIncubatingEgg(db, { now, emit }) -> Promise` (renamed from `ensureIncubatingEgg`, body unchanged), `getIncubatingEgg(db) -> Promise` (now exported). @@ -463,9 +477,12 @@ Expected from the grep: **no hits**. `servers/sharing/instance-sync.js:726` ment ```bash node scripts/run-suite.mjs tests/ramble-eggs-supply.test.js node scripts/run-suite.mjs tests/ramble-eggs.test.js +node scripts/run-suite.mjs tests/ramble-panel.test.js +node scripts/run-suite.mjs tests/ramble-tools.test.js +node scripts/run-suite.mjs tests/ramble-feed.test.js ``` -Expected: both PASS. `tests/ramble-eggs.test.js` has a test asserting two `mintIncubatingEgg` calls return the same egg — that still holds. +Expected: all PASS, after the fixtures in the table above have been given explicit eggs. `tests/ramble-eggs.test.js` has a test asserting two `mintIncubatingEgg` calls return the same egg — that still holds. **Do not move on with any of these red**; the suite must be green at the end of every task, not only at the end of the phase. - [ ] **Step 8: Commit** @@ -473,6 +490,7 @@ Expected: both PASS. `tests/ramble-eggs.test.js` has a test asserting two `mintI git add tests/ramble-eggs-supply.test.js git commit bundles/ramble/server/eggs.js tests/ramble-eggs-supply.test.js tests/ramble-eggs.test.js \ tests/ramble-flock.test.js tests/ramble-trades.test.js tests/ramble-sync.test.js \ + tests/ramble-panel.test.js tests/ramble-tools.test.js \ -m "ramble: looking at a screen no longer mints an egg" ``` @@ -490,17 +508,19 @@ git commit bundles/ramble/server/eggs.js tests/ramble-eggs-supply.test.js tests/ | File:line | What it asserts today | What it becomes | |---|---|---| | `tests/ramble-eggs.test.js:49` | `[["hatched",1],["incubating",1]]`, titled *"…and starts the next egg"* | `[["hatched",1]]` — no successor. Retitle. | -| `tests/ramble-eggs.test.js:58` | `s.egg.egg_id` after a hatch | `assert.equal(s.egg, null)` | +| `tests/ramble-eggs.test.js:58` | `s.egg.egg_id` after a hatch | `assert.equal(s.egg, null)`. **Then add a positive case** — shelve an egg, let the hatch promote it, credit warmth and assert a non-zero `percent` — or nothing anywhere covers a non-zero percent any more. | | `tests/ramble-eggs.test.js:71` | `before.egg.warmth` after a hatch | null-guard, or shelve an egg first so one exists | | `tests/ramble-eggs.test.js:82` | `before.egg.warmth` after a hatch | same | -| `tests/ramble-flock.test.js:150` | *"a successor egg was minted"* | the slot is empty after a hatch with an empty shelf | +| `tests/ramble-flock.test.js:150` | *"a successor egg was minted"* | **NOT "the slot is empty"** — the shelf is not empty there. The test parks egg `E` as `'user'` via `incubateEgg`, then `hot` hatches and `promoteFromShelf` draws `E` straight back in, so the count stays 1. Assert `getIncubatingEgg(d).egg_id === E` and retitle to *"the parked egg is promoted back into the slot"*. | | `tests/ramble-flock.test.js:172` | *"the incubating egg is ensured and listed first"* | mint one explicitly as a fixture, then assert ordering | | `tests/ramble-tools.test.js:177` | `s.eggs[0].status === "incubating"` | mint a fixture, or assert the eggless shape | | `tests/ramble-panel.test.js:1227` | `flock.eggs.find(e => e.status === "incubating")` — the file header (line 53) says it "churns hatches and later asserts an incubating egg exists" | give the fixture an explicit egg | +`tests/ramble-flock.test.js:174` and `tests/ramble-panel.test.js:1226/1234` were red in a draft where `flockState` promoted on read. It no longer does (see `promoteFromShelf`'s note), so they are unaffected — **verify that rather than assuming it**, since it is the kind of claim this plan has already got wrong twice. + **Interfaces:** -- Produces: `promoteFromShelf(db, { now, emit, speculative }) -> Promise` — promotes the oldest non-locked `shelf`/`received` egg into the incubating slot, or returns null. -- Consumed by: `hatchIfReady` (eggs.js, `speculative: false`), `flockState` (eggs.js, `speculative: true`), and `GET /api/ramble/egg` in `panel/routes.js` (Task 6, `speculative: true`). **`petState` does NOT promote** — an earlier draft's interface block claimed it did while no task added it. +- Produces: `promoteFromShelf(db, { now, emit }) -> Promise` — promotes the oldest non-locked `shelf`/`received` egg into the incubating slot, or returns null. +- Consumed by: **`hatchIfReady` only.** No read path calls it — not `eggState`, not `flockState`, not `petState`, not any route. **The ordering rule, which is the whole design:** both instances must pick the **same** egg with no round trip. The order is `created_at ASC, egg_id ASC` — a total order and a pure function of replicated rows, exactly the order `RAMBLE_EGG_REPROMOTE_SQL` already uses for the sync layer's own re-promote. @@ -538,26 +558,25 @@ test("promoteFromShelf takes the OLDEST shelf egg and clears shelf_origin", asyn assert.equal((await statusOf(db, "younger")).status, "shelf", "only one is drafted"); }); -test("a SPECULATIVE promote marks 'sync' and never emits, so it cannot out-rank a real choice", async () => { - const db = await freshDb(); - await shelveEgg(db, "parked", T0); - const emitted = []; - const emit = (table, op, row) => { emitted.push([table, op, row.egg_id]); }; - - const promoted = await promoteFromShelf(db, { now: T0 + 1000, emit, speculative: true }); - assert.equal(promoted.egg_id, "parked"); - assert.equal((await statusOf(db, "parked")).shelf_origin, "sync", - "'sync' ranks BELOW a NULL-origin egg in applyRambleEgg, so the user's real choice wins"); - assert.deepEqual(emitted, [], - "a guess must not replicate — instance-sync.js:855 refuses to draft during an incubate swap"); -}); - -test("a DELIBERATE promote does emit, because the user really moved to a new egg", async () => { +test("promoteFromShelf emits, because the user really did move to a new egg", async () => { const db = await freshDb(); await shelveEgg(db, "next", T0); const emitted = []; await promoteFromShelf(db, { now: T0 + 1000, emit: (t, o, r) => emitted.push([t, o, r.egg_id]) }); assert.deepEqual(emitted, [["ramble_eggs", "update", "next"]]); + assert.equal((await statusOf(db, "next")).shelf_origin, null, + "NULL, never 'sync': relabelling would widen the nest shelf cap (flock.js:126) and make the " + + "sync layer's own re-promote draftable on an egg the user parked"); +}); + +test("a READ never promotes — flockState and the egg route are pure", async () => { + const db = await freshDb(); + await shelveEgg(db, "parked", T0); + await flockState(db, { now: T0 + 1000 }); + assert.equal(await getIncubatingEgg(db), null, + "a GET must not queue a sync op, and a read-path promote races applyRambleEgg's " + + "isUserShelve carve-out (instance-sync.js:855)"); + assert.equal((await statusOf(db, "parked")).status, "shelf"); }); test("promoteFromShelf breaks a created_at tie by the lower egg_id, so two instances agree", async () => { @@ -698,32 +717,39 @@ Then: * An egg named by an open swap is skipped: it is promised to a contact, and * incubating it would let the user spend it twice. * - * Writes NOTHING when the slot is occupied or nothing is promotable, which is - * what makes it safe to call from a read path. + * Writes NOTHING when the slot is occupied or nothing is promotable. * - * ⚠ TWO MODES, and the difference is a real hazard, not a style choice. + * ⚠ CALLED FROM EXACTLY ONE PLACE: `hatchIfReady`. Never from a read path. * - * `speculative: false` (the hatch path) is a deliberate local event: the egg - * takes `shelf_origin = NULL` — the TOP class in applyRambleEgg's convergence - * rule — and the row is emitted, because the user really did just move to a - * new egg. + * Two earlier drafts of this plan called it from `eggState`/`flockState` too, + * so that a slot emptied by a sync arrival would refill without waiting for a + * hatch. Both were wrong, and the second was wrong in a subtler way than the + * first: * - * `speculative: true` (the read paths) is a guess. The slot may be empty only - * because a peer's drain is mid-flight: applyRambleEgg carries an explicit - * carve-out (instance-sync.js:855-860) saying a peer's USER shelve is half of - * an "incubate" swap and the replacement row "follows in the same drain", so - * re-promoting there "would draft an old convergence loser into the slot, - * which then out-ranks the user's real choice by created_at on both sides". - * A GET landing in that window would do exactly what that carve-out refuses - * to do. So a speculative promote: - * - marks the egg `shelf_origin = 'sync'`, which ranks BELOW any NULL-origin - * egg, so the user's real choice out-ranks it the moment it arrives; and - * - NEVER emits, so it stays a local re-derivation each instance performs - * for itself — the same discipline the sync layer's own re-promote uses. - * This is deliberately the same semantics as RAMBLE_EGG_REPROMOTE_SQL, - * extended to 'user' eggs so §4.2's release valve still works. + * 1. It is a write during a GET, and `applyRambleEgg` carries an explicit + * carve-out (instance-sync.js:855-860) refusing to re-promote on a peer's + * USER shelve, because the replacement egg's row "follows in the same + * drain" — a GET landing in that window drafts the egg the user just + * parked, and it then out-ranks their real choice on both machines. + * 2. The attempted fix — marking such a promote `shelf_origin = 'sync'` so + * it ranks below a real choice — LAUNDERS PROVENANCE. `flock.js:126` + * counts `status='shelf' AND shelf_origin='user'` for the nest shelf cap + * and `flock.js:253` for `shelf_count`; `instance-sync.js:831` rewrites + * a demoted egg to `'sync'` unconditionally; and + * `RAMBLE_EGG_REPROMOTE_SQL` drafts `'sync'` eggs only. A user egg + * relabelled 'sync' therefore stops consuming a shelf slot, is + * under-reported to the user, becomes draftable by the very sync rule + * the 'user' mark exists to protect it from, and is mislabelled "came + * back from another of your Crows" at `static/ramble.js:1776`. + * + * Deleting the read-path call resolves both, and costs almost nothing: the + * slot only ever empties locally on a hatch (this function), and a slot + * emptied by convergence is already refilled by `RAMBLE_EGG_REPROMOTE_SQL` + * in the same apply batch. The one uncovered case is a gift arriving while + * the user is wholly eggless — it lands on the shelf and they tap "incubate", + * which is arguably the better interaction anyway: you choose to warm a gift. */ -export async function promoteFromShelf(db, { now, emit, speculative = false } = {}) { +export async function promoteFromShelf(db, { now, emit } = {}) { void now; if (await getIncubatingEgg(db)) return null; @@ -741,16 +767,15 @@ export async function promoteFromShelf(db, { now, emit, speculative = false } = // a query against the table's contents, not a schema constraint, so two // overlapping promotes must not both succeed. const { rowsAffected } = await db.execute({ - sql: `UPDATE ramble_eggs SET status = 'incubating', shelf_origin = ? + sql: `UPDATE ramble_eggs SET status = 'incubating', shelf_origin = NULL WHERE egg_id = ? AND status IN ('shelf', 'received') AND NOT EXISTS (SELECT 1 FROM ramble_eggs WHERE status = 'incubating')`, - args: [speculative ? "sync" : null, next.egg_id], + args: [next.egg_id], }); if (rowsAffected === 0) return null; const promoted = await getIncubatingEgg(db); - // A speculative promote never emits — see the two-modes note above. - if (promoted && !speculative) await safeEmit(emit, "ramble_eggs", "update", promoted); + if (promoted) await safeEmit(emit, "ramble_eggs", "update", promoted); return promoted; } ``` @@ -774,17 +799,13 @@ Delete the now-unused `const nextEgg = ...; void nextEgg;` lines. Update `hatchI In `bundles/ramble/server/flock.js`, delete `await ensureIncubatingEgg(db, { now });` from `flockState` and replace it with: ```js - // Phase 3: a flock screen is a READ. It used to mint the incubating egg, - // so opening this view recreated one. It may still promote, because a slot - // can empty from a sync arrival that no local code path observed — that - // moves an egg the user already owns rather than conjuring one, and - // promoteFromShelf writes nothing when there is nothing to promote. - // SPECULATIVE: marks 'sync' and does not emit, so a GET landing mid-drain - // cannot out-rank a deliberate incubate made on another instance. - await promoteFromShelf(db, { now, speculative: true }); + // Phase 3: a flock screen is a READ, and now genuinely is one. It used to + // mint the incubating egg, so opening this view recreated one. It does NOT + // promote either: see promoteFromShelf's note on why a write during a GET + // both races the sync drain and launders shelf_origin provenance. ``` -Update the import: drop `ensureIncubatingEgg`, add `promoteFromShelf`. +Update the import: drop `ensureIncubatingEgg`. **Do not add `promoteFromShelf`** — `flock.js` no longer needs it. - [ ] **Step 6: Correct the stale comment in `init-tables.js` — COMMENT ONLY** @@ -1133,9 +1154,14 @@ export async function layProgress(db) { * decay-on-read write never emits, because a GET must never queue a sync op", * and `petState` has no `emit` in scope to pass. A day is therefore earned by * DOING something — a walk, a chore, a check-in — not by opening the app, - * which is also the truer reading of §4.3's "sustained care". A player who - * denies geolocation can still bank days: chores and the daily check-in both - * run through `feed`. + * which is also the truer reading of §4.3's "sustained care". + * + * ⚠ Laying REQUIRES real movement, and that is a design consequence, not an + * oversight. Decay is 10 per 6 h (-40/day); the most a player who never posts + * a location fix can earn is checkin 5 + 3 chores x 8 = 29/day. From the + * default 60 they bank three happy days and then fall below the 60 threshold + * for good. Do NOT write, in a comment or a doc, that chores and the check-in + * alone can reach `lay.days`. They cannot. */ export async function recordHappyDay(db, { now = Date.now(), mood, emit } = {}) { if (mood !== "happy") return { recorded: false, laid: false }; @@ -1183,7 +1209,9 @@ export async function recordHappyDay(db, { now = Date.now(), mood, emit } = {}) 1. `petState(db, { now = Date.now() } = {})` has **no `emit` in scope** (`pet.js:223`). The call would be a `ReferenceError` on every `GET /api/ramble/pet` and every `ramble_pet_state` MCP call — for every player, egg or not. 2. `pet.js:13-21` documents the invariant it would break: *"`petState`'s decay-on-read write never emits, because a GET must never queue a sync op."* Neither caller passes an `emit` (`routes.js` calls `petState(db, { now })`; `server.js:305` calls `petState(db)`), so even adding the parameter would silently produce lay-days that never replicate. -3. It is the better game rule anyway: a day is earned by **doing** something, not by opening the app. That is the truer reading of §4.3's "sustained care", and a player who denies geolocation is not shut out — chores and the daily check-in both run through `feed`. +3. It is the better game rule anyway: a day is earned by **doing** something, not by opening the app — the truer reading of §4.3's "sustained care". + +**⚠ Do not repeat the claim that a geolocation-denying player can still reach the floor.** An earlier draft said so and it is arithmetically false: decay is 10 per 6 h (−40/day) against a maximum of `checkin 5 + 3 × chore 8 = 29/day`, so from the default 60 such a player banks **three** happy days and then sits below the 60 threshold permanently. Removing the `petState` call did not cause this — `petState` computes mood after decay and before the day's feeds, so including it would have been strictly worse. **Laying requires real movement.** Flagged for Kevin in the hand-back as a design consequence of §4.3 meeting the existing decay curve. - [ ] **Step 5: Run the tests** @@ -1373,7 +1401,10 @@ export async function readPrologue(db) { } export async function setPrologueSeen(db, which, { emit } = {}) { - const key = PROLOGUE_KEYS[which]; + // Object.hasOwn, not a truthiness check: PROLOGUE_KEYS is a plain literal, + // so `setPrologueSeen(db, "constructor")` would otherwise return a function + // and bind it into the SQL args instead of throwing. + const key = Object.hasOwn(PROLOGUE_KEYS, which) ? PROLOGUE_KEYS[which] : null; if (!key) throw new Error(`unknown prologue beat: ${which}`); await writeSetting(db, key, "1", { emit }); } @@ -1435,7 +1466,11 @@ git commit bundles/ramble/server/eggs.js tests/ramble-prologue.test.js \ - [ ] **Step 1: Write the failing route tests** -Add to `tests/ramble-panel.test.js` (follow the file's existing harness — a scratch `CROW_DATA_DIR`, never the live db): +**⚠ These four tests CANNOT go in `tests/ramble-panel.test.js`'s shared harness.** That file creates ONE scratch `CROW_DATA_DIR` and one db at `:28-36` for the whole file and runs in declaration order, so by the time these ran: `grantStarterEgg` would return `null` (an egg already exists from the nest claim at `:1186`), `egg: null` would be false, and `lay.days` would already be ≥ 1 because `POST /api/ramble/area` at `:458` feeds a fresh pet 60 → 75 → happy while eggless and therefore writes a `layday` row. + +Put them in a **new file `tests/ramble-prologue-routes.test.js`** with its own `createClient` + `initRambleTables` scratch db per test, the way `tests/ramble-eggs.test.js:88` does with `file::memory:`. Add that file to this task's Files list. The route helpers (`get`/`post`) should be built the same way `ramble-panel.test.js` builds its own, but against the fresh db. + +Sketch of the assertions (adapt to the harness you build): ```js test("GET /api/ramble/egg reports egg: null on a fresh install and mints nothing", async () => { @@ -1494,12 +1529,9 @@ In `bundles/ramble/panel/routes.js`, wherever `mods.eggsMod` is assembled, make ```js router.get("/api/ramble/egg", handle(async (req, res) => { - // A slot can empty from a sync arrival no local path observed; this moves - // an egg the user already owns and writes nothing when there is none. - // SPECULATIVE and therefore NOT emitting — a GET must never queue a sync - // op, and a promote landing mid-drain must not out-rank a deliberate - // incubate made on another instance. Note there is no `emit` argument. - await mods.eggsMod.promoteFromShelf(db, { now: Date.now(), speculative: true }); + // No promote here. A GET must never queue a sync op, and see + // promoteFromShelf's note: a read-path promote both races the drain and + // launders shelf_origin. hatchIfReady is the only local emptier. const state = await mods.eggsMod.eggState(db, { now: Date.now() }); const lay = await mods.eggsMod.layProgress(db); res.json({ ...state, lay }); @@ -1552,13 +1584,22 @@ where `egg` is `await mods.eggsMod.eggState(db, { now })` (line 812). The moment - [ ] **Step 5: Fix the MCP tool, which breaks the same way** -`bundles/ramble/server/server.js:306` **explicitly dereferences** `egg.egg.percent` — it does not pick the new shape up "for free from the spread". Patch it exactly as the route above: +`bundles/ramble/server/server.js:305-306` **explicitly dereferences** `egg.egg.percent` — it does not pick the new shape up "for free from the spread". Note the local there is called `egg`, **not** `eggSummary` as in the route, and `layProgress` is not imported (`:30` reads `import { eggState, activeBird, isoWeek } from "./eggs.js";`). So: ```js - egg: eggSummary.egg ?? null, - lay: await layProgress(db), +// :30 — extend the import +import { eggState, activeBird, isoWeek, layProgress } from "./eggs.js"; + +// :306 — ramble_pet_state +return text(JSON.stringify({ + ...state, bird, hearts: await heartsBalance(db), + egg: egg.egg ?? null, + lay: await layProgress(db), +})); ``` +**And `ramble_egg_state` at `:319`** still returns `{ egg, checklist }` with no `lay`, so the tool and `GET /api/ramble/egg` would disagree — the exact divergence this step exists to prevent. Add `lay` there too. + A tool and a route that disagree about the same egg is the defect phase 2 caught late. After patching, grep for any other dereference of `.egg.` that assumes non-null: ```bash @@ -1625,10 +1666,18 @@ test("the Next egg card is never hidden — it is the only route to the check-in }); test("the eggless copy is present and written from inside the premise", () => { + // ⚠ TWO FILES. Static copy lives in the server-rendered shell; only strings + // the client BUILDS live in the client. An earlier draft asserted both + // against the client, and asserted a "good days" literal the client never + // contains — it is concatenated around a pluralised day/days. + const shell = readFileSync("bundles/ramble/panel/ramble.js", "utf8"); + assert.ok(shell.includes("Nothing warming just now.")); + assert.ok(shell.includes("Nests hold them. So do friends.")); + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); assert.ok(src.includes("No one on the way just now.")); - assert.ok(src.includes("Nothing warming just now.")); - assert.ok(src.includes("good days"), "K4: the soft count is named"); + assert.ok(src.includes("keep it up and you'll manage one yourself"), "K4: the soft count is named"); + assert.ok(src.includes('" good "'), "pluralised around the count"); }); test("the AR renderer hides the egg when there is neither bird nor egg", () => { @@ -1749,23 +1798,53 @@ In `bundles/ramble/panel/static/ramble.js`, `paintEgg`: } else { /* No bird and no egg — the window between a first load and the * prologue's Go button. seedFromEggId(null) is 0, so drawing here - * would show a phantom egg that does not exist. Fall through to the - * plain dot, which hereIcon already handles. */ + * would show a phantom egg that does not exist. Return null and let + * hereIcon draw its documented plain dot (static/ramble.js:219-222). */ return null; } ``` +**⚠ `paintHereArt` must also stop skipping the repaint.** It currently reads: + +```js + var art = hereArt(); + if (art) hereDot.setIcon(hereIcon(art)); +``` + +A `null` skips `setIcon` **entirely**, so the marker keeps whatever icon it last had — only the initial creation (`:301`) ever reaches `hereIcon`'s fallback. A player who becomes eggless while still birdless would keep the phantom walking egg on the map, which is the whole defect. Make it unconditional: + +```js + /* Unconditional: hereIcon(null) is the documented plain-dot path, and + * skipping setIcon here would leave a stale egg on the map forever. */ + hereDot.setIcon(hereIcon(hereArt())); +``` + **The AR view (`rb-ar-egg`) must be fixed in the RENDERER, not in `startAr`.** `ramble-ar.js:378` runs `if (e.egg) setHidden(e.egg, valid);` on every frame, where `valid` means "a valid bird exists" — so it *un-hides* the egg whenever there is no bird, overriding anything `startAr` set within one animation frame. Add `bundles/ramble/panel/static/ramble-ar.js` to this task's files and thread a `hasEgg` flag onto the render frame: +**⚠ `frame` is NOT in `paintBird`'s scope** — `paintBird(bird)` is a local at `ramble-ar.js:375`; `frame` belongs to `render(state)` at `:400-402` and is passed in as `paintBird(frame.bird)` at `:435`. Referencing `frame.hasEgg` inside `paintBird` throws on every AR frame. Three exact edits: + ```js - // ramble-ar.js, paintBird: - if (e.bird) setHidden(e.bird, !valid); - // Phase 3: with no bird AND no egg there is nothing to draw. Without the - // hasEgg term this un-hides a phantom seed-0 egg every frame. - if (e.egg) setHidden(e.egg, valid || !frame.hasEgg); +// 1. static/ramble.js:2125 — the panel supplies the flag + arSession.render({ /* …existing fields… */, hasEgg: !!eggSeedId }); + +// 2. static/ramble-ar.js:228 — carry it onto the frame, beside `bird` + bird: s.bird || null, + hasEgg: !!s.hasEgg, // also update the state contract comment at :8 + +// 3. static/ramble-ar.js:375 and :435 — take it as an argument + function paintBird(bird, hasEgg) { + var valid = !!(engine && bird && /* …unchanged… */); + if (e.bird) setHidden(e.bird, !valid); + // Phase 3: with no bird AND no egg there is nothing to draw. Without the + // hasEgg term this un-hides a phantom seed-0 egg on every frame. + if (e.egg) setHidden(e.egg, valid || !hasEgg); + /* …rest unchanged… */ + } + // :435 + paintBird(frame.bird, frame.hasEgg); ``` -Set `hasEgg` where the frame is built from the pet state, and update `tests/ramble-ar.test.js` to cover the no-bird-no-egg case. **Do not assert the presence of a line in `startAr`** — an earlier draft of this plan did exactly that, and the assertion passed against a fix that the renderer immediately undid. +**`tests/ramble-ar.test.js:245` goes red and must be updated:** it calls `session.render({ anchors, pose: pose(null), bird: null })` and then asserts `els.egg.hasAttribute("hidden") === false`. With no `hasEgg` on the state that is now hidden. Pass `hasEgg: true` there, and add a new case with `bird: null, hasEgg: false` asserting the egg IS hidden. **Do not assert the presence of a line in `startAr`** — an earlier draft of this plan did exactly that, and the assertion passed against a fix that the renderer immediately undid. The framing fix (K5), in `paintHereArt` and `paintPerchGo`: @@ -1776,14 +1855,21 @@ The framing fix (K5), in `paintHereArt` and `paintPerchGo`: go.textContent = "How you're doing"; /* was "Your bird" / "Your egg" */ ``` -And the check-in confirmation at `static/ramble.js:1244` (S2) — the exact surrounding code must be read first; the branch is on whether the response carried an egg: +And the check-in confirmation at `static/ramble.js:1243-1246` (S2). **⚠ There is no `hasEgg` in scope there** — the handler branches on `out.credited`, and `POST /api/ramble/egg/checkin` answers `{ credited, warmth, hatched }` with no egg, while `eggSeedId` is still stale because `refreshEgg()` runs afterwards at `:1250`. So the route must say. Add `egg` to the check-in response in Task 6: + +```js + // routes.js, POST /api/ramble/egg/checkin — add to the res.json body: + egg: (await mods.eggsMod.getIncubatingEgg(db)) ? true : false, +``` + +and list it in Task 6's interfaces as `POST /api/ramble/egg/checkin -> { credited, warmth, hatched, egg }`. Then the client can branch honestly: ```js /* With no egg the credit was real but the warmth had nowhere to land * (D3). Saying "that is today's warmth" would be false on the one * screen K4 requires to be legible. */ - setText(el, hasEgg ? "Checked in. That is today's warmth." - : "Checked in. Nothing to warm yet — but it counted."); + setText(el, out.egg ? "Checked in. That is today's warmth." + : "Checked in. Nothing to warm yet — but it counted."); ``` - [ ] **Step 5: Style the eggless card** @@ -1800,15 +1886,19 @@ Append to `bundles/ramble/panel/static/ramble.css` — follow the file's existin ```bash node scripts/run-suite.mjs tests/ramble-panel.test.js +node scripts/run-suite.mjs tests/ramble-ar.test.js ``` -Expected: PASS, including the zero-backticks and exactly-two-sinks assertions. +Expected: both PASS, including the zero-backticks and exactly-two-sinks assertions. - [ ] **Step 7: Commit** +**⚠ Both AR files must be in the commit.** An earlier draft listed them in Files but omitted them here, and this plan forbids `git add -A`, so the AR fix would simply never have been committed. + ```bash git commit bundles/ramble/panel/ramble.js bundles/ramble/panel/static/ramble.js \ - bundles/ramble/panel/static/ramble.css tests/ramble-panel.test.js \ + bundles/ramble/panel/static/ramble-ar.js bundles/ramble/panel/static/ramble.css \ + tests/ramble-panel.test.js tests/ramble-ar.test.js \ -m "ramble: the panel answers for a player with no egg" ``` @@ -2123,10 +2213,10 @@ Expected: **no output**. If either check fails the design drifted; stop before g - [ ] **Confirm nothing mints an egg by accident any more** ```bash -grep -rn "mintIncubatingEgg" bundles/ | grep -v node_modules +grep -rn "mintIncubatingEgg(" bundles/ | grep -v node_modules | grep -v "^\s*\*" ``` -Expected: the definition, plus **exactly two** call sites — `grantStarterEgg` and `recordHappyDay`, both in `eggs.js`. Any third caller is the bug this phase exists to remove. +Expected: the definition, plus **exactly two** call sites — `grantStarterEgg` and `recordHappyDay`, both in `eggs.js`. Any third caller is the bug this phase exists to remove. (Match on `mintIncubatingEgg(` with a trailing paren and filter comment lines: this plan writes the bare name into several doc comments, so an unfiltered `grep -rn "mintIncubatingEgg"` fires spuriously.) - [ ] **Confirm the panel client rules held** @@ -2241,3 +2331,31 @@ DELETE FROM ramble_settings WHERE key LIKE 'prologue.%'; -- both dismissal flag **Baseline gate softened, deliberately (reviewer Q5).** "A drop below 4340 means something was deleted" would misfire once Task 3 legitimately rewrites seven assertions. The gate is now: 0 fail, and every touched test file named in the PR with what changed — the only assertions permitted to change meaning are the ones in Task 3's table. **Confirmed by the reviewer, needing no change:** Finding 1 is real (`feed.js:42`, `KEYED_TYPES` at `:14`) and chore/mark paths are genuinely unaffected — `chore` is not in `ACCEPTED_TYPES` and reaches `pet.js:feed` directly, while `mark_left`/`unlock_mark` are unkeyed so `shouldFeedPet` is unconditionally true. `ensureIncubatingEgg` has exactly the four claimed call sites, and nothing in `instance-sync.js`, `trades.js`, `delivery.js`, `claimNest` or `server.js` mints. Finding 2's quoted comment is real (`panel/ramble.js:311-313`) and the Next-egg card genuinely is the only route to the check-in. Finding 3 is real (`identity.js:136` — `randomBytes(32)` per instance). Task 3's trade fixtures are schema-valid and `OPEN_SQL` really is `"state IN ('proposed','accepted')"`. The `delta = 1` discipline matches `hearts.js`. The `eggs -> trades -> eggs` cycle Task 1 breaks is real and `flock.js:22` is the only external importer. Same-instance concurrent double-lay is already impossible via `INSERT OR IGNORE`. Privacy holds — no egg, lay-day or balance reaches a contact-facing payload. No schema change is needed. `mods.eggsMod` is a namespace import, so new exports are reachable automatically. + +--- + +### Second review (2026-09-09), scoped to round 1's fixes + +**Verdict: REVISE.** C1/C2, C4a, C4b and C6 were confirmed genuinely and precisely correct. But **the fixes to C5, C7, C8 and C9 each introduced new defects**, and C3's fix left behind a justification that is arithmetically false — the phase 2 pattern exactly, on the revised passages. + +| # | Issue | Resolution | +|---|---|---| +| N1 | Task 7's copy test asserted `"Nothing warming just now."` against the CLIENT (Task 7 puts it in the server shell) and a `"good days"` literal the client never contains — it is concatenated around a pluralised `day`/`days`. Two of three assertions would fail. | Split across the two files; asserts the real literals. | +| N2 | **The C8 AR fix was a `ReferenceError`.** `frame` is a local of `render(state)` (`ramble-ar.js:400`), not of `paintBird(bird)` (`:375`) — `frame.hasEgg` throws on every AR frame. "Set `hasEgg` where the frame is built" was also not actionable. | Three exact edits given: `static/ramble.js:2125` supplies the flag, `ramble-ar.js:228` carries it onto the frame, `:375`/`:435` pass it as an argument. | +| N3 | The AR files were in Task 7's Files list but **not in its commit**, and the plan forbids `git add -A` — so the fix would never have been committed. `tests/ramble-ar.test.js` was never run either. | Both added to the commit and to Step 6. | +| N4 | `tests/ramble-ar.test.js:245` renders `bird: null` and asserts the egg is NOT hidden; with `hasEgg` absent it becomes hidden and the test goes red. Unlisted. | Listed, with `hasEgg: true` there plus a new no-bird-no-egg case. | +| N5 | **C7's fix did not work on a repaint.** `paintHereArt` does `if (art) hereDot.setIcon(...)`, so a `null` skips `setIcon` entirely and the marker keeps its last icon — only first paint reaches `hereIcon`'s plain-dot fallback. A player who became eggless while birdless would keep the phantom egg. | `setIcon` made unconditional; `hereIcon(null)` is the documented plain-dot path. | +| N6 | Task 3's prescribed rewrite for `tests/ramble-flock.test.js:150` was wrong: the shelf is NOT empty there, so after the hatch `promoteFromShelf` draws the parked egg back and the count stays 1. Following the table literally fails. | Corrected to assert the parked egg is promoted back. | +| N7 | `tests/ramble-flock.test.js:174` broke because the C9 fix made `flockState` promote on read. | Moot — the read-path promote is deleted entirely (N11). Flagged to be re-verified rather than assumed. | +| N8 | The C5 table **misattributed the breakage to Task 3**; most of it lands at Task 2, where `eggState` stops minting — and Task 2 listed neither `ramble-panel` nor `ramble-tools`. Twelve further red assertions enumerated. The suite would have been red from Task 2 through Task 6, the precise failure C5 was raised about. | The Task 2 table now carries them, with the files, the Step 7 runs and the commit. | +| N9 | Task 6's four new route tests **cannot pass in `tests/ramble-panel.test.js`**: it shares one db across the file in declaration order, so an egg already exists (nest claim at `:1186`), and `POST /api/ramble/area` at `:458` already banked a `layday` row (fresh pet 60 → 75 → happy while eggless). | Moved to a new `tests/ramble-prologue-routes.test.js` with a per-test scratch db. | +| N10 | Task 6 Step 5's `server.js` snippet used the route's variable name (`eggSummary`; the local there is `egg`) and called `layProgress`, which is not imported at `server.js:30`. `ramble_egg_state` at `:319` was also left without `lay`, so the tool and the route would disagree — the divergence the step exists to prevent. | Real names, the import, and `lay` on both tools. | +| N11 | **The C9 fix laundered provenance.** Marking a read-path promote `shelf_origin='sync'` ranks it correctly, but `flock.js:126` counts `'user'` shelf eggs for the nest cap, `flock.js:253` for `shelf_count`, `instance-sync.js:831` rewrites demotions to `'sync'` unconditionally, `RAMBLE_EGG_REPROMOTE_SQL` drafts `'sync'` only, and `static/ramble.js:1776` labels `'sync'` as "came back from another of your Crows". A user egg relabelled 'sync' silently widens the shelf cap, is under-reported, becomes draftable by the very rule the 'user' mark protects it from, and is mislabelled. | **The read-path promote is DELETED.** Following the reviewer's closing question: once `'user'` eggs are excluded the only speculative case left is a `'sync'` egg, which `RAMBLE_EGG_REPROMOTE_SQL` already refills in the same apply batch — so the write during a GET buys nothing. `promoteFromShelf` is now single-mode and called from `hatchIfReady` alone. Deviation 4 rewritten. This is a simplification the review produced, not a patch. | +| N12 | **C3's justification was arithmetically false.** "A player who denies geolocation can still bank days" — decay is 10 per 6 h (−40/day) against a maximum of `checkin 5 + 3 × chore 8 = 29/day`, so from the default 60 they bank three happy days and then sit below the threshold permanently. | The claim is deleted from both places and replaced with the arithmetic and an explicit "laying requires real movement". **Raised with Kevin as a design question**, not silently accepted — see below. | +| N13 | S2's check-in copy branched on a `hasEgg` that does not exist: the handler sees `{ credited, warmth, hatched }` and `eggSeedId` is stale until `refreshEgg()` runs afterwards. | The check-in route now returns `egg`, listed in Task 6's interfaces. | + +**Suggestions adopted:** `tests/ramble-eggs.test.js:58`'s rewrite would have left nothing anywhere asserting a non-zero `percent` — a positive case is now required alongside it; `ramble_egg_state` carries `lay`; `PROLOGUE_KEYS` uses `Object.hasOwn`, since a plain literal makes `setPrologueSeen(db, "constructor")` bind a function into the SQL args instead of throwing; the final-verification grep matches `mintIncubatingEgg(` and filters comment lines, because this plan writes the bare name into several doc comments and the unfiltered grep fires spuriously. + +**Recorded for a future second instance (reviewer suggestion 1):** Deviation 3 accepts two eggs per lay on a two-instance fleet. A deterministic id *is* available without the cross-user collision that rules out a constant — derive it from `lay:` plus a per-user random salt written once into `ramble_settings`, which replicates (unlike `crowId`). Accepting remains right for now: Ramble is on grackle only, and the spare lands as `shelf_origin='sync'`, so it does not even consume the nest shelf cap. The salt is recorded so a second instance is a cheap change rather than a redesign. + +**Confirmed correct in round 2, not to be revisited:** the `routes.js:812-820` replacement (every field exactly once, `mods.eggsMod` is a namespace import so `layProgress` is reachable, no other nullable-egg dereference survives in `routes.js`); `readPet` genuinely private to `feedAll` and off the pet path; `feed` has `emit` in scope and `doChore` reaches it; `layProgress`'s key-ordered SQL (TEXT `MAX(key)` over `YYYY-MM-DD` is chronological, `COALESCE(…, '')` includes everything when no lay exists, and the threshold day is counted before the `lay` row is written and excluded after — no off-by-one); the multi-instance tests would genuinely fail the `created_at` implementation they rule out; C4b cannot fail to lay; every number in the C6 energy test (default 60, ceiling 100 with no hearts, deltas 5/15/20, and no decay possible between the timestamps used); the check-in does route through `pet.js:feed`; a pet at the ceiling still records a day; `'sync'` genuinely ranks below NULL in `applyRambleEgg`; no re-shelve loop exists and `hatchIfReady` handles a `'sync'`-origin incubating egg; Task 9's quoted doc lines are verbatim in both languages; and all four hard constraints hold. From 2bb2c68186bc2d88f9f0680f9b35bac69f81b6c4 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:18:07 -0500 Subject: [PATCH 04/20] plan: fix six issues from the third adversarial review --- .../plans/2026-09-09-ramble-eggs-phase3.md | 167 ++++++++++++++---- 1 file changed, 136 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md index 07561b6d..e4fee615 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md +++ b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md @@ -22,6 +22,7 @@ | K2 | `lay.days` — 14 or 10? | **14**, the spec's number. It is a live setting; retuning is a config change. | | K3 | Does the prologue ship in this phase? | **Yes, inside phase 3.** A new player on a build with no auto-mint and no starter egg has literally no egg and no way to be told why. The prologue is that player's only entry point — a functional dependency, not decoration. | | K4 | How legible is laying while eggless? | **Named, with a soft count, in words not a bar.** Phase 3 removes the free egg; a floor you cannot see is not reassurance. The eggless card says how many good days you have had. | +| K6 | Review proved the floor is unreachable without walking (decay −40/day vs. a maximum of 29/day from check-in + chores, so 3 banked days and then permanently below the 60 threshold). Retune? | **No — leave it. Laying requires walking.** The point of the game is to go outside, so a floor that still needs movement is on-theme; the housebound player keeps earning seed and hearts. Do not touch decay, chore values, or `lay.days`. **The queued pedometer arc (after phase 4) is the proper fix** — steps feed energy indoors, which is exactly the missing input, so bending the decay curve now would only have to be undone. Say this plainly in the hand-back rather than letting it read as an oversight. | | K5 | What is the incubating egg, given you ARE the bird? | **The next you.** You are whoever is active; the incubating egg is the next self coming; the flock is everyone you have been; laying is you laying it yourself. This is the shipped premise — map phase 1, at the operator's request: *"you are not carrying an egg, you ARE one — an egg that wandered off from its nest."* All copy in this phase is written from inside it. | --- @@ -84,7 +85,7 @@ A fixed constant id is also wrong: a contact could gift you *their* starter egg 1. **The starter-egg id is a random UUID gated on an empty egg table, not derived from the Crow identity.** See Finding 3 — the spec's mechanism is impossible because `crowId` is per-instance. Say this in the PR body so a reviewer reads it as a correction, not drift. 2. **`creditWarmth` still writes its ledger row when there is no egg.** See Finding 1. The spec does not describe the interaction with `feedAll`'s `shouldFeedPet` gate at all; without this, laying is unreachable. 3. **A two-instance fleet can lay two eggs from one lay, and that is accepted.** Both instances eggless, both observing the same happy days, both crossing the threshold before a sync: each writes the same `lay:` key locally (so `rowsAffected` is 1 on both) and each mints an egg with its own UUID. `applyRambleEgg` then shelves one, so the user ends with one incubating and one spare on the shelf. Making this impossible would need a deterministic egg id derived from the lay day — which reintroduces exactly the cross-user collision that rules a constant id out for the starter egg (a contact who laid on the same date could gift you a colliding row). **Ramble is installed on grackle only**, so this is currently unreachable; the graceful degradation is the existing convergence rule, and the cost of the alternative is a real collision for a hypothetical one. Revisit if a second instance ever installs the bundle. -4. **Auto-promote runs ONLY on hatch — the read paths deliberately do not promote.** Spec §4.2 says "when the incubating slot empties" without saying who notices, and two drafts of this plan had the read paths notice. Both were wrong: a write during a GET races `applyRambleEgg`'s `isUserShelve` carve-out (`instance-sync.js:855-860`), and the attempted mitigation (marking such a promote `'sync'`) launders provenance that `flock.js:126`'s nest shelf cap, `flock.js:253`'s `shelf_count`, `RAMBLE_EGG_REPROMOTE_SQL` and `static/ramble.js:1776` all read. Deleting the read-path promote costs one case — a gift arriving while wholly eggless waits on the shelf for a manual "incubate" — and that is arguably the better interaction. +4. **Auto-promote runs ONLY on hatch — the read paths deliberately do not promote.** Spec §4.2 says "when the incubating slot empties" without saying who notices, and two drafts of this plan had the read paths notice. Both were wrong: a write during a GET races `applyRambleEgg`'s `isUserShelve` carve-out (`instance-sync.js:855-860`), and the attempted mitigation (marking such a promote `'sync'`) launders provenance that `flock.js:126`'s nest shelf cap, `flock.js:253`'s `shelf_count`, `RAMBLE_EGG_REPROMOTE_SQL` and `static/ramble.js:1776` all read. The read-path promote is therefore deleted, and the cases it was covering are handled on **write** paths instead: `hatchIfReady`, plus `receiveTrade`/`declineSwap`/`expireTrades` in `trades.js` (Task 3 Step 5b), which is what stops a swap-locked last egg from leaving the player eggless *and* unable to lay. One case remains uncovered by design — a slot emptied by `applyRambleEgg` while the user holds only `'user'` shelf eggs, unreachable on a one-instance fleet and recovered by a manual "incubate". --- @@ -95,13 +96,14 @@ A fixed constant id is also wrong: a contact could gift you *their* starter egg - `tests/ramble-eggs-supply.test.js` — null tolerance, the feed decoupling, auto-promote, convergence. - `tests/ramble-laying.test.js` — the lay-day ledger and the floor. - `tests/ramble-prologue.test.js` — the starter grant and the two flags. +- `tests/ramble-prologue-routes.test.js` — the prologue routes, on their own virgin-db harness (Task 6). **Modify** -- `bundles/ramble/server/trades.js` — import the three lock symbols from `egg-locks.js` and re-export `isEggLocked`/`lockedEggIds` so existing consumers are untouched. +- `bundles/ramble/server/trades.js` — import the three lock symbols from `egg-locks.js` and re-export `isEggLocked`/`lockedEggIds` so existing consumers are untouched (Task 1); call `promoteFromShelf` from `receiveTrade`/`declineSwap`/`expireTrades` (Task 3 Step 5b). - `bundles/ramble/server/eggs.js` — the bulk: rename, null tolerance, promote, laying, starter grant, prologue flags. - `bundles/ramble/server/flock.js` — drop the mint from `flockState`; import the locks from the leaf. - `bundles/ramble/server/pet.js` — call the lay-day recorder after mood is known. -- `bundles/ramble/server/feed.js` — `readPet` carries the egg summary and lay progress. +- `bundles/ramble/panel/static/ramble-ar.js` — the AR renderer must not draw a phantom egg (Task 7). **`bundles/ramble/server/feed.js` is NOT modified** — an early draft had `readPet` carry the egg summary; see Task 6 Step 4 for why that is wrong. - `bundles/ramble/server/init-tables.js` — **comment only** (the `shelf_origin` comment says `'user'` must never be auto-promoted; that rule is the *sync layer's*, and phase 3 adds an app-level promote that deliberately does). - `bundles/ramble/panel/routes.js` — null-tolerant egg/pet/flock responses; the two prologue routes. - `bundles/ramble/server/server.js` — `ramble_egg_state` / `ramble_pet_state` MCP tools must agree with the HTTP routes. @@ -150,8 +152,9 @@ Create `bundles/ramble/server/egg-locks.js`, pasting the real `OPEN_SQL` from St * trades.js, but trades.js imports `startOfLocalDay` from eggs.js, and phase * 3's auto-promote (eggs.js) must skip a locked egg — so keeping it there * would force an eggs -> trades -> eggs cycle. trades.js re-exports both - * helpers, so its existing importer (flock.js, the only one) is unchanged and - * there is still exactly one definition of "locked". + * helpers, so its existing importers (flock.js:22 and tests/ramble-trades + * .test.js:14 — NOT panel/routes.js, which never imported them) are + * unchanged and there is still exactly one definition of "locked". */ // Verbatim from trades.js:68. An "open" trade is one that still has a claim on @@ -187,6 +190,8 @@ import { OPEN_SQL, isEggLocked, lockedEggIds } from "./egg-locks.js"; export { isEggLocked, lockedEggIds }; ``` +**⚠ `panel/routes.js` does NOT import the lock helpers** — `flock.js:22` and `tests/ramble-trades.test.js:14` are the only importers. Do not write otherwise into the comment. + **⚠ `LOCK_GUARD_SQL` (line 69) and `GIFTABLE_GUARD_SQL` (line 71) sit immediately below the old `OPEN_SQL` and interpolate it at module load.** They stay in `trades.js` untouched — the imported `OPEN_SQL` feeds them exactly as the local const did. Deleting the const without adding the import breaks the module at load, not at test time. Leave every other use of `OPEN_SQL` inside `trades.js` exactly as it was. - [ ] **Step 4: Point `flock.js` at the leaf directly** @@ -222,7 +227,10 @@ git commit bundles/ramble/server/egg-locks.js bundles/ramble/server/trades.js bu **Files:** - Modify: `bundles/ramble/server/eggs.js` - Create: `tests/ramble-eggs-supply.test.js` +- **Modify (import rename — REQUIRED IN THIS TASK):** `bundles/ramble/server/flock.js` - Modify (import rename only): `tests/ramble-flock.test.js`, `tests/ramble-trades.test.js`, `tests/ramble-sync.test.js` + +**⚠ `flock.js` MUST be renamed here, not in Task 3.** `flock.js:20` imports `ensureIncubatingEgg` **by name** and calls it at `:225`. Renaming the export without touching `flock.js` is a module-load `SyntaxError: does not provide an export named` — which takes down `ramble-flock`, `ramble-panel` (routes → flock) and `ramble-tools` (server.js → flock), i.e. three of the five suites this task's Step 7 requires green. Rename both the import and the call site now; Task 3 Step 5 then deletes the call. - **Modify (these go RED at THIS task, because `eggState` stops minting):** `tests/ramble-eggs.test.js`, `tests/ramble-panel.test.js`, `tests/ramble-tools.test.js` **⚠ The nullable egg breaks assertions here, in Task 2 — not in Task 3.** An earlier draft attributed all of them to Task 3 and listed only some. `POST /api/ramble/area` and `GET /api/ramble/egg` no longer mint, so every one of these dereferences a `null`: @@ -466,11 +474,12 @@ Also update `notCredited()` to use `getIncubatingEgg` (it already did, via the p ```bash cd /home/kh0pp/crow-wt-ramble-eggs sed -i 's/\bensureIncubatingEgg\b/mintIncubatingEgg/g' \ + bundles/ramble/server/flock.js \ tests/ramble-eggs.test.js tests/ramble-flock.test.js tests/ramble-trades.test.js tests/ramble-sync.test.js grep -rn "ensureIncubatingEgg" tests/ bundles/ | grep -v node_modules ``` -Expected from the grep: **no hits**. `servers/sharing/instance-sync.js:726` mentions the old name in a doc comment — leave that file alone; Task 3 fixes the comment in `init-tables.js` only. If the grep shows `instance-sync.js`, that is expected and must NOT be edited. +Expected from the grep: **no hits** — and `bundles/ramble/server/flock.js` is in that `sed` list for the reason above, so do not drop it. (`servers/sharing/instance-sync.js:733` also mentions the old name in a doc comment, but this grep is scoped to `tests/ bundles/` and can never show it. Leave that file alone regardless.) - [ ] **Step 7: Run the tests** @@ -490,7 +499,7 @@ Expected: all PASS, after the fixtures in the table above have been given explic git add tests/ramble-eggs-supply.test.js git commit bundles/ramble/server/eggs.js tests/ramble-eggs-supply.test.js tests/ramble-eggs.test.js \ tests/ramble-flock.test.js tests/ramble-trades.test.js tests/ramble-sync.test.js \ - tests/ramble-panel.test.js tests/ramble-tools.test.js \ + tests/ramble-panel.test.js tests/ramble-tools.test.js bundles/ramble/server/flock.js \ -m "ramble: looking at a screen no longer mints an egg" ``` @@ -513,8 +522,7 @@ git commit bundles/ramble/server/eggs.js tests/ramble-eggs-supply.test.js tests/ | `tests/ramble-eggs.test.js:82` | `before.egg.warmth` after a hatch | same | | `tests/ramble-flock.test.js:150` | *"a successor egg was minted"* | **NOT "the slot is empty"** — the shelf is not empty there. The test parks egg `E` as `'user'` via `incubateEgg`, then `hot` hatches and `promoteFromShelf` draws `E` straight back in, so the count stays 1. Assert `getIncubatingEgg(d).egg_id === E` and retitle to *"the parked egg is promoted back into the slot"*. | | `tests/ramble-flock.test.js:172` | *"the incubating egg is ensured and listed first"* | mint one explicitly as a fixture, then assert ordering | -| `tests/ramble-tools.test.js:177` | `s.eggs[0].status === "incubating"` | mint a fixture, or assert the eggless shape | -| `tests/ramble-panel.test.js:1227` | `flock.eggs.find(e => e.status === "incubating")` — the file header (line 53) says it "churns hatches and later asserts an incubating egg exists" | give the fixture an explicit egg | +**`tests/ramble-tools.test.js:177` and `tests/ramble-panel.test.js:1227` are NOT in this table** — nothing mints from Task 2 onward, so they go red there, along with `ramble-panel.test.js:1228`, `:1234`, `:1239` and `:1347`. Repair them in Task 2 by minting one incubating egg in each harness's setup, which fixes the whole family at once. `tests/ramble-flock.test.js:174` and `tests/ramble-panel.test.js:1226/1234` were red in a draft where `flockState` promoted on read. It no longer does (see `promoteFromShelf`'s note), so they are unaffected — **verify that rather than assuming it**, since it is the kind of claim this plan has already got wrong twice. @@ -532,6 +540,7 @@ Append to `tests/ramble-eggs-supply.test.js`: ```js import { promoteFromShelf, hatchIfReady } from "../bundles/ramble/server/eggs.js"; +import { flockState } from "../bundles/ramble/server/flock.js"; async function shelveEgg(db, eggId, createdAt, { status = "shelf", origin = "user" } = {}) { await db.execute({ @@ -615,8 +624,10 @@ test("promoteFromShelf SKIPS an egg spoken for by an open swap", async () => { test("an EXPIRED but unswept trade still locks its egg — do not 'fix' the predicate", async () => { // expireTrades runs on the 15 s drain tick, so there is a window where a - // lapsed offer is still 'proposed'. The slot stays empty until the sweep. - // Harmless, and pinned here so nobody later widens OPEN_SQL to "fix" it. + // lapsed offer is still 'proposed' and its egg stays locked. Recovery is + // expireTrades itself, which calls promoteFromShelf (Task 3 Step 5b) — NOT + // a later read, which no longer promotes. Pinned here so nobody widens + // OPEN_SQL to "fix" the window. const db = await freshDb(); await shelveEgg(db, "only-one", T0); await db.execute({ @@ -742,12 +753,24 @@ Then: * the 'user' mark exists to protect it from, and is mislabelled "came * back from another of your Crows" at `static/ramble.js:1776`. * - * Deleting the read-path call resolves both, and costs almost nothing: the - * slot only ever empties locally on a hatch (this function), and a slot - * emptied by convergence is already refilled by `RAMBLE_EGG_REPROMOTE_SQL` - * in the same apply batch. The one uncovered case is a gift arriving while - * the user is wholly eggless — it lands on the shelf and they tap "incubate", - * which is arguably the better interaction anyway: you choose to warm a gift. + * Deleting the read-path call resolves both. What it does NOT do is cover + * every emptier by itself, so this function is also called from the + * trade-closing WRITE paths in `trades.js` (see below). Honest inventory: + * + * - a hatch -> covered here + * - `incubateEgg` swap -> never empties the slot (one conditional + * UPDATE), and it ends in `hatchIfReady` + * - gifting / swapping away -> the incubating egg is not giftable + * (`GIFTABLE = {shelf, received}`) + * - a swap or gift ARRIVING while the user is wholly eggless + * -> covered by the trades.js calls + * - a swap expiring or being declined, unlocking the last shelf egg + * -> covered by the trades.js calls + * - a slot emptied by `applyRambleEgg` while the user holds ONLY 'user' + * shelf eggs -> NOT covered. `RAMBLE_EGG_REPROMOTE_SQL` drafts + * `shelf_origin='sync'` rows only. Unreachable on a one-instance fleet; + * recovered by a manual "incubate". Do not "fix" it by promoting on a + * read — that is what this note exists to prevent. */ export async function promoteFromShelf(db, { now, emit } = {}) { void now; @@ -807,6 +830,33 @@ In `bundles/ramble/server/flock.js`, delete `await ensureIncubatingEgg(db, { now Update the import: drop `ensureIncubatingEgg`. **Do not add `promoteFromShelf`** — `flock.js` no longer needs it. +- [ ] **Step 5b: Promote from the trade-closing paths, or a swap can deadlock the game** + +Without this there is a real trap, and it is not merely cosmetic. Suppose the user offers their **only** shelf egg in a swap and then hatches: + +- the slot empties, and `promoteFromShelf` skips the locked egg, so nothing refills it; +- `hasAnyEggAnywhere` counts `shelf`/`received`, so the locked egg **also blocks laying**; +- warmth vanishes (D3) with nothing to land in; +- and once the swap expires or is declined, **nothing calls `promoteFromShelf` again**. The read paths no longer do, and there is no egg left to hatch. + +The player is stuck until they happen to open the flock screen and tap "incubate", with no signal that they should. These are **write** paths, so none of the read-path objections apply — no GET is queuing a sync op, and there is no drain race to lose. `trades.js` already imports from `eggs.js` (`:32`), so there is no cycle. + +In `bundles/ramble/server/trades.js`, extend the existing import and call the promote at the end of each path that can free or deliver an egg — `receiveTrade` (`:213`), `declineSwap` (`:187`) and `expireTrades` (`:306`): + +```js +import { startOfLocalDay, promoteFromShelf } from "./eggs.js"; + +// …at the end of receiveTrade, declineSwap and expireTrades, after their +// existing writes and emits: + // Phase 3 (spec §4.2): closing a trade can free the last shelf egg, or + // deliver one, while the incubating slot sits empty. These are writes, not + // reads, so promoting here is safe — and without it a user whose only shelf + // egg was locked in a swap is left eggless AND unable to lay. + await promoteFromShelf(db, { now, emit }); +``` + +`promoteFromShelf` writes nothing when the slot is occupied or nothing is promotable, so adding it to a path that did not need it is a no-op. + - [ ] **Step 6: Correct the stale comment in `init-tables.js` — COMMENT ONLY** The `shelf_origin` comment currently reads "and it must NEVER be auto-promoted". Phase 3 adds an auto-promote that deliberately does. Leaving it would make a reader conclude the new promote is a bug — the phase 2 lesson about a wrong-but-checkable justification, in reverse. Change that clause to: @@ -1218,6 +1268,7 @@ export async function recordHappyDay(db, { now = Date.now(), mood, emit } = {}) ```bash node scripts/run-suite.mjs tests/ramble-laying.test.js node scripts/run-suite.mjs tests/ramble-pet.test.js +node scripts/run-suite.mjs tests/ramble-feed.test.js node scripts/run-suite.mjs tests/ramble-eggs-supply.test.js ``` @@ -1452,8 +1503,10 @@ git commit bundles/ramble/server/eggs.js tests/ramble-prologue.test.js \ ## Task 6: the routes and the MCP tools speak the new shape **Files:** -- Modify: `bundles/ramble/panel/routes.js`, `bundles/ramble/server/feed.js`, `bundles/ramble/server/server.js` +- Modify: `bundles/ramble/panel/routes.js`, `bundles/ramble/server/server.js` +- **Create: `tests/ramble-prologue-routes.test.js`** (see Step 1 — it cannot live in `ramble-panel.test.js`) - Modify: `tests/ramble-panel.test.js`, `tests/ramble-tools.test.js` +- **NOT modified: `bundles/ramble/server/feed.js`** — see Step 4. **Interfaces:** - `GET /api/ramble/egg` -> `{ egg: null | {...}, checklist: {...}, lay: { days, needed } }` @@ -1461,6 +1514,7 @@ git commit bundles/ramble/server/eggs.js tests/ramble-prologue.test.js \ - `GET /api/ramble/prologue` -> `{ intro_seen, hatch_seen, granted }` - `POST /api/ramble/prologue/intro` -> `{ egg: row|null, intro_seen: true }` — grants and flags - `POST /api/ramble/prologue/hatch` -> `{ hatch_seen: true }` +- `POST /api/ramble/egg/checkin` -> `{ credited, warmth, hatched, egg }` — `egg` is new: a boolean saying whether one is incubating after the credit (Task 7's copy branches on it) **⚠ Both prologue POSTs must be idempotent** — the panel fires them from a dismiss button that a double-tap can send twice. @@ -1468,7 +1522,13 @@ git commit bundles/ramble/server/eggs.js tests/ramble-prologue.test.js \ **⚠ These four tests CANNOT go in `tests/ramble-panel.test.js`'s shared harness.** That file creates ONE scratch `CROW_DATA_DIR` and one db at `:28-36` for the whole file and runs in declaration order, so by the time these ran: `grantStarterEgg` would return `null` (an egg already exists from the nest claim at `:1186`), `egg: null` would be false, and `lay.days` would already be ≥ 1 because `POST /api/ramble/area` at `:458` feeds a fresh pet 60 → 75 → happy while eggless and therefore writes a `layday` row. -Put them in a **new file `tests/ramble-prologue-routes.test.js`** with its own `createClient` + `initRambleTables` scratch db per test, the way `tests/ramble-eggs.test.js:88` does with `file::memory:`. Add that file to this task's Files list. The route helpers (`get`/`post`) should be built the same way `ramble-panel.test.js` builds its own, but against the fresh db. +Put them in a **new file `tests/ramble-prologue-routes.test.js`**. + +**⚠ A per-test `createClient` handle is NOT achievable — do not try.** `rambleRouter(dashboardAuth, options)` (`routes.js:176`) takes only an injected `emit`; the db is created inside the closure at `:230` via `mods.dbMod.createDbClient()`, which resolves `CROW_DB_PATH` → `CROW_DATA_DIR/crow.db` (`server/db.js:124`) and is then memoized for the router's lifetime. There is no seam to hand it an in-memory client. + +What these tests actually need is not per-test isolation but **one virgin db**, which the existing harness gives for free. Copy the setup at `tests/ramble-panel.test.js:28-44`: `mkdtemp` → set `CROW_APP_ROOT` and `CROW_DATA_DIR` → dynamic `import()` of `routes.js` → express app → `once(server, "listening")` → a `req()` helper. Keep one db for the file and rely on declaration order: tests 1 and 2 assert `egg: null` on a virgin db, test 3 grants, test 4 is order-independent. Nothing on these four routes mints, and none of them feeds — `recordHappyDay` is called only from `pet.js:feed` — so no `layday` row can appear either. + +(If genuine per-test isolation is ever needed it takes a fresh `CROW_DB_PATH` **plus** a fresh `rambleRouter()` and app per test, not a client handle.) Sketch of the assertions (adapt to the harness you build): @@ -1518,14 +1578,14 @@ test("POST /api/ramble/prologue/hatch flags the second beat and is idempotent", - [ ] **Step 2: Run it to watch it fail** ```bash -node scripts/run-suite.mjs tests/ramble-panel.test.js +node scripts/run-suite.mjs tests/ramble-prologue-routes.test.js ``` Expected: FAIL — the prologue routes 404 and `lay` is absent. - [ ] **Step 3: Load the new module surface and add the routes** -In `bundles/ramble/panel/routes.js`, wherever `mods.eggsMod` is assembled, make sure `layProgress`, `readPrologue`, `setPrologueSeen`, `grantStarterEgg` and `promoteFromShelf` are reachable. Then extend the egg route and add the prologue routes: +`mods.eggsMod` is a namespace import (`routes.js:227`), so every new `eggs.js` export — `layProgress`, `readPrologue`, `setPrologueSeen`, `grantStarterEgg`, `getIncubatingEgg` — is reachable with no import change. **`promoteFromShelf` is deliberately NOT in that list: no route calls it.** Then extend the egg route and add the prologue routes: ```js router.get("/api/ramble/egg", handle(async (req, res) => { @@ -1553,6 +1613,15 @@ In `bundles/ramble/panel/routes.js`, wherever `mods.eggsMod` is assembled, make })); ``` +**And the check-in route must say whether an egg exists**, or Task 7's honest confirmation copy has nothing to branch on (the handler sees only `{ credited, warmth, hatched }`, and `eggSeedId` is stale until `refreshEgg()` runs afterwards). In the existing `POST /api/ramble/egg/checkin` handler (`routes.js:845-849`), add one field to the response: + +```js + // Read AFTER feedAll deliberately: a check-in that hatches the last egg + // with an empty shelf reports false, and "nothing to warm yet" is then + // the true statement about what comes next. + egg: !!(await mods.eggsMod.getIncubatingEgg(db)), +``` + Keep the existing `GET /api/ramble/egg` handler's other behaviour (auth, error handling) exactly as it was — copy the surrounding shape from the file rather than the sketch above. - [ ] **Step 4: Fix `GET /api/ramble/pet`, which 500s for every eggless player** @@ -1611,17 +1680,19 @@ Every hit must be null-guarded or provably reached only when an egg exists. - [ ] **Step 6: Run the tests** ```bash +node scripts/run-suite.mjs tests/ramble-prologue-routes.test.js node scripts/run-suite.mjs tests/ramble-panel.test.js node scripts/run-suite.mjs tests/ramble-tools.test.js ``` -Expected: both PASS. +Expected: all PASS. - [ ] **Step 7: Commit** ```bash -git commit bundles/ramble/panel/routes.js bundles/ramble/server/feed.js bundles/ramble/server/server.js \ - tests/ramble-panel.test.js tests/ramble-tools.test.js \ +git add tests/ramble-prologue-routes.test.js +git commit bundles/ramble/panel/routes.js bundles/ramble/server/server.js \ + tests/ramble-prologue-routes.test.js tests/ramble-panel.test.js tests/ramble-tools.test.js \ -m "ramble: the routes answer for a player with no egg" ``` @@ -1864,12 +1935,15 @@ And the check-in confirmation at `static/ramble.js:1243-1246` (S2). **⚠ There and list it in Task 6's interfaces as `POST /api/ramble/egg/checkin -> { credited, warmth, hatched, egg }`. Then the client can branch honestly: +**⚠ Keep the not-credited branch.** Today's code is a two-way branch and the second arm still matters — replacing it with a test on `out.egg` alone would make a second same-day tap read "Checked in. That is today's warmth.", a fresh lie on the screen this change exists to stop lying on. It becomes three-way: + ```js - /* With no egg the credit was real but the warmth had nowhere to land - * (D3). Saying "that is today's warmth" would be false on the one - * screen K4 requires to be legible. */ - setText(el, out.egg ? "Checked in. That is today's warmth." - : "Checked in. Nothing to warm yet — but it counted."); + /* Three arms, not two. With no egg the credit was real but the warmth + * had nowhere to land (D3), and a repeat tap is neither. */ + setText($("rb-egg-status"), + !(out && out.credited) ? "Already checked in today — go somewhere instead." + : out.egg ? "Checked in. That is today's warmth." + : "Checked in. Nothing to warm yet — but it counted."); ``` - [ ] **Step 5: Style the eggless card** @@ -1902,6 +1976,12 @@ git commit bundles/ramble/panel/ramble.js bundles/ramble/panel/static/ramble.js -m "ramble: the panel answers for a player with no egg" ``` +**⚠ If you made the check-in route edit while working this task** (its instruction appears above, but it belongs to Task 6), commit `bundles/ramble/panel/routes.js` with it — otherwise the copy branches on a field the route never sends: + +```bash +git commit bundles/ramble/panel/routes.js -m "ramble: the check-in says whether there is an egg" +``` + --- ## Task 8: the prologue @@ -2246,7 +2326,7 @@ Fix everything it finds ON THE BRANCH before opening the PR. **Re-review every f - **No schema change, no migration, no `SCHEMA_GENERATION` bump** — laying rides `ramble_wallet`, prologue flags ride `ramble_settings`. - **Finding 1**: `feedAll` gates the pet feed on `credited`, so warmth and energy had to be decoupled or laying would be unreachable. - **Finding 3 / Deviation 1**: the spec's starter-egg race protection is impossible because `crowId` is per-instance; a random uuid plus the existing convergence rule replaces it. -- **Deviation 3**: auto-promote runs on read paths, and why that is not the same defect as auto-minting. +- **Deviation 4**: auto-promote runs ONLY on hatch and the trade-closing writes — never on a read path — and why a read-path promote both races the sync drain and launders `shelf_origin`. - **K1**: the grant is derived from replicated egg data so a game-state reset replays the prologue. - **K5**: all copy is written from "you ARE the egg; the incubating egg is the next you". - Ramble is installed on **grackle only** — crow primary and r4 have no Ramble bundle. @@ -2280,7 +2360,9 @@ Every run must be `completed` / `success`. Contexts: `suite`, `static-checks`, ` - [ ] **Hand back to Kevin** -Say plainly: what shipped; that his current egg is grandfathered and will be the last one that arrives on its own; that after it hatches the slot stays empty unless the shelf has one; what the eggless card will say; the phone smoke test still outstanding from phase 2 (walk to one of the 14 heart pips); and anything the whole-branch review found. +**Say the laying floor's real reach out loud (K6).** Laying requires walking: without location fixes a player banks three happy days and then sits below the happy threshold for good, so the floor protects an irregular walker, not a housebound one. That is the accepted design, and the queued pedometer arc is what will change it. + +Then: what shipped; that his current egg is grandfathered and will be the last one that arrives on its own; that after it hatches the slot stays empty unless the shelf has one; what the eggless card will say; the phone smoke test still outstanding from phase 2 (walk to one of the 14 heart pips); and anything the whole-branch review found. **The game-state reset needs more than two tables — spell it out.** The prologue will not fire for Kevin until he resets, and clearing only `ramble_eggs` plus the `prologue.%` settings would start the "new game" with a full energy bar, a dangling `active_egg_id`, and a part-finished lay count. The full set is: @@ -2359,3 +2441,26 @@ DELETE FROM ramble_settings WHERE key LIKE 'prologue.%'; -- both dismissal flag **Recorded for a future second instance (reviewer suggestion 1):** Deviation 3 accepts two eggs per lay on a two-instance fleet. A deterministic id *is* available without the cross-user collision that rules out a constant — derive it from `lay:` plus a per-user random salt written once into `ramble_settings`, which replicates (unlike `crowId`). Accepting remains right for now: Ramble is on grackle only, and the spare lands as `shelf_origin='sync'`, so it does not even consume the nest shelf cap. The salt is recorded so a second instance is a cheap change rather than a redesign. **Confirmed correct in round 2, not to be revisited:** the `routes.js:812-820` replacement (every field exactly once, `mods.eggsMod` is a namespace import so `layProgress` is reachable, no other nullable-egg dereference survives in `routes.js`); `readPet` genuinely private to `feedAll` and off the pet path; `feed` has `emit` in scope and `doChore` reaches it; `layProgress`'s key-ordered SQL (TEXT `MAX(key)` over `YYYY-MM-DD` is chronological, `COALESCE(…, '')` includes everything when no lay exists, and the threshold day is counted before the `lay` row is written and excluded after — no off-by-one); the multi-instance tests would genuinely fail the `created_at` implementation they rule out; C4b cannot fail to lay; every number in the C6 energy test (default 60, ceiling 100 with no hearts, deltas 5/15/20, and no decay possible between the timestamps used); the check-in does route through `pet.js:feed`; a pet at the ceiling still records a day; `'sync'` genuinely ranks below NULL in `applyRambleEgg`; no re-shelve loop exists and `hatchIfReady` handles a `'sync'`-origin incubating egg; Task 9's quoted doc lines are verbatim in both languages; and all four hard constraints hold. + +--- + +### Third review (2026-09-09), scoped to round 2's fixes + +**Verdict: REVISE.** Round 2's fixes were materially better — the AR line references, the `hereIcon`/`paintHereArt` analysis, the `flock.test.js:150` re-derivation, the `server.js` names and all twelve N8 line numbers were verified exact. But three were incomplete enough to stop execution, and the N11 deletion left a claim that was wrong about game behaviour. + +| # | Issue | Resolution | +|---|---|---| +| T1 | **Task 2 broke `flock.js` at module load.** `flock.js:20` imports `ensureIncubatingEgg` **by name**; Task 2 renamed the export but did not list, edit or commit `flock.js` — a `SyntaxError` taking down three of the five suites Task 2's own Step 7 requires green. Its verification grep also mis-stated the expected output and cited `instance-sync.js:726` (really `:733`) inside a grep scoped to `tests/ bundles/`, where it can never appear. | `flock.js` added to Task 2's Files, `sed` list and commit; the grep note corrected. | +| T2 | The new `tests/ramble-prologue-routes.test.js` was **orphaned** — absent from the Files list, both run-steps, the `git add` and the commit. With `git add -A` forbidden, N3's failure mode reproduced verbatim. | Wired into all five places, and into the global File-structure list. | +| T3 | **N9's harness was not executable.** `rambleRouter` takes only an injected `emit`; the db is built inside the closure by `createDbClient()` from `CROW_DATA_DIR` and memoized, so no `createClient` handle can be passed in. | Replaced with the achievable thing: copy `ramble-panel.test.js:28-44`'s mkdtemp harness and rely on one virgin db plus declaration order — none of these four routes mints or feeds. The per-test alternative (fresh `CROW_DB_PATH` **and** a fresh router+app) is written down rather than implied. | +| T4 | N13's check-in fix was split across two tasks and landed in neither: the route edit was described in Task 7 but belongs to Task 6, was absent from Task 6's interfaces, and Task 7's commit omits `routes.js`. **And the client snippet dropped the not-credited arm**, so a second same-day tap would read "Checked in. That is today's warmth." — a fresh lie on the screen the change exists to stop lying on. | The route edit now has a home and an interface line in Task 6; the client branch is three-way; Task 7 carries a fallback commit if the edit is made there. | +| T5 | Task 6 Step 3 still told the implementer to make `promoteFromShelf` reachable from the routes — the exact invitation N11 removed — and the PR-body checklist still promised "auto-promote runs on read paths", mis-numbered as Deviation 3. As written the PR body would state a falsehood about the branch. | Both corrected. | +| T6 | **Deviation 4's "costs one case" was false, and the gap is a deadlock.** With the read-path promote gone, nothing calls `promoteFromShelf` after a trade closes. A user who offers their only shelf egg in a swap and then hatches is left with an empty slot, **laying also blocked** (`hasAnyEggAnywhere` counts the locked egg), and warmth vanishing — recoverable only by noticing and tapping "incubate". The expired-trade test's "harmless… until the sweep" comment described round-1 behaviour that N11 removed. | New **Task 3 Step 5b**: `receiveTrade`, `declineSwap` and `expireTrades` call `promoteFromShelf`. These are writes, so none of N11's drain-race or provenance objections apply, and `trades.js` already imports from `eggs.js` (`:32`) so there is no cycle. The docstring now carries an honest inventory of every emptier, including the one genuinely uncovered case (a slot emptied by `applyRambleEgg` while only `'user'` shelf eggs remain — unreachable on a one-instance fleet). | + +**Secondary fixes applied:** the `flockState` import in `tests/ramble-eggs-supply.test.js` (lost when an earlier edit script aborted before writing — the reviewer caught it); the stale global File-structure entry claiming `feed.js`'s `readPet` carries the egg summary, which Task 6 explicitly forbids; `ramble-ar.js`, `ramble-ar.test.js` and `ramble-prologue-routes.test.js` added to that list; Task 1's comment naming `panel/routes.js` as an importer of the lock helpers when `flock.js:22` and `tests/ramble-trades.test.js:14` are the only ones — the same false-claim class as round 1's S7, caught a second time; `tests/ramble-feed.test.js` added to Task 4's run; and the `ramble-tools:177` / `ramble-panel:1227` rows moved out of Task 3's table into Task 2, where they actually break, along with `:1228`, `:1234`, `:1239` and `:1347`, all repaired at once by minting one egg in each harness's setup. + +**Kevin's ruling K6, recorded during this round.** The reviewer proved the laying floor is unreachable without walking. Kevin's decision: **leave it — laying requires walking**, do not retune decay, chore values or `lay.days`, and let the queued pedometer arc (after phase 4) supply the missing indoor energy input rather than bending the curve now. The hand-back must say this plainly so it does not read as an oversight. + +**Confirmed correct in round 3:** all four AR line references and the state-contract comment at `ramble-ar.js:8`; the proposed AR test regex matches the proposed code; `tests/ramble-ar.test.js:245`/`:242` are exactly the assertions affected and `:218` is not; `hereIcon(null)` genuinely yields the plain dot and unconditional `setIcon` causes no churn or lost aria-label; the `flock.test.js:150` re-derivation; every `server.js` name and line; the N1 two-file split; all twelve N8 line references (spot-checked 12 of 12); that gifts and swaps provably cannot empty the incubating slot; and all four hard constraints, with `init-tables.js`'s comment sitting below every line the plan cites so no reference moves. + +**Line-number drift noted, cosmetic, not corrected in the task tables:** `tests/ramble-eggs.test.js` `:49→:50`, `:71→:73`, `:82→:84`; `flock.js:126→:125`; `init-tables.js:105→:104`; `tests/ramble-flock.test.js:174→:173`. Implementers should grep for the quoted assertion text rather than trusting a line number — which is the right habit anyway on a branch that rewrites these files. From ea94b257ee244fbae5fee580b21c465a99fb61af Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:30:09 -0500 Subject: [PATCH 05/20] plan: revert the trades promote, offer the waiting egg instead --- .../plans/2026-09-09-ramble-eggs-phase3.md | 212 ++++++++++++------ 1 file changed, 146 insertions(+), 66 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md index e4fee615..44085a17 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md +++ b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md @@ -85,7 +85,7 @@ A fixed constant id is also wrong: a contact could gift you *their* starter egg 1. **The starter-egg id is a random UUID gated on an empty egg table, not derived from the Crow identity.** See Finding 3 — the spec's mechanism is impossible because `crowId` is per-instance. Say this in the PR body so a reviewer reads it as a correction, not drift. 2. **`creditWarmth` still writes its ledger row when there is no egg.** See Finding 1. The spec does not describe the interaction with `feedAll`'s `shouldFeedPet` gate at all; without this, laying is unreachable. 3. **A two-instance fleet can lay two eggs from one lay, and that is accepted.** Both instances eggless, both observing the same happy days, both crossing the threshold before a sync: each writes the same `lay:` key locally (so `rowsAffected` is 1 on both) and each mints an egg with its own UUID. `applyRambleEgg` then shelves one, so the user ends with one incubating and one spare on the shelf. Making this impossible would need a deterministic egg id derived from the lay day — which reintroduces exactly the cross-user collision that rules a constant id out for the starter egg (a contact who laid on the same date could gift you a colliding row). **Ramble is installed on grackle only**, so this is currently unreachable; the graceful degradation is the existing convergence rule, and the cost of the alternative is a real collision for a hypothetical one. Revisit if a second instance ever installs the bundle. -4. **Auto-promote runs ONLY on hatch — the read paths deliberately do not promote.** Spec §4.2 says "when the incubating slot empties" without saying who notices, and two drafts of this plan had the read paths notice. Both were wrong: a write during a GET races `applyRambleEgg`'s `isUserShelve` carve-out (`instance-sync.js:855-860`), and the attempted mitigation (marking such a promote `'sync'`) launders provenance that `flock.js:126`'s nest shelf cap, `flock.js:253`'s `shelf_count`, `RAMBLE_EGG_REPROMOTE_SQL` and `static/ramble.js:1776` all read. The read-path promote is therefore deleted, and the cases it was covering are handled on **write** paths instead: `hatchIfReady`, plus `receiveTrade`/`declineSwap`/`expireTrades` in `trades.js` (Task 3 Step 5b), which is what stops a swap-locked last egg from leaving the player eggless *and* unable to lay. One case remains uncovered by design — a slot emptied by `applyRambleEgg` while the user holds only `'user'` shelf eggs, unreachable on a one-instance fleet and recovered by a manual "incubate". +4. **Auto-promote runs ONLY on hatch — the read paths deliberately do not promote.** Spec §4.2 says "when the incubating slot empties" without saying who notices, and two drafts of this plan had the read paths notice. Both were wrong: a write during a GET races `applyRambleEgg`'s `isUserShelve` carve-out (`instance-sync.js:855-860`), and the attempted mitigation (marking such a promote `'sync'`) launders provenance that `flock.js:126`'s nest shelf cap, `flock.js:253`'s `shelf_count`, `RAMBLE_EGG_REPROMOTE_SQL` and `static/ramble.js:1776` all read. The read-path promote is therefore deleted, and `hatchIfReady` is the only promoter. A draft also promoted from `trades.js`'s closing paths; that was reverted because promoting inside `expireTrades` strands an in-flight `completed` envelope and leaves the user holding **both** eggs — a free-egg race, in the phase built to remove the free egg. What remains uncovered is a gift or a freed swap egg landing while the slot is empty, and that is answered by an **affordance** rather than a promote: the Next-egg card says one is waiting and incubates it in a tap (Task 7). The player's real complaint was never "the slot is empty" — it was having no signal and no way back. --- @@ -99,11 +99,11 @@ A fixed constant id is also wrong: a contact could gift you *their* starter egg - `tests/ramble-prologue-routes.test.js` — the prologue routes, on their own virgin-db harness (Task 6). **Modify** -- `bundles/ramble/server/trades.js` — import the three lock symbols from `egg-locks.js` and re-export `isEggLocked`/`lockedEggIds` so existing consumers are untouched (Task 1); call `promoteFromShelf` from `receiveTrade`/`declineSwap`/`expireTrades` (Task 3 Step 5b). +- `bundles/ramble/server/trades.js` — import the three lock symbols from `egg-locks.js` and re-export `isEggLocked`/`lockedEggIds` so existing consumers are untouched. **Task 1 only** — no other task edits this file. - `bundles/ramble/server/eggs.js` — the bulk: rename, null tolerance, promote, laying, starter grant, prologue flags. - `bundles/ramble/server/flock.js` — drop the mint from `flockState`; import the locks from the leaf. - `bundles/ramble/server/pet.js` — call the lay-day recorder after mood is known. -- `bundles/ramble/panel/static/ramble-ar.js` — the AR renderer must not draw a phantom egg (Task 7). **`bundles/ramble/server/feed.js` is NOT modified** — an early draft had `readPet` carry the egg summary; see Task 6 Step 4 for why that is wrong. +- `bundles/ramble/panel/static/ramble-ar.js` — the AR renderer must not draw a phantom egg (Task 7); `tests/ramble-ar.test.js` extended alongside it. **`bundles/ramble/server/feed.js` is NOT modified** — an early draft had `readPet` carry the egg summary; see Task 6 Step 4 for why that is wrong. - `bundles/ramble/server/init-tables.js` — **comment only** (the `shelf_origin` comment says `'user'` must never be auto-promoted; that rule is the *sync layer's*, and phase 3 adds an app-level promote that deliberately does). - `bundles/ramble/panel/routes.js` — null-tolerant egg/pet/flock responses; the two prologue routes. - `bundles/ramble/server/server.js` — `ramble_egg_state` / `ramble_pet_state` MCP tools must agree with the HTTP routes. @@ -185,7 +185,9 @@ In `bundles/ramble/server/trades.js`: delete the local `OPEN_SQL`, `lockedEggIds ```js import { OPEN_SQL, isEggLocked, lockedEggIds } from "./egg-locks.js"; -// Re-exported so existing importers (flock.js, panel/routes.js) need no change +// Re-exported so its existing importers (flock.js:22 and +// tests/ramble-trades.test.js:14 — NOT panel/routes.js, which never imported +// them) need no change // and there is still one definition of "locked". export { isEggLocked, lockedEggIds }; ``` @@ -244,7 +246,9 @@ git commit bundles/ramble/server/egg-locks.js bundles/ramble/server/trades.js bu | `tests/ramble-panel.test.js:609` | `after.egg.warmth === before.egg.warmth + 15` | | `tests/ramble-tools.test.js:132-133, 160, 167, 225` | `payload.egg.percent`, `after.egg.warmth - before.egg.warmth`, `state.egg.percent`, `eggAfter.egg.warmth` | -Give each fixture an explicit egg with `mintIncubatingEgg` — these tests are about warmth accrual, not about egg supply, so an explicit fixture is the right repair rather than weakening the assertion. (`tests/ramble-panel.test.js:911` asserts the source literal `"eggPercent = pet.egg.percent"`, which Task 7's `paintPet` rewrite deletes — that one belongs to Task 7, and is listed there.) +Give each fixture an explicit egg with `mintIncubatingEgg` — these tests are about warmth accrual, not about egg supply, so an explicit fixture is the right repair rather than weakening the assertion. Add `tests/ramble-panel.test.js:1347` to that list too: it dereferences `(await req("/api/ramble/egg")).json()).egg.egg_id` and so breaks here. + +Two that look like they belong here but do NOT: `tests/ramble-panel.test.js:911` asserts the source literal `"eggPercent = pet.egg.percent"`, which only Task 7's `paintPet` rewrite deletes; and the whole flock family (`tests/ramble-tools.test.js:177`, `tests/ramble-panel.test.js:1227/1228/1234/1239`) survives this task, because Task 2 only RENAMES `flock.js`'s mint — `flockState` keeps minting until Task 3 Step 5. **Interfaces:** - Produces: `mintIncubatingEgg(db, { now, emit }) -> Promise` (renamed from `ensureIncubatingEgg`, body unchanged), `getIncubatingEgg(db) -> Promise` (now exported). @@ -489,9 +493,12 @@ node scripts/run-suite.mjs tests/ramble-eggs.test.js node scripts/run-suite.mjs tests/ramble-panel.test.js node scripts/run-suite.mjs tests/ramble-tools.test.js node scripts/run-suite.mjs tests/ramble-feed.test.js +node scripts/run-suite.mjs tests/ramble-trades.test.js +node scripts/run-suite.mjs tests/ramble-flock.test.js +node scripts/run-suite.mjs tests/ramble-sync.test.js ``` -Expected: all PASS, after the fixtures in the table above have been given explicit eggs. `tests/ramble-eggs.test.js` has a test asserting two `mintIncubatingEgg` calls return the same egg — that still holds. **Do not move on with any of these red**; the suite must be green at the end of every task, not only at the end of the phase. +Expected: all PASS, after the fixtures in the table above have been given explicit eggs. The last three are the `sed` targets — a rename this task commits but does not re-run would surface a task later, which is exactly the attribution mess this plan has already had to untangle twice. `tests/ramble-eggs.test.js` has a test asserting two `mintIncubatingEgg` calls return the same egg — that still holds. **Do not move on with any of these red**; the suite must be green at the end of every task, not only at the end of the phase. - [ ] **Step 8: Commit** @@ -522,13 +529,16 @@ git commit bundles/ramble/server/eggs.js tests/ramble-eggs-supply.test.js tests/ | `tests/ramble-eggs.test.js:82` | `before.egg.warmth` after a hatch | same | | `tests/ramble-flock.test.js:150` | *"a successor egg was minted"* | **NOT "the slot is empty"** — the shelf is not empty there. The test parks egg `E` as `'user'` via `incubateEgg`, then `hot` hatches and `promoteFromShelf` draws `E` straight back in, so the count stays 1. Assert `getIncubatingEgg(d).egg_id === E` and retitle to *"the parked egg is promoted back into the slot"*. | | `tests/ramble-flock.test.js:172` | *"the incubating egg is ensured and listed first"* | mint one explicitly as a fixture, then assert ordering | -**`tests/ramble-tools.test.js:177` and `tests/ramble-panel.test.js:1227` are NOT in this table** — nothing mints from Task 2 onward, so they go red there, along with `ramble-panel.test.js:1228`, `:1234`, `:1239` and `:1347`. Repair them in Task 2 by minting one incubating egg in each harness's setup, which fixes the whole family at once. +| `tests/ramble-tools.test.js:177` | `s.eggs[0].status === "incubating"` off the flock tool | mint a fixture | +| `tests/ramble-panel.test.js:1227, 1228, 1234, 1239` | the flock family — the file header (`:53`) says it "churns hatches and later asserts an incubating egg exists" | give the fixture an explicit egg | + +**⚠ These belong HERE, in Task 3, not in Task 2.** A round-3 edit moved them to Task 2 reasoning that "nothing mints from Task 2 onward" — that is false, because Task 2 only RENAMES `flock.js`'s mint; `flockState` keeps minting until Task 3 Step 5 removes it, and every one of these assertions sits downstream of a `GET /api/ramble/flock` or `ramble_flock` call. `tests/ramble-panel.test.js:1347` is different — it dereferences `GET /api/ramble/egg`, so it really does break at Task 2 and is listed there. `tests/ramble-flock.test.js:174` and `tests/ramble-panel.test.js:1226/1234` were red in a draft where `flockState` promoted on read. It no longer does (see `promoteFromShelf`'s note), so they are unaffected — **verify that rather than assuming it**, since it is the kind of claim this plan has already got wrong twice. **Interfaces:** -- Produces: `promoteFromShelf(db, { now, emit }) -> Promise` — promotes the oldest non-locked `shelf`/`received` egg into the incubating slot, or returns null. -- Consumed by: **`hatchIfReady` only.** No read path calls it — not `eggState`, not `flockState`, not `petState`, not any route. +- Produces: `nextPromotable(db) -> Promise<{egg_id}|null>` (a pure read: the egg that would be promoted) and `promoteFromShelf(db, { now, emit }) -> Promise` (which uses it and writes). +- Consumed by: **`hatchIfReady` only** — one call site in the whole codebase. No read path calls it (not `eggState`, `flockState`, `petState` or any route), and neither does `trades.js`; see the docstring for why the trade paths use an affordance instead. **The ordering rule, which is the whole design:** both instances must pick the **same** egg with no round trip. The order is `created_at ASC, egg_id ASC` — a total order and a pure function of replicated rows, exactly the order `RAMBLE_EGG_REPROMOTE_SQL` already uses for the sync layer's own re-promote. @@ -539,7 +549,7 @@ git commit bundles/ramble/server/eggs.js tests/ramble-eggs-supply.test.js tests/ Append to `tests/ramble-eggs-supply.test.js`: ```js -import { promoteFromShelf, hatchIfReady } from "../bundles/ramble/server/eggs.js"; +import { promoteFromShelf, nextPromotable, hatchIfReady } from "../bundles/ramble/server/eggs.js"; import { flockState } from "../bundles/ramble/server/flock.js"; async function shelveEgg(db, eggId, createdAt, { status = "shelf", origin = "user" } = {}) { @@ -624,10 +634,10 @@ test("promoteFromShelf SKIPS an egg spoken for by an open swap", async () => { test("an EXPIRED but unswept trade still locks its egg — do not 'fix' the predicate", async () => { // expireTrades runs on the 15 s drain tick, so there is a window where a - // lapsed offer is still 'proposed' and its egg stays locked. Recovery is - // expireTrades itself, which calls promoteFromShelf (Task 3 Step 5b) — NOT - // a later read, which no longer promotes. Pinned here so nobody widens - // OPEN_SQL to "fix" the window. + // lapsed offer is still 'proposed' and its egg stays locked. Once it + // expires the egg is promotable again, but nothing auto-promotes it — the + // panel offers it instead (Task 7's "one's waiting on your shelf"). Pinned + // here so nobody widens OPEN_SQL to "fix" the window. const db = await freshDb(); await shelveEgg(db, "only-one", T0); await db.execute({ @@ -639,6 +649,21 @@ test("an EXPIRED but unswept trade still locks its egg — do not 'fix' the pred assert.equal(await promoteFromShelf(db, { now: T0 + 5000 }), null); }); +test("nextPromotable answers the same question the promote acts on, and writes nothing", async () => { + const db = await freshDb(); + assert.equal(await nextPromotable(db), null); + await shelveEgg(db, "younger", T0 + 5000); + await shelveEgg(db, "older", T0); + + const peek = await nextPromotable(db); + assert.equal(peek.egg_id, "older"); + assert.equal((await statusOf(db, "older")).status, "shelf", "a peek must not move it"); + + const promoted = await promoteFromShelf(db, { now: T0 + 9000 }); + assert.equal(promoted.egg_id, peek.egg_id, "the card and the promote read ONE rule"); + assert.equal(await nextPromotable(db), null, "the slot is full now"); +}); + test("promoteFromShelf is a NO-OP when the slot is full, and when there is nothing to promote", async () => { const db = await freshDb(); assert.equal(await promoteFromShelf(db, { now: T0 }), null, "empty shelf, empty slot"); @@ -753,29 +778,42 @@ Then: * the 'user' mark exists to protect it from, and is mislabelled "came * back from another of your Crows" at `static/ramble.js:1776`. * - * Deleting the read-path call resolves both. What it does NOT do is cover - * every emptier by itself, so this function is also called from the - * trade-closing WRITE paths in `trades.js` (see below). Honest inventory: + * Honest inventory of every way the slot can empty, and what covers it: * - * - a hatch -> covered here + * - a hatch -> covered HERE, and this is the main loop * - `incubateEgg` swap -> never empties the slot (one conditional * UPDATE), and it ends in `hatchIfReady` * - gifting / swapping away -> the incubating egg is not giftable * (`GIFTABLE = {shelf, received}`) - * - a swap or gift ARRIVING while the user is wholly eggless - * -> covered by the trades.js calls - * - a swap expiring or being declined, unlocking the last shelf egg - * -> covered by the trades.js calls + * - a gift or swap ARRIVING, or a swap expiring/declining and unlocking + * the last shelf egg, while the slot is empty + * -> NOT auto-promoted. The egg sits on the + * shelf and the panel says so, with a button + * that incubates it in one tap (Task 7). * - a slot emptied by `applyRambleEgg` while the user holds ONLY 'user' - * shelf eggs -> NOT covered. `RAMBLE_EGG_REPROMOTE_SQL` drafts - * `shelf_origin='sync'` rows only. Unreachable on a one-instance fleet; - * recovered by a manual "incubate". Do not "fix" it by promoting on a - * read — that is what this note exists to prevent. + * shelf eggs -> same: `RAMBLE_EGG_REPROMOTE_SQL` drafts + * `shelf_origin='sync'` rows only. + * + * ⚠ An earlier draft promoted from `trades.js`'s closing paths to auto-cover + * rows 4 and 5. It was reverted: promoting inside `expireTrades` strands an + * in-flight `completed` envelope — the hand-over UPDATE (`WHERE status IN + * ('shelf','received')`) then matches nothing while `receivedEggStatement` + * still inserts, so the user keeps BOTH eggs. Manufacturing a free-egg race + * in the phase whose whole purpose is removing the free egg is not a trade + * worth making, and the underlying complaint was never "the slot is empty" — + * it was "the player has no signal and no way back". That is an affordance + * problem, and it is fixed with an affordance. */ -export async function promoteFromShelf(db, { now, emit } = {}) { - void now; +/** + * The egg that WOULD be promoted, or null — a pure read, no writes. + * + * Extracted so the promote and the panel's "one's waiting on your shelf" card + * read exactly ONE rule. A card that offers an egg the promote would not take + * (or the reverse) is the map/payout split this project has already had to + * close once in phase 1. + */ +export async function nextPromotable(db) { if (await getIncubatingEgg(db)) return null; - const locked = await lockedEggIds(db); const { rows } = await db.execute({ sql: `SELECT egg_id FROM ramble_eggs @@ -783,7 +821,12 @@ export async function promoteFromShelf(db, { now, emit } = {}) { ORDER BY created_at ASC, egg_id ASC`, args: [], }); - const next = rows.find((r) => !locked.has(r.egg_id)); + return rows.find((r) => !locked.has(r.egg_id)) ?? null; +} + +export async function promoteFromShelf(db, { now, emit } = {}) { + void now; + const next = await nextPromotable(db); if (!next) return null; // Guarded exactly like mintIncubatingEgg: the "one incubating egg" rule is @@ -830,33 +873,6 @@ In `bundles/ramble/server/flock.js`, delete `await ensureIncubatingEgg(db, { now Update the import: drop `ensureIncubatingEgg`. **Do not add `promoteFromShelf`** — `flock.js` no longer needs it. -- [ ] **Step 5b: Promote from the trade-closing paths, or a swap can deadlock the game** - -Without this there is a real trap, and it is not merely cosmetic. Suppose the user offers their **only** shelf egg in a swap and then hatches: - -- the slot empties, and `promoteFromShelf` skips the locked egg, so nothing refills it; -- `hasAnyEggAnywhere` counts `shelf`/`received`, so the locked egg **also blocks laying**; -- warmth vanishes (D3) with nothing to land in; -- and once the swap expires or is declined, **nothing calls `promoteFromShelf` again**. The read paths no longer do, and there is no egg left to hatch. - -The player is stuck until they happen to open the flock screen and tap "incubate", with no signal that they should. These are **write** paths, so none of the read-path objections apply — no GET is queuing a sync op, and there is no drain race to lose. `trades.js` already imports from `eggs.js` (`:32`), so there is no cycle. - -In `bundles/ramble/server/trades.js`, extend the existing import and call the promote at the end of each path that can free or deliver an egg — `receiveTrade` (`:213`), `declineSwap` (`:187`) and `expireTrades` (`:306`): - -```js -import { startOfLocalDay, promoteFromShelf } from "./eggs.js"; - -// …at the end of receiveTrade, declineSwap and expireTrades, after their -// existing writes and emits: - // Phase 3 (spec §4.2): closing a trade can free the last shelf egg, or - // deliver one, while the incubating slot sits empty. These are writes, not - // reads, so promoting here is safe — and without it a user whose only shelf - // egg was locked in a swap is left eggless AND unable to lay. - await promoteFromShelf(db, { now, emit }); -``` - -`promoteFromShelf` writes nothing when the slot is occupied or nothing is promotable, so adding it to a path that did not need it is a no-op. - - [ ] **Step 6: Correct the stale comment in `init-tables.js` — COMMENT ONLY** The `shelf_origin` comment currently reads "and it must NEVER be auto-promoted". Phase 3 adds an auto-promote that deliberately does. Leaving it would make a reader conclude the new promote is a bug — the phase 2 lesson about a wrong-but-checkable justification, in reverse. Change that clause to: @@ -1509,8 +1525,8 @@ git commit bundles/ramble/server/eggs.js tests/ramble-prologue.test.js \ - **NOT modified: `bundles/ramble/server/feed.js`** — see Step 4. **Interfaces:** -- `GET /api/ramble/egg` -> `{ egg: null | {...}, checklist: {...}, lay: { days, needed } }` -- `GET /api/ramble/pet` -> adds `pet.egg` (`null` or `{ percent, ... }`) and `pet.lay` +- `GET /api/ramble/egg` -> `{ egg: null | {...}, checklist: {...}, lay: { days, needed }, shelf_waiting: eggId|null }` +- `GET /api/ramble/pet` -> adds `pet.egg` (`null` or `{ percent, ... }`), `pet.lay`, and `pet.shelf_waiting` (an `egg_id` or null) - `GET /api/ramble/prologue` -> `{ intro_seen, hatch_seen, granted }` - `POST /api/ramble/prologue/intro` -> `{ egg: row|null, intro_seen: true }` — grants and flags - `POST /api/ramble/prologue/hatch` -> `{ hatch_seen: true }` @@ -1526,7 +1542,7 @@ Put them in a **new file `tests/ramble-prologue-routes.test.js`**. **⚠ A per-test `createClient` handle is NOT achievable — do not try.** `rambleRouter(dashboardAuth, options)` (`routes.js:176`) takes only an injected `emit`; the db is created inside the closure at `:230` via `mods.dbMod.createDbClient()`, which resolves `CROW_DB_PATH` → `CROW_DATA_DIR/crow.db` (`server/db.js:124`) and is then memoized for the router's lifetime. There is no seam to hand it an in-memory client. -What these tests actually need is not per-test isolation but **one virgin db**, which the existing harness gives for free. Copy the setup at `tests/ramble-panel.test.js:28-44`: `mkdtemp` → set `CROW_APP_ROOT` and `CROW_DATA_DIR` → dynamic `import()` of `routes.js` → express app → `once(server, "listening")` → a `req()` helper. Keep one db for the file and rely on declaration order: tests 1 and 2 assert `egg: null` on a virgin db, test 3 grants, test 4 is order-independent. Nothing on these four routes mints, and none of them feeds — `recordHappyDay` is called only from `pet.js:feed` — so no `layday` row can appear either. +What these tests actually need is not per-test isolation but **one virgin db**, which the existing harness gives for free. Copy that file's harness, which is spread across four places, not one range: `:17-27` (node imports, `REPO_ROOT`, `SCRATCH`), `:29-46` (`mkdtemp`, `CROW_APP_ROOT`/`CROW_DATA_DIR`, the dynamic `import()` of `routes.js`), `:109-118` (router → express app → `listen` → `once(server, "listening")`), `:161` (`req()`), and **`:146-153` (`after()` — close the server, restore env, `rmSync` the scratch dir)**. The `after()` block is not optional: without it the new file leaks a listening socket and a temp directory. Keep one db for the file and rely on declaration order: tests 1 and 2 assert `egg: null` on a virgin db, test 3 grants, test 4 is order-independent. Nothing on these four routes mints, and none of them feeds — `recordHappyDay` is called only from `pet.js:feed` — so no `layday` row can appear either. (If genuine per-test isolation is ever needed it takes a fresh `CROW_DB_PATH` **plus** a fresh `rambleRouter()` and app per test, not a client handle.) @@ -1594,7 +1610,8 @@ Expected: FAIL — the prologue routes 404 and `lay` is absent. // launders shelf_origin. hatchIfReady is the only local emptier. const state = await mods.eggsMod.eggState(db, { now: Date.now() }); const lay = await mods.eggsMod.layProgress(db); - res.json({ ...state, lay }); + const waiting = await mods.eggsMod.nextPromotable(db); + res.json({ ...state, lay, shelf_waiting: waiting ? waiting.egg_id : null }); })); router.get("/api/ramble/prologue", handle(async (req, res) => { @@ -1637,11 +1654,15 @@ where `egg` is `await mods.eggsMod.eggState(db, { now })` (line 812). The moment ```js const eggSummary = await mods.eggsMod.eggState(db, { now }); const lay = await mods.eggsMod.layProgress(db); + // The egg sitting on the shelf that a tap would incubate, or null. Nothing + // auto-promotes it (see promoteFromShelf's note), so the card offers it. + const waiting = await mods.eggsMod.nextPromotable(db); res.json({ ...pet, bird, // null when genuinely eggless — paintPet must not assume one exists. egg: eggSummary.egg ?? null, + shelf_waiting: waiting ? waiting.egg_id : null, lay, seed: await mods.walletMod.seedBalance(db), hearts: await mods.heartsMod.heartsBalance(db), @@ -1712,7 +1733,8 @@ git commit bundles/ramble/panel/routes.js bundles/ramble/server/server.js \ |---|---|---| | Egg view line | unchanged | "No one on the way just now." | | Egg view sub-line | unchanged | "Nests hold them. So do friends." | -| Next egg card | percent + ring | "Nothing warming just now." + the lay line | +| Next egg card, nothing anywhere | percent + ring | "Nothing warming just now." + the lay line | +| Next egg card, **one waiting on the shelf** | — | "One's waiting on your shelf." + a **Warm it** button — the lay line is hidden, because you are not eggless | | Lay line, 0 days | — | "Keep yourself happy and you'll manage one yourself, in time." | | Lay line, N days | — | "You've had N good days — keep it up and you'll manage one yourself." | | Perch status line | "Your egg is N% warm." | omit the sentence entirely | @@ -1722,6 +1744,18 @@ git commit bundles/ramble/panel/routes.js bundles/ramble/server/server.js \ **S2 — the check-in must stop lying.** `static/ramble.js:1244` says *"Checked in. That is today's warmth."* With no egg, `credited` is true but the warmth vanished (D3). K4 requires the eggless state to be legible, and this is the one screen that would actively contradict it. Branch the message on whether an egg exists. +**The Warm it button** reuses the shipped incubate endpoint — do not invent one. `static/ramble.js:1796` already has `incubate(egg, btn)` posting to `/api/ramble/eggs//incubate`; declare `var lastWaitingEggId = null;` beside the other module vars and wire the button to it: + +```js + var warmBtn = $("rb-nextegg-warm"); + if (warmBtn) warmBtn.addEventListener("click", function () { + if (!lastWaitingEggId) return; + incubate({ egg_id: lastWaitingEggId }, warmBtn); + }); +``` + +`incubate` takes an object with `egg_id` and a button, and already refreshes on success. + **S3 — the perch door keeps an affordance.** An earlier draft relabelled both the marker's `aria-label` and the world view's GPS-independent button to bare `"You"`. That is inside K5 but strips the button of any sense of where it goes. Use `"How you're doing"` for the button and `"You"` for the marker's `aria-label`. - [ ] **Step 1: Write the failing panel-source tests** @@ -1736,6 +1770,17 @@ test("the Next egg card is never hidden — it is the only route to the check-in assert.ok(src.includes("rb-nextegg-empty"), "it changes state instead"); }); +test("a shelf egg waiting for an empty slot is offered, not hidden", () => { + const shell = readFileSync("bundles/ramble/panel/ramble.js", "utf8"); + assert.ok(shell.includes("waiting on your shelf")); + assert.ok(shell.includes('id="rb-nextegg-warm"'), "and a one-tap way to act on it"); + + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.ok(src.includes("lastWaitingEggId"), "wired to the shipped incubate endpoint"); + // The lay line must not claim you are eggless while an egg sits on the shelf. + assert.ok(/setHidden\(\$\("rb-nextegg-lay"\)|!!waiting/.test(src)); +}); + test("the eggless copy is present and written from inside the premise", () => { // ⚠ TWO FILES. Static copy lives in the server-rendered shell; only strings // the client BUILDS live in the client. An earlier draft asserted both @@ -1782,6 +1827,8 @@ New *markup* goes in `bundles/ramble/panel/ramble.js`, not the client — that i ```html + + ``` And on the egg view, after `#rb-egg-line`, add: @@ -1836,11 +1883,20 @@ In `bundles/ramble/panel/static/ramble.js`, `paintEgg`: if (nextArt) { setHidden(nextArt, !hasNext); if (hasNext) drawEggArt(nextArt, nextEgg.egg_id); } setHidden($("rb-nextegg-empty"), hasNext); + /* Nothing auto-promotes a shelf egg into an empty slot (see the server's + * promoteFromShelf note), so offer it here rather than leaving the player + * with no signal and no way back. It also means they are NOT eggless, so + * the lay line must not claim they are. */ + var waiting = pet.shelf_waiting || null; + setHidden($("rb-nextegg-waiting"), hasNext || !waiting); + setHidden($("rb-nextegg-warm"), hasNext || !waiting); + lastWaitingEggId = waiting; + var lay = pet.lay || null; var layEl = $("rb-nextegg-lay"); if (layEl) { - setHidden(layEl, hasNext || !lay); - if (!hasNext && lay) { + setHidden(layEl, hasNext || !lay || !!waiting); + if (!hasNext && lay && !waiting) { setText(layEl, lay.days > 0 ? "You've had " + lay.days + " good " + (lay.days === 1 ? "day" : "days") + " — keep it up and you'll manage one yourself." @@ -1896,7 +1952,8 @@ A `null` skips `setIcon` **entirely**, so the marker keeps whatever icon it last ```js // 1. static/ramble.js:2125 — the panel supplies the flag - arSession.render({ /* …existing fields… */, hasEgg: !!eggSeedId }); + arSession.render({ anchors: anchors, pose: arPose, bird: arBirdState(), + camera: arCamera, hasEgg: !!eggSeedId }); // 2. static/ramble-ar.js:228 — carry it onto the frame, beside `bird` bird: s.bird || null, @@ -1930,7 +1987,7 @@ And the check-in confirmation at `static/ramble.js:1243-1246` (S2). **⚠ There ```js // routes.js, POST /api/ramble/egg/checkin — add to the res.json body: - egg: (await mods.eggsMod.getIncubatingEgg(db)) ? true : false, + egg: !!(await mods.eggsMod.getIncubatingEgg(db)), ``` and list it in Task 6's interfaces as `POST /api/ramble/egg/checkin -> { credited, warmth, hatched, egg }`. Then the client can branch honestly: @@ -2464,3 +2521,26 @@ DELETE FROM ramble_settings WHERE key LIKE 'prologue.%'; -- both dismissal flag **Confirmed correct in round 3:** all four AR line references and the state-contract comment at `ramble-ar.js:8`; the proposed AR test regex matches the proposed code; `tests/ramble-ar.test.js:245`/`:242` are exactly the assertions affected and `:218` is not; `hereIcon(null)` genuinely yields the plain dot and unconditional `setIcon` causes no churn or lost aria-label; the `flock.test.js:150` re-derivation; every `server.js` name and line; the N1 two-file split; all twelve N8 line references (spot-checked 12 of 12); that gifts and swaps provably cannot empty the incubating slot; and all four hard constraints, with `init-tables.js`'s comment sitting below every line the plan cites so no reference moves. **Line-number drift noted, cosmetic, not corrected in the task tables:** `tests/ramble-eggs.test.js` `:49→:50`, `:71→:73`, `:82→:84`; `flock.js:126→:125`; `init-tables.js:105→:104`; `tests/ramble-flock.test.js:174→:173`. Implementers should grep for the quoted assertion text rather than trusting a line number — which is the right habit anyway on a branch that rewrites these files. + +--- + +### Fourth review (2026-09-09), scoped to round 3's fixes + +**Verdict: REVISE**, and all five criticals landed on **one** fix — round 3's Task 3 Step 5b, the `trades.js` promote. T1, T2, T3, T4 and T5 were each verified genuinely fixed. The reviewer also walked all nine tasks and confirmed the suite is green at the end of every one of them except Task 3. + +**Step 5b is reverted, not repaired.** Its five defects were: `trades.js` was never added to Task 3's Files or its commit (the never-committed-edit failure, for the third round running); `receiveTrade` has no single "end" — it is an `if/return` chain whose textual tail is the `'expired'` no-op path, so the prescribed call would have run on nothing; it took `tests/ramble-trades.test.js` and `tests/ramble-transport.test.js` red across ten assertions, and `ramble-transport.test.js` appears **nowhere** in this plan; plain gifts go through `receiveGift`, not `receiveTrade`, so the docstring's claim that gifts were covered was false; and — decisively — **promoting inside `expireTrades` strands an in-flight `completed` envelope**: the hand-over `UPDATE … WHERE status IN ('shelf','received')` then matches nothing while `receivedEggStatement` still inserts, so the user keeps **both** eggs. + +Manufacturing a free-egg race in the phase built to remove the free egg is not a trade worth making. And the underlying complaint was never "the slot is empty" — it was that the player had **no signal and no way back**. That is an affordance problem, so it now gets an affordance: + +- `nextPromotable(db)` is extracted as a pure read, and `promoteFromShelf` uses it — so the card and the promote read exactly **one** rule, with a test asserting they agree. (This is the map/payout discipline phase 1 had to learn.) +- `GET /api/ramble/egg` and `GET /api/ramble/pet` carry `shelf_waiting`. +- The Next-egg card says **"One's waiting on your shelf."** with a **Warm it** button wired to the already-shipped `incubate()` / `POST /api/ramble/eggs/:id/incubate` — no new endpoint. +- The lay line is suppressed while an egg waits, because the player is not eggless and it must not say they are. + +`promoteFromShelf` is once again called from exactly one place, `hatchIfReady`, which is what its docstring and interface block always claimed. + +**Round 4's secondaries, all applied:** Task 2 Step 7 now re-runs the three `sed`-touched suites it commits; the flock-family assertions (`ramble-tools:177`, `ramble-panel:1227/1228/1234/1239`) moved **back** to Task 3, because round 3's move to Task 2 rested on "nothing mints from Task 2 onward" and that is false — Task 2 only renames `flock.js`'s mint, and `flockState` keeps minting until Task 3 Step 5 (`ramble-panel:1347` genuinely does break at Task 2 and stays there); Task 1's re-export comment corrected for the **third** time, having been flagged in rounds 1, 3 and 4 while sitting three lines above the warning that corrects it; `tests/ramble-ar.test.js` added to the global File-structure list; the two divergent spellings of the check-in `egg` line collapsed to one; the AR `render({…})` snippet's leading-comma syntax error fixed and its fields spelled out; and T3's harness citation corrected from a single wrong range to the four real ones, including the `after()` teardown without which the new test file leaks a socket and a temp dir. + +**Confirmed fixed in round 4:** T1 (`flock.js` in Task 2's Files, `sed` list and commit — and Task 2 legitimately ends green with `flock.js` still minting, since the purity test arrives in Task 3); T2 (the new test file wired into all five places); T3 (the "declaration order is enough" reasoning verified true — none of the four routes mints or feeds, and `recordHappyDay` is reachable only from `pet.js:feed`); T4 (route edit homed in Task 6 with an interface line; `$("rb-egg-status")` and `out` verified correct; the three-way branch preserves the not-credited arm); T5 (no stale invitation, correct deviation number). Also re-verified: `routes.js:816`, `server.js:30/306/319`, the `eggs.js` and `pet.js` line cites, `hereArt`/`paintHereArt`, and that `egg-locks.js` trips neither `bundle-server-deps` nor `bundle-contract`. + +**Superseded:** the round-3 record's T6 row describes Step 5b as the resolution. It is not — Step 5b was reverted here. Read this section, not that row. From 3262a3a7d4f33ed281f543e2bcb8b2d05f1de347 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:38:17 -0500 Subject: [PATCH 06/20] plan: fix four issues from the fifth review, close the review --- .../plans/2026-09-09-ramble-eggs-phase3.md | 82 +++++++++++++++++-- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md index 44085a17..19395909 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md +++ b/docs/superpowers/plans/2026-09-09-ramble-eggs-phase3.md @@ -832,11 +832,16 @@ export async function promoteFromShelf(db, { now, emit } = {}) { // Guarded exactly like mintIncubatingEgg: the "one incubating egg" rule is // a query against the table's contents, not a schema constraint, so two // overlapping promotes must not both succeed. + // The lock is re-checked in SQL, not only in nextPromotable's JS filter, so + // this matches incubateEgg's own guard (flock.js:189-192) exactly and a swap + // opened between the peek and the write cannot slip through. const { rowsAffected } = await db.execute({ sql: `UPDATE ramble_eggs SET status = 'incubating', shelf_origin = NULL WHERE egg_id = ? AND status IN ('shelf', 'received') - AND NOT EXISTS (SELECT 1 FROM ramble_eggs WHERE status = 'incubating')`, - args: [next.egg_id], + AND NOT EXISTS (SELECT 1 FROM ramble_eggs WHERE status = 'incubating') + AND NOT EXISTS (SELECT 1 FROM ramble_trades + WHERE my_egg_id = ? AND state IN ('proposed', 'accepted'))`, + args: [next.egg_id, next.egg_id], }); if (rowsAffected === 0) return null; @@ -1525,7 +1530,7 @@ git commit bundles/ramble/server/eggs.js tests/ramble-prologue.test.js \ - **NOT modified: `bundles/ramble/server/feed.js`** — see Step 4. **Interfaces:** -- `GET /api/ramble/egg` -> `{ egg: null | {...}, checklist: {...}, lay: { days, needed }, shelf_waiting: eggId|null }` +- `GET /api/ramble/egg` -> `{ egg: null | {...}, checklist: {...}, lay: { days, needed } }` — deliberately NO `shelf_waiting`: the pet card owns that affordance - `GET /api/ramble/pet` -> adds `pet.egg` (`null` or `{ percent, ... }`), `pet.lay`, and `pet.shelf_waiting` (an `egg_id` or null) - `GET /api/ramble/prologue` -> `{ intro_seen, hatch_seen, granted }` - `POST /api/ramble/prologue/intro` -> `{ egg: row|null, intro_seen: true }` — grants and flags @@ -1542,7 +1547,9 @@ Put them in a **new file `tests/ramble-prologue-routes.test.js`**. **⚠ A per-test `createClient` handle is NOT achievable — do not try.** `rambleRouter(dashboardAuth, options)` (`routes.js:176`) takes only an injected `emit`; the db is created inside the closure at `:230` via `mods.dbMod.createDbClient()`, which resolves `CROW_DB_PATH` → `CROW_DATA_DIR/crow.db` (`server/db.js:124`) and is then memoized for the router's lifetime. There is no seam to hand it an in-memory client. -What these tests actually need is not per-test isolation but **one virgin db**, which the existing harness gives for free. Copy that file's harness, which is spread across four places, not one range: `:17-27` (node imports, `REPO_ROOT`, `SCRATCH`), `:29-46` (`mkdtemp`, `CROW_APP_ROOT`/`CROW_DATA_DIR`, the dynamic `import()` of `routes.js`), `:109-118` (router → express app → `listen` → `once(server, "listening")`), `:161` (`req()`), and **`:146-153` (`after()` — close the server, restore env, `rmSync` the scratch dir)**. The `after()` block is not optional: without it the new file leaks a listening socket and a temp directory. Keep one db for the file and rely on declaration order: tests 1 and 2 assert `egg: null` on a virgin db, test 3 grants, test 4 is order-independent. Nothing on these four routes mints, and none of them feeds — `recordHappyDay` is called only from `pet.js:feed` — so no `layday` row can appear either. +What these tests actually need is not per-test isolation but **one virgin db**, which the existing harness gives for free. Copy that file's harness, which is spread across four places, not one range: `:17-27` (node imports, `REPO_ROOT`, `SCRATCH`), `:29-46` (`mkdtemp`, `CROW_APP_ROOT`/`CROW_DATA_DIR`, the dynamic `import()` of `routes.js`), `:109-118` (router → express app → `listen` → `once(server, "listening")`), `:161` (`req()`), and **`:146-153` (`after()` — close the server, restore env, `rmSync` the scratch dir)**. The `after()` block is not optional: without it the new file leaks a listening socket and a temp directory. + +**Grep for the code, do not trust these line numbers** — several are off by one or two (`mkdtempSync` is at `:28`, `const BASE` at `:119`, `req()` at `:161` depends on `realFetch` at `:159`, and `after()` runs `:146-154`). Every omission is an instant `ReferenceError`, so copy by symbol, not by range. Keep one db for the file and rely on declaration order: tests 1 and 2 assert `egg: null` on a virgin db, test 3 grants, test 4 is order-independent. Nothing on these four routes mints, and none of them feeds — `recordHappyDay` is called only from `pet.js:feed` — so no `layday` row can appear either. (If genuine per-test isolation is ever needed it takes a fresh `CROW_DB_PATH` **plus** a fresh `rambleRouter()` and app per test, not a client handle.) @@ -1610,8 +1617,12 @@ Expected: FAIL — the prologue routes 404 and `lay` is absent. // launders shelf_origin. hatchIfReady is the only local emptier. const state = await mods.eggsMod.eggState(db, { now: Date.now() }); const lay = await mods.eggsMod.layProgress(db); - const waiting = await mods.eggsMod.nextPromotable(db); - res.json({ ...state, lay, shelf_waiting: waiting ? waiting.egg_id : null }); + res.json({ ...state, lay }); + // NOTE: no `shelf_waiting` here. The waiting egg is offered on the PET + // card (Task 7), which reads GET /api/ramble/pet; adding it to this route + // too would give the egg view a field it never paints, and the two + // surfaces would then disagree — the egg view saying "No one on the way" + // while the pet card offers one. One surface owns this affordance. })); router.get("/api/ramble/prologue", handle(async (req, res) => { @@ -1690,6 +1701,20 @@ return text(JSON.stringify({ **And `ramble_egg_state` at `:319`** still returns `{ egg, checklist }` with no `lay`, so the tool and `GET /api/ramble/egg` would disagree — the exact divergence this step exists to prevent. Add `lay` there too. +**⚠ Both tools also need `shelf_waiting`**, which Step 3 adds to both routes. Leaving it off reproduces the very divergence this step exists to prevent, one field later. So the import at `:30` is: + +```js +import { eggState, activeBird, isoWeek, layProgress, nextPromotable } from "./eggs.js"; +``` + +and both payloads carry: + +```js + shelf_waiting: (await nextPromotable(db))?.egg_id ?? null, // ramble_pet_state only +``` + +(`ramble_egg_state` gets `lay` but NOT `shelf_waiting`, matching its route.) + A tool and a route that disagree about the same egg is the defect phase 2 caught late. After patching, grep for any other dereference of `.egg.` that assumes non-null: ```bash @@ -1737,7 +1762,7 @@ git commit bundles/ramble/panel/routes.js bundles/ramble/server/server.js \ | Next egg card, **one waiting on the shelf** | — | "One's waiting on your shelf." + a **Warm it** button — the lay line is hidden, because you are not eggless | | Lay line, 0 days | — | "Keep yourself happy and you'll manage one yourself, in time." | | Lay line, N days | — | "You've had N good days — keep it up and you'll manage one yourself." | -| Perch status line | "Your egg is N% warm." | omit the sentence entirely | +| Perch status line | "Your egg is N% warm." | fall back to the existing "Quiet around here right now." (`static/ramble.js:888`) rather than an empty line | | AR view | egg art | the egg element hidden | | Check-in confirmation | "Checked in. That is today's warmth." | "Checked in. Nothing to warm yet — but it counted." | | Map marker | walking egg | plain dot when there is no bird AND no egg | @@ -1754,6 +1779,15 @@ git commit bundles/ramble/panel/routes.js bundles/ramble/server/server.js \ }); ``` +**⚠ Give the failure somewhere to land.** `incubate`'s `.catch` calls `flockStatus(err.message)`, which writes `#rb-flock-status` — an element on the *flock* view. Tapped from the pet card, a 409 (`in-trade`, if a swap opened between the read and the tap) would re-enable the button with no message anywhere on screen. Report it on the card instead: + +```js + incubate({ egg_id: lastWaitingEggId }, warmBtn) + .catch(function (err) { setText($("rb-nextegg-waiting"), err.message); }); +``` + +Check `incubate`'s return value first — if it does not return its promise, add `return` to its `jsonFetch` chain rather than duplicating the call. + `incubate` takes an object with `egg_id` and a button, and already refreshes on success. **S3 — the perch door keeps an affordance.** An earlier draft relabelled both the marker's `aria-label` and the world view's GPS-independent button to bare `"You"`. That is inside K5 but strips the button of any sense of where it goes. Use `"How you're doing"` for the button and `"You"` for the marker's `aria-label`. @@ -1952,8 +1986,13 @@ A `null` skips `setIcon` **entirely**, so the marker keeps whatever icon it last ```js // 1. static/ramble.js:2125 — the panel supplies the flag - arSession.render({ anchors: anchors, pose: arPose, bird: arBirdState(), + arSession.render({ anchors: arAnchors, pose: arPose, bird: arBirdState(), camera: arCamera, hasEgg: !!eggSeedId }); + // ⚠ arAnchors, NOT anchors. There is no bare `anchors` binding in this file + // (the var is declared at :2053). A draft of this snippet used the wrong + // name; nothing would have caught it — ramble-ar.test.js drives the AR + // module directly and ramble-panel.test.js only greps source literals, so + // the throw stays silent inside scheduleArRender's rAF until AR is opened. // 2. static/ramble-ar.js:228 — carry it onto the frame, beside `bird` bird: s.bird || null, @@ -1972,6 +2011,12 @@ A `null` skips `setIcon` **entirely**, so the marker keeps whatever icon it last paintBird(frame.bird, frame.hasEgg); ``` +**⚠ `tests/ramble-panel.test.js:911` goes red and is THIS task's responsibility.** It asserts the source literal `"eggPercent = pet.egg.percent"` (the real line is `static/ramble.js:1343`), which this task's `paintPet` rewrite deletes. Earlier drafts said it "is listed there" while listing it nowhere. Rewrite it to track the new code, keeping the reason string: + +```js +assert.ok(body.includes("eggPercent = nextPct"), "the world view's warmth line follows the pet refresh"); +``` + **`tests/ramble-ar.test.js:245` goes red and must be updated:** it calls `session.render({ anchors, pose: pose(null), bird: null })` and then asserts `els.egg.hasAttribute("hidden") === false`. With no `hasEgg` on the state that is now hidden. Pass `hasEgg: true` there, and add a new case with `bird: null, hasEgg: false` asserting the egg IS hidden. **Do not assert the presence of a line in `startAr`** — an earlier draft of this plan did exactly that, and the assertion passed against a fix that the renderer immediately undid. The framing fix (K5), in `paintHereArt` and `paintPerchGo`: @@ -2383,7 +2428,7 @@ Fix everything it finds ON THE BRANCH before opening the PR. **Re-review every f - **No schema change, no migration, no `SCHEMA_GENERATION` bump** — laying rides `ramble_wallet`, prologue flags ride `ramble_settings`. - **Finding 1**: `feedAll` gates the pet feed on `credited`, so warmth and energy had to be decoupled or laying would be unreachable. - **Finding 3 / Deviation 1**: the spec's starter-egg race protection is impossible because `crowId` is per-instance; a random uuid plus the existing convergence rule replaces it. -- **Deviation 4**: auto-promote runs ONLY on hatch and the trade-closing writes — never on a read path — and why a read-path promote both races the sync drain and launders `shelf_origin`. +- **Deviation 4**: auto-promote runs ONLY on hatch — never on a read path and never from `trades.js`. A gift, or a swap egg freed when a trade closes, is **offered by the Next-egg card** instead. Say why both alternatives were rejected: a read-path promote races the sync drain and launders `shelf_origin`, and promoting inside `expireTrades` strands an in-flight `completed` envelope so the user keeps both eggs. - **K1**: the grant is derived from replicated egg data so a game-state reset replays the prologue. - **K5**: all copy is written from "you ARE the egg; the incubating egg is the next you". - Ramble is installed on **grackle only** — crow primary and r4 have no Ramble bundle. @@ -2544,3 +2589,22 @@ Manufacturing a free-egg race in the phase built to remove the free egg is not a **Confirmed fixed in round 4:** T1 (`flock.js` in Task 2's Files, `sed` list and commit — and Task 2 legitimately ends green with `flock.js` still minting, since the purity test arrives in Task 3); T2 (the new test file wired into all five places); T3 (the "declaration order is enough" reasoning verified true — none of the four routes mints or feeds, and `recordHappyDay` is reachable only from `pet.js:feed`); T4 (route edit homed in Task 6 with an interface line; `$("rb-egg-status")` and `out` verified correct; the three-way branch preserves the not-credited arm); T5 (no stale invitation, correct deviation number). Also re-verified: `routes.js:816`, `server.js:30/306/319`, the `eggs.js` and `pet.js` line cites, `hereArt`/`paintHereArt`, and that `egg-locks.js` trips neither `bundle-server-deps` nor `bundle-contract`. **Superseded:** the round-3 record's T6 row describes Step 5b as the resolution. It is not — Step 5b was reverted here. Read this section, not that row. + +--- + +### Fifth review (2026-09-09) — REVISE on four text edits, then execute + +The reviewer's own judgement: *"no design change is needed and no sixth review round should happen."* The revert is clean and the affordance genuinely closes round 3's hole — verified by walking the scenario against the code: while the swap is open `nextPromotable` filters the locked egg so `shelf_waiting` is correctly null and the card shows the lay line; after `expireTrades` sets `'expired'` the egg leaves `OPEN_SQL`, `shelf_waiting` goes non-null, and recovery is one tap. + +| # | Issue | Resolution | +|---|---|---| +| C1 | **Round 4's own fix introduced a `ReferenceError`.** Fixing N2's leading-comma syntax error by "spelling out the fields" mis-transcribed one: the snippet wrote `anchors: anchors`, but the only binding in the file is `arAnchors` (`:2053`). Nothing would have caught it — `ramble-ar.test.js` drives the AR module directly and `ramble-panel.test.js` only greps source literals, so it throws silently inside a rAF callback until someone opens AR. A paste-ready snippet that is wrong is worse than the obvious placeholder it replaced. | `arAnchors`, with a note saying why no test covers it. | +| C2 | The PR-body bullet still promised "auto-promote runs ONLY on hatch **and the trade-closing writes**" after that fix was reverted — so the PR would have described the branch falsely. Round 3 raised exactly this bullet as a critical; it is the third round in which a stale claim survived in prose while the code around it was corrected. | Rewritten, and it now also records why both rejected alternatives were rejected. | +| C3 | **Task 7 would have ended red.** `tests/ramble-panel.test.js:911` asserts the source literal `eggPercent = pet.egg.percent`, which Task 7's `paintPet` rewrite deletes. Round 3 wrote "and is listed there"; round 4 dropped that clause without ever adding the listing. Nobody owned it. | Listed in Task 7 with the exact replacement assertion. | +| C4 | `shelf_waiting` was added to the routes but not to the MCP tools, breaking the plan's own standing rule that `ramble_egg_state`/`ramble_pet_state` must agree with the HTTP routes — the divergence Task 6 Step 5 exists to prevent, reproduced one field later. | Added to `ramble_pet_state` with the import; scoped explicitly so `ramble_egg_state` matches its own route. | + +**Suggestions adopted:** `shelf_waiting` **dropped** from `GET /api/ramble/egg` — nothing painted it, and leaving it would have let the egg view say "No one on the way just now." while the pet card offered one; the pet card alone owns this affordance. The Warm-it button's failure now lands on the card rather than in `#rb-flock-status`, an element on a different view, where a 409 would have been invisible. `promoteFromShelf`'s guarded `UPDATE` re-checks the trade lock in SQL, matching `incubateEgg`'s own guard (`flock.js:189-192`) so a swap opened between the peek and the write cannot slip through. The perch's eggless status line falls back to the existing "Quiet around here right now." rather than an empty string, aligning the copy table to the better snippet. And the harness citation now tells the implementer to copy by symbol rather than by line range, since several of its numbers are off by one or two. + +**Accepted, recorded, not fixed:** while a swap is open on the last shelf egg the lay line still reads "…you'll manage one yourself, in time." although `hasAnyEggAnywhere` blocks accrual. It is bounded by the 7-day trade TTL, follows from a choice the user made deliberately, and the affordance restores the count's honesty as soon as the trade closes. + +**Review closed after five rounds.** Counts ran 9 → 13 → 6 → 5 → 4, and the character changed as well as the number: round 5 found no design defect, only mis-transcriptions and one unowned test. Continuing would be over-editing rather than improving. Execution starts now. From ad6e78cb8a672886dd1dd62173bc826d79362bd5 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:40:18 -0500 Subject: [PATCH 07/20] ramble: move the egg-lock predicate to a leaf module --- bundles/ramble/server/egg-locks.js | 31 ++++++++++++++++++++++++++++++ bundles/ramble/server/flock.js | 2 +- bundles/ramble/server/trades.js | 19 ++++++------------ 3 files changed, 38 insertions(+), 14 deletions(-) create mode 100644 bundles/ramble/server/egg-locks.js diff --git a/bundles/ramble/server/egg-locks.js b/bundles/ramble/server/egg-locks.js new file mode 100644 index 00000000..ae0256be --- /dev/null +++ b/bundles/ramble/server/egg-locks.js @@ -0,0 +1,31 @@ +/** + * Ramble egg locks — is this egg spoken for by an open swap? + * + * A LEAF module: it imports nothing, deliberately. The rule lived in + * trades.js, but trades.js imports `startOfLocalDay` from eggs.js, and phase + * 3's auto-promote (eggs.js) must skip a locked egg — so keeping it there + * would force an eggs -> trades -> eggs cycle. trades.js re-exports both + * helpers, so its existing importers (flock.js:22 and tests/ramble-trades + * .test.js:14 — NOT panel/routes.js, which never imported them) are + * unchanged and there is still exactly one definition of "locked". + */ + +// Verbatim from trades.js:68. An "open" trade is one that still has a claim on +// the egg; changing this set would silently change which eggs are giftable. +export const OPEN_SQL = "state IN ('proposed', 'accepted')"; + +export async function lockedEggIds(db) { + const { rows } = await db.execute({ + sql: `SELECT my_egg_id FROM ramble_trades WHERE my_egg_id IS NOT NULL AND ${OPEN_SQL}`, + args: [], + }); + return new Set(rows.map((r) => r.my_egg_id)); +} + +export async function isEggLocked(db, eggId) { + const { rows } = await db.execute({ + sql: `SELECT 1 FROM ramble_trades WHERE my_egg_id = ? AND ${OPEN_SQL} LIMIT 1`, + args: [eggId], + }); + return rows.length > 0; +} diff --git a/bundles/ramble/server/flock.js b/bundles/ramble/server/flock.js index e2fc88e6..089c9e06 100644 --- a/bundles/ramble/server/flock.js +++ b/bundles/ramble/server/flock.js @@ -19,7 +19,7 @@ import { createRequire } from "node:module"; import { withinRange, haversineMeters } from "./anchors.js"; import { isoWeek, startOfLocalDay, hatchIfReady, ensureIncubatingEgg, readWarmthWeights } from "./eggs.js"; import { nestFor, cellsInBbox, nestsInCells, NEST_RATE_DEFAULT, CELL7_RE, WEEK_RE } from "./nests.js"; -import { isEggLocked, lockedEggIds } from "./trades.js"; +import { isEggLocked, lockedEggIds } from "./egg-locks.js"; const require = createRequire(import.meta.url); const { ROSTER } = require("./bird-svg.cjs"); diff --git a/bundles/ramble/server/trades.js b/bundles/ramble/server/trades.js index 7f5389ed..e804a960 100644 --- a/bundles/ramble/server/trades.js +++ b/bundles/ramble/server/trades.js @@ -34,6 +34,7 @@ import { CROW_ID_RE, isRambleEnvelope, parseEggPayload, giftPayload, tradePayload, parseTradePayload, payloadToMark, enqueueDeliveries, } from "./delivery.js"; +import { OPEN_SQL, isEggLocked, lockedEggIds } from "./egg-locks.js"; export const TRADE_TTL_MS = 7 * 86400e3; export const OPEN_STATES = ["proposed", "accepted"]; @@ -50,6 +51,11 @@ export const MAX_GIFTS_PER_CONTACT_PER_DAY = 20; */ export const MAX_CONTACT_MARKS_PER_CONTACT = 50; +// Re-exported so its existing importers (flock.js:22 and +// tests/ramble-trades.test.js:14 — NOT panel/routes.js, which never imported +// them) need no change and there is still one definition of "locked". +export { isEggLocked, lockedEggIds }; + async function safeEmit(emit, table, op, row) { if (!emit) return; try { await emit(table, op, row); } @@ -65,23 +71,10 @@ async function getTrade(db, tradeId) { return rows[0] ?? null; } -const OPEN_SQL = "state IN ('proposed', 'accepted')"; const LOCK_GUARD_SQL = `NOT EXISTS (SELECT 1 FROM ramble_trades WHERE my_egg_id = ? AND ${OPEN_SQL})`; /** Binds ONE ?: the egg must still be giftable at write time (a gift racing a propose must not lock a gone egg — S1). */ const GIFTABLE_GUARD_SQL = "EXISTS (SELECT 1 FROM ramble_eggs WHERE egg_id = ? AND status IN ('shelf', 'received'))"; -/* ---------------------------------------------------------------- locks */ - -export async function lockedEggIds(db) { - const { rows } = await db.execute({ sql: `SELECT my_egg_id FROM ramble_trades WHERE my_egg_id IS NOT NULL AND ${OPEN_SQL}`, args: [] }); - return new Set(rows.map((r) => r.my_egg_id)); -} - -export async function isEggLocked(db, eggId) { - const { rows } = await db.execute({ sql: `SELECT 1 FROM ramble_trades WHERE my_egg_id = ? AND ${OPEN_SQL} LIMIT 1`, args: [eggId] }); - return rows.length > 0; -} - /** The "receive an egg" upsert shared by gifts and swap completion: insert if new, revive if it was gifted away, else no-op. */ function receivedEggStatement(egg, fromCrowId, now) { return { From ec64d0c9d55a25c2e25756fe03780c39d6b650e4 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:48:47 -0500 Subject: [PATCH 08/20] ramble: looking at a screen no longer mints an egg Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/panel/routes.js | 5 +- bundles/ramble/server/eggs.js | 49 +++++++++---- bundles/ramble/server/flock.js | 4 +- bundles/ramble/server/server.js | 4 +- tests/ramble-eggs-supply.test.js | 119 +++++++++++++++++++++++++++++++ tests/ramble-eggs.test.js | 8 +-- tests/ramble-feed.test.js | 10 ++- tests/ramble-flock.test.js | 12 ++-- tests/ramble-panel.test.js | 17 ++++- tests/ramble-sync.test.js | 4 +- tests/ramble-tools.test.js | 6 +- tests/ramble-trades.test.js | 4 +- 12 files changed, 206 insertions(+), 36 deletions(-) create mode 100644 tests/ramble-eggs-supply.test.js diff --git a/bundles/ramble/panel/routes.js b/bundles/ramble/panel/routes.js index 1e532f19..f225fcbb 100644 --- a/bundles/ramble/panel/routes.js +++ b/bundles/ramble/panel/routes.js @@ -813,7 +813,10 @@ export default function rambleRouter(dashboardAuth, options = {}) { res.json({ ...pet, bird, - egg: { percent: egg.egg.percent }, + // Task 2 (spec 2026-09-08 §4.1): no egg is a valid state now — a read + // must not crash for it, so an absent egg shows as 0% rather than + // dereferencing a null. + egg: { percent: egg.egg ? egg.egg.percent : 0 }, seed: await mods.walletMod.seedBalance(db), hearts: await mods.heartsMod.heartsBalance(db), energy_max_cap: (await mods.heartsMod.readHeartSettings(db)).cap, diff --git a/bundles/ramble/server/eggs.js b/bundles/ramble/server/eggs.js index 80c08cf8..ee8624dd 100644 --- a/bundles/ramble/server/eggs.js +++ b/bundles/ramble/server/eggs.js @@ -138,20 +138,26 @@ export function creditKey(event, { now }) { const KNOWN_TYPES = new Set(["visit_place", "checkin", "meet_crow", "mark_left", "unlock_mark"]); -async function getIncubatingEgg(db) { +export async function getIncubatingEgg(db) { const { rows } = await db.execute({ sql: "SELECT * FROM ramble_eggs WHERE status = 'incubating' LIMIT 1", args: [] }); return rows[0] ?? null; } /** - * Returns the current incubating egg, creating one if none exists. The - * INSERT ... SELECT ... WHERE NOT EXISTS guard (rather than a unique index) - * makes this race-free within one process on a single SQLite connection: - * two overlapping calls each attempt the guarded insert, only one succeeds - * to insert a row (the second sees the just-inserted row and its WHERE NOT - * EXISTS fails), and both re-select the same egg. + * Insert a fresh incubating egg. THE ONLY MINTING PRIMITIVE — as of phase 3 + * it is called from exactly two places, the starter grant and laying, and + * both are deliberate acts. It was called `ensureIncubatingEgg` and was + * invoked from four sites, two of them pure reads (`eggState` on every + * GET /api/ramble/egg, `flockState` on every flock screen), so merely looking + * at a screen recreated the egg. Read with `getIncubatingEgg` instead; the + * name is "mint" so that a future caller has to mean it. + * + * The INSERT ... SELECT ... WHERE NOT EXISTS guard (rather than a unique + * index) makes this race-free within one process on a single SQLite + * connection: two overlapping calls each attempt the guarded insert, only one + * succeeds, and both re-select the same egg. */ -export async function ensureIncubatingEgg(db, { now, emit } = {}) { +export async function mintIncubatingEgg(db, { now, emit } = {}) { const eggId = crypto.randomUUID(); await db.execute({ sql: `INSERT INTO ramble_eggs (egg_id, status, warmth, created_at) @@ -184,7 +190,7 @@ async function ensurePetRow(db) { /** * Hatches the incubating egg if its warmth has reached hatch_at. The UPDATE * that flips this egg to 'hatched' MUST run before the successor egg is - * inserted: ensureIncubatingEgg's "one incubating egg" guard is a query + * inserted: mintIncubatingEgg's "one incubating egg" guard is a query * against the table's current contents, not a schema constraint, so the old * egg has to already be out of 'incubating' status before the next insert's * WHERE NOT EXISTS check runs. @@ -213,7 +219,7 @@ export async function hatchIfReady(db, { now, emit } = {}) { await safeEmit(emit, "ramble_pet", "update", updatedPet); } - const nextEgg = await ensureIncubatingEgg(db, { now, emit }); + const nextEgg = await mintIncubatingEgg(db, { now, emit }); void nextEgg; return hatchedEgg; @@ -252,7 +258,14 @@ export async function creditWarmth(db, event, { now, emit } = {}) { if (Number(rows[0]?.n ?? 0) >= MEET_CROW_DAILY_CAP) return notCredited(); } - const egg = await ensureIncubatingEgg(db, { now, emit }); + // ⚠ NOT a mint. With no egg the ledger row is STILL written and `credited` + // is still true, because `credited` means "this key was new" and + // feedAll's `shouldFeedPet` gate reads it: reporting not-credited here + // would stop new places, crows and check-ins from feeding the bird for as + // long as the player is eggless — and laying needs happy days while + // eggless. The warmth itself vanishes (spec D3) and the key is burned, so + // the same place cannot bank warmth for a later egg. + const egg = await getIncubatingEgg(db); if (key) { const { rowsAffected } = await db.execute({ @@ -261,9 +274,11 @@ export async function creditWarmth(db, event, { now, emit } = {}) { }); if (rowsAffected === 0) { const current = await getIncubatingEgg(db); - return { credited: false, warmth: current ? current.warmth : egg.warmth, hatched: null }; + return { credited: false, warmth: current ? current.warmth : 0, hatched: null }; } } + + if (!egg) return { credited: true, warmth: 0, hatched: null }; // key === null (mark_left/unlock_mark): always credited, no ledger row. const weights = await readWarmthWeights(db); @@ -305,9 +320,11 @@ export async function activeBird(db) { } export async function eggState(db, { now } = {}) { - const egg = await ensureIncubatingEgg(db, { now }); + const egg = await getIncubatingEgg(db); const weights = await readWarmthWeights(db); - const percent = weights.hatch_at > 0 ? Math.max(0, Math.min(100, Math.round((egg.warmth / weights.hatch_at) * 100))) : 0; + const percent = egg && weights.hatch_at > 0 + ? Math.max(0, Math.min(100, Math.round((egg.warmth / weights.hatch_at) * 100))) + : 0; const week = isoWeek(now); const { rows: placeRows } = await db.execute({ @@ -330,7 +347,9 @@ export async function eggState(db, { now } = {}) { const checkedInToday = (checkinRows[0]?.n ?? 0) > 0; return { - egg: { egg_id: egg.egg_id, warmth: egg.warmth, hatch_at: weights.hatch_at, percent }, + egg: egg + ? { egg_id: egg.egg_id, warmth: egg.warmth, hatch_at: weights.hatch_at, percent } + : null, checklist: { new_places_week: newPlacesWeek, first_mark: firstMark, checked_in_today: checkedInToday }, }; } diff --git a/bundles/ramble/server/flock.js b/bundles/ramble/server/flock.js index 089c9e06..4b2a13bd 100644 --- a/bundles/ramble/server/flock.js +++ b/bundles/ramble/server/flock.js @@ -17,7 +17,7 @@ import crypto from "node:crypto"; import { createRequire } from "node:module"; import { withinRange, haversineMeters } from "./anchors.js"; -import { isoWeek, startOfLocalDay, hatchIfReady, ensureIncubatingEgg, readWarmthWeights } from "./eggs.js"; +import { isoWeek, startOfLocalDay, hatchIfReady, mintIncubatingEgg, readWarmthWeights } from "./eggs.js"; import { nestFor, cellsInBbox, nestsInCells, NEST_RATE_DEFAULT, CELL7_RE, WEEK_RE } from "./nests.js"; import { isEggLocked, lockedEggIds } from "./egg-locks.js"; @@ -222,7 +222,7 @@ export async function activateBird(db, eggId, { emit } = {}) { /** The flock screen's data: hatched birds, unhatched eggs, and the species score. */ export async function flockState(db, { now = Date.now() } = {}) { - await ensureIncubatingEgg(db, { now }); + await mintIncubatingEgg(db, { now }); const weights = await readWarmthWeights(db); const { shelfCap } = await readFlockSettings(db); const pet = await getPetRow(db); diff --git a/bundles/ramble/server/server.js b/bundles/ramble/server/server.js index acbf30f8..a6224112 100644 --- a/bundles/ramble/server/server.js +++ b/bundles/ramble/server/server.js @@ -303,7 +303,9 @@ export function createRambleServer(db, options = {}) { async () => { try { const [state, bird, egg] = await Promise.all([petState(db), activeBird(db), eggState(db, { now: Date.now() })]); - return text(JSON.stringify({ ...state, bird, hearts: await heartsBalance(db), egg: { percent: egg.egg.percent } })); + // Task 2 (spec 2026-09-08 §4.1): no egg is a valid state — a read + // must not throw for it, so an absent egg reports 0%. + return text(JSON.stringify({ ...state, bird, hearts: await heartsBalance(db), egg: { percent: egg.egg ? egg.egg.percent : 0 } })); } catch (err) { return errorText(err.message); } diff --git a/tests/ramble-eggs-supply.test.js b/tests/ramble-eggs-supply.test.js new file mode 100644 index 00000000..f0ad749a --- /dev/null +++ b/tests/ramble-eggs-supply.test.js @@ -0,0 +1,119 @@ +/** + * Spec 2026-09-08 §4.1 — the auto-minted egg is gone, and NOTHING recreates + * it by being looked at. + * + * ⚠ The test that matters most here is "walking still feeds you with no egg". + * feedAll gates the PET feed on creditWarmth's `credited`, so making + * creditWarmth report not-credited when there is no egg would stop energy + * arriving exactly while the player is eggless — and laying (Task 5) needs + * happy days while eggless. That is a death spiral, not a rough edge. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createClient } from "@libsql/client"; +import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; +import { + mintIncubatingEgg, getIncubatingEgg, eggState, creditWarmth, localDay, +} from "../bundles/ramble/server/eggs.js"; +import { feedAll } from "../bundles/ramble/server/feed.js"; + +const T0 = Date.UTC(2026, 8, 9, 12, 0, 0); + +async function freshDb() { + const db = createClient({ url: ":memory:" }); + await initRambleTables(db); + return db; +} + +async function eggCount(db) { + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_eggs", args: [] }); + return Number(rows[0].n); +} + +test("eggState on a fresh db creates NO egg and reports egg: null", async () => { + const db = await freshDb(); + const state = await eggState(db, { now: T0 }); + assert.equal(state.egg, null, "no egg exists, so none is reported"); + assert.equal(await eggCount(db), 0, "a pure read must not mint"); + assert.ok(state.checklist, "the checklist still renders with no egg"); +}); + +test("eggState is still a pure read when an egg DOES exist", async () => { + const db = await freshDb(); + await mintIncubatingEgg(db, { now: T0 }); + const state = await eggState(db, { now: T0 }); + assert.ok(state.egg, "the egg is reported"); + assert.equal(state.egg.warmth, 0); + assert.equal(state.egg.percent, 0); + assert.equal(await eggCount(db), 1, "reading twice must not mint a second"); + await eggState(db, { now: T0 }); + assert.equal(await eggCount(db), 1); +}); + +test("creditWarmth with no egg: the ledger row is written, warmth vanishes (D3)", async () => { + const db = await freshDb(); + const out = await creditWarmth(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 }); + assert.equal(out.credited, true, "credited means THE KEY WAS NEW, not that an egg received it"); + assert.equal(out.warmth, 0); + assert.equal(out.hatched, null); + assert.equal(await eggCount(db), 0, "crediting warmth must never mint an egg"); + + const { rows } = await db.execute({ + sql: "SELECT count(*) AS n FROM ramble_credits WHERE kind = 'visit_place'", args: [], + }); + assert.equal(Number(rows[0].n), 1, "the key is burned: D3 says the warmth is wasted, not banked"); + + // Same place again in the same week is still a no-op. + const again = await creditWarmth(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 }); + assert.equal(again.credited, false); +}); + +test("REGRESSION: walking, meeting a crow and checking in ALL still feed energy with no egg", async () => { + const db = await freshDb(); + // ⚠ EXACT VALUES, NOT `> 0` OR `>=`. `ramble_pet.energy` DEFAULTS TO 60 + // (init-tables.js:105), so `energy > 0` is true whether or not anything was + // fed, and `>=` is true when the feed was SKIPPED and the value did not + // move. An earlier draft of this very test asserted exactly that and would + // have passed against the death spiral it exists to prevent — phase 2's + // vacuous-fixture lesson, on the one test that most needed to be sharp. + // Deltas (pet.js FEED_DELTAS): checkin +5, visit_place +15, meet_crow +20, + // against the base ceiling of 100. + const before = await feedAll(db, { type: "checkin" }, { now: T0 }); + assert.equal(await eggCount(db), 0, "feeding must not mint an egg"); + assert.equal(before.pet.energy, 65, "60 + 5: the check-in fed the bird with no egg"); + + const place = await feedAll(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 + 1000 }); + assert.equal(place.pet.energy, 80, "65 + 15: a new place fed the bird"); + + const crow = await feedAll(db, { type: "meet_crow", persona: "abc123" }, { now: T0 + 2000 }); + assert.equal(crow.pet.energy, 100, "80 + 20: meeting a crow fed the bird"); + assert.equal(await eggCount(db), 0); +}); + +test("NEGATIVE CONTROL: a repeat visit_place does not feed, so the test above can fail", async () => { + // Without this, an implementation that fed unconditionally would also pass + // the test above. `shouldFeedPet` must still honour the dedup key. + const db = await freshDb(); + const first = await feedAll(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 }); + assert.equal(first.pet.energy, 75, "60 + 15"); + const repeat = await feedAll(db, { type: "visit_place", cell: "9vk79ed" }, { now: T0 + 1000 }); + assert.equal(repeat.credited, false, "same cell, same ISO week"); + assert.equal(repeat.pet.energy, 75, "a not-credited keyed event must NOT feed"); +}); + +test("an unknown or pet-only event is still a pure read with no egg", async () => { + const db = await freshDb(); + await creditWarmth(db, { type: "chore" }, { now: T0 }); + await creditWarmth(db, { type: "nonsense" }, { now: T0 }); + assert.equal(await eggCount(db), 0); + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_credits", args: [] }); + assert.equal(Number(rows[0].n), 0, "chore/unknown never touch the ledger"); +}); + +test("getIncubatingEgg is a plain read that returns null rather than throwing", async () => { + const db = await freshDb(); + assert.equal(await getIncubatingEgg(db), null); + const egg = await mintIncubatingEgg(db, { now: T0 }); + const read = await getIncubatingEgg(db); + assert.equal(read.egg_id, egg.egg_id); +}); diff --git a/tests/ramble-eggs.test.js b/tests/ramble-eggs.test.js index 4ac38cf1..346a49f4 100644 --- a/tests/ramble-eggs.test.js +++ b/tests/ramble-eggs.test.js @@ -2,14 +2,14 @@ import { test, before } from "node:test"; import assert from "node:assert/strict"; import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; -import { ensureIncubatingEgg, creditWarmth, hatchIfReady, checkin, eggState, activeBird, isoWeek, localDay, WARMTH_DEFAULTS, MEET_CROW_DAILY_CAP } from "../bundles/ramble/server/eggs.js"; +import { mintIncubatingEgg, creditWarmth, hatchIfReady, checkin, eggState, activeBird, isoWeek, localDay, WARMTH_DEFAULTS, MEET_CROW_DAILY_CAP } from "../bundles/ramble/server/eggs.js"; let db; const T0 = Date.UTC(2026, 8, 7, 12); // 2026-09-07 12:00Z before(async () => { db = createClient({ url: "file::memory:" }); await initRambleTables(db); }); test("a fresh instance gets exactly one incubating egg", async () => { - const a = await ensureIncubatingEgg(db, { now: T0 }); - const b = await ensureIncubatingEgg(db, { now: T0 }); + const a = await mintIncubatingEgg(db, { now: T0 }); + const b = await mintIncubatingEgg(db, { now: T0 }); assert.equal(a.egg_id, b.egg_id); const { rows } = await db.execute("SELECT count(*) AS n FROM ramble_eggs WHERE status='incubating'"); assert.equal(rows[0].n, 1); @@ -129,7 +129,7 @@ test("meet_crow warmth is capped per local day (a spoofed-persona flood cannot f test("activeBird ignores a pet pointer at an egg that has not hatched", async () => { const fresh = createClient({ url: "file::memory:" }); await initRambleTables(fresh); - const egg = await ensureIncubatingEgg(fresh, { now: T0 }); + const egg = await mintIncubatingEgg(fresh, { now: T0 }); await fresh.execute({ sql: "INSERT INTO ramble_pet (owner, active_egg_id) VALUES ('self', ?)", args: [egg.egg_id] }); assert.equal(await activeBird(fresh), null, "an incubating egg is not a bird"); diff --git a/tests/ramble-feed.test.js b/tests/ramble-feed.test.js index 51fcd4c8..b8b7fd03 100644 --- a/tests/ramble-feed.test.js +++ b/tests/ramble-feed.test.js @@ -4,9 +4,17 @@ import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { feedAll } from "../bundles/ramble/server/feed.js"; import { petState } from "../bundles/ramble/server/pet.js"; +import { mintIncubatingEgg } from "../bundles/ramble/server/eggs.js"; let db; const T0 = Date.UTC(2026, 8, 7, 12); -before(async () => { db = createClient({ url: "file::memory:" }); await initRambleTables(db); }); +before(async () => { + db = createClient({ url: "file::memory:" }); + await initRambleTables(db); + // Task 2 (spec 2026-09-08 §4.1): minting is deliberate now — these tests + // are about warmth accrual and hatching, not egg supply, so give them an + // explicit starter egg rather than weaken their assertions. + await mintIncubatingEgg(db, { now: T0 }); +}); test("one call credits warmth AND energy; a repeat keyed event credits neither", async () => { const e0 = (await petState(db, { now: T0 })).energy; diff --git a/tests/ramble-flock.test.js b/tests/ramble-flock.test.js index 390038da..21dd082d 100644 --- a/tests/ramble-flock.test.js +++ b/tests/ramble-flock.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { encodeGeohash } from "../bundles/ramble/server/anchors.js"; -import { isoWeek, ensureIncubatingEgg } from "../bundles/ramble/server/eggs.js"; +import { isoWeek, mintIncubatingEgg } from "../bundles/ramble/server/eggs.js"; import { nestFor, CELL7_LAT_STEP } from "../bundles/ramble/server/nests.js"; import { readFlockSettings, listNests, claimNest, @@ -113,7 +113,7 @@ test("claimNest: one claim per local day, and the shelf cap refuses the sixth", assert.equal((await d.execute("SELECT count(*) AS n FROM ramble_eggs WHERE status='shelf' AND shelf_origin='user'")).rows[0].n, SHELF_CAP_DEFAULT); // The cap counts USER shelf eggs only: neither the incubating egg nor a // convergence loser that landed on the shelf is one of the user's spots. - await ensureIncubatingEgg(d, { now: T0 }); + await mintIncubatingEgg(d, { now: T0 }); await d.execute("INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, created_at) VALUES ('loser','shelf','sync',0,1)"); assert.deepEqual(await claimNest(d, { cell: cells[5], week: WEEK, here: at(cells[5]), now: day5 }), { claimed: false, reason: "shelf-full" }); // A raised cap admits it (5 user eggs < 6) — and the refused attempts above @@ -124,7 +124,7 @@ test("claimNest: one claim per local day, and the shelf cap refuses the sixth", test("incubateEgg swaps the slot: old egg shelved as 'user', target incubating, emits shelved then incubating", async () => { const d = await freshDb(); - const first = await ensureIncubatingEgg(d, { now: T0 }); + const first = await mintIncubatingEgg(d, { now: T0 }); await d.execute({ sql: "UPDATE ramble_eggs SET warmth = 40 WHERE egg_id = ?", args: [first.egg_id] }); await d.execute("INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, found_cell, created_at) VALUES ('s1','shelf','user',10,'9v6m21h',5)"); const emitted = []; @@ -143,7 +143,7 @@ test("incubateEgg swaps the slot: old egg shelved as 'user', target incubating, test("incubateEgg hatches a swapped-in egg that is already past the threshold", async () => { const d = await freshDb(); - await ensureIncubatingEgg(d, { now: T0 }); + await mintIncubatingEgg(d, { now: T0 }); await d.execute("INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, created_at) VALUES ('hot','shelf','user',100,5)"); const r = await incubateEgg(d, "hot", { now: T0 }); assert.ok(r.hatched && r.hatched.egg_id === "hot" && typeof r.hatched.species === "string"); @@ -178,7 +178,7 @@ test("flockState: birds with the active one marked, eggs incubating-first, speci test("incubateEgg is all-or-nothing under a concurrent swap of the same egg", async () => { const d = await freshDb(); - const first = await ensureIncubatingEgg(d, { now: T0 }); + const first = await mintIncubatingEgg(d, { now: T0 }); await d.execute("INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, created_at) VALUES ('s2','shelf','user',10,5)"); const results = await Promise.all([incubateEgg(d, "s2", { now: T0 }), incubateEgg(d, "s2", { now: T0 })]); assert.ok(results.every((r) => r.ok === true), JSON.stringify(results)); @@ -198,7 +198,7 @@ test("incubateEgg is all-or-nothing under a concurrent swap of the same egg", as test("phase 3: incubateEgg admits a received egg (origin cleared), refuses a locked one and a gifted one; flockState lists received + locked", async () => { const d = await freshDb(); - const first = await ensureIncubatingEgg(d, { now: T0 }); + const first = await mintIncubatingEgg(d, { now: T0 }); await d.execute("INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, from_crow_id, created_at) VALUES ('rx','received','user',35,'crow:friend',7)"); await d.execute("INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, created_at) VALUES ('sw','shelf','user',5,8), ('gone','gifted','user',5,9)"); const p = await proposeSwap(d, { eggId: "sw", toCrowId: "crow:friend", now: T0 }); diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index a86ab8fb..511c9ead 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -41,7 +41,8 @@ const { default: rambleRouter } = await import("../bundles/ramble/panel/routes.j const { default: panel } = await import("../bundles/ramble/panel/ramble.js"); const { createDbClient } = await import("../bundles/ramble/server/db.js"); const { default: bus } = await import("../servers/shared/event-bus.js"); -const { isoWeek } = await import("../bundles/ramble/server/eggs.js"); +const { isoWeek, mintIncubatingEgg } = await import("../bundles/ramble/server/eggs.js"); +const { initRambleTables } = await import("../bundles/ramble/server/init-tables.js"); const { nestsInCells, cellsInBbox, NEST_RATE_DEFAULT } = await import("../bundles/ramble/server/nests.js"); const { bboxAround } = await import("../bundles/ramble/server/around.js"); @@ -143,6 +144,20 @@ const PK_BLOCKED = "ed".repeat(32); try { db.close?.(); } catch { /* scratch */ } } +// Task 2 (spec 2026-09-08 §4.1): minting is deliberate now — a read no longer +// creates an egg by being looked at. This suite's warmth-accrual tests are +// about the DELTA an event credits, not about egg supply, so give them an +// explicit starter egg here rather than weaken any of their assertions; the +// file's own hatch churn (see the walkTo/unlockRadius comment above) keeps an +// incubating egg in place for the rest of the run. +{ + const db = createDbClient(); + try { + await initRambleTables(db); + await mintIncubatingEgg(db, { now: Date.now() }); + } finally { db.close?.(); } +} + after(async () => { await new Promise((r) => server.close(r)); for (const [k, v] of Object.entries(savedEnv)) { diff --git a/tests/ramble-sync.test.js b/tests/ramble-sync.test.js index c00fa9bd..28fe90d8 100644 --- a/tests/ramble-sync.test.js +++ b/tests/ramble-sync.test.js @@ -19,7 +19,7 @@ import { SYNCED_TABLES, EXCLUDED_COLUMNS, applyRemoteOp, shouldSyncRow } from ". import { emitOrQueue, _setEligibilityForTest } from "../servers/shared/sync-emit.js"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { createMark, blockPersona } from "../bundles/ramble/server/marks.js"; -import { ensureIncubatingEgg, isoWeek } from "../bundles/ramble/server/eggs.js"; +import { mintIncubatingEgg, isoWeek } from "../bundles/ramble/server/eggs.js"; import { feed } from "../bundles/ramble/server/pet.js"; import { claimNest } from "../bundles/ramble/server/flock.js"; import { nestFor, CELL7_LAT_STEP } from "../bundles/ramble/server/nests.js"; @@ -167,7 +167,7 @@ test("allowlist + exclusions for eggs/pet", () => { }); test("outbox door: an egg write with no manager queues and is stamped", async () => { - const egg = await ensureIncubatingEgg(a, { now: 1000 }); + const egg = await mintIncubatingEgg(a, { now: 1000 }); const res = await emitOrQueue(null, a, "ramble_eggs", "insert", egg); assert.ok(res && res.queued, "emitOrQueue returned null — missing stampSql branch or lamport_ts?"); const { rows } = await a.execute({ sql: "SELECT lamport_ts FROM ramble_eggs WHERE egg_id=?", args: [egg.egg_id] }); diff --git a/tests/ramble-tools.test.js b/tests/ramble-tools.test.js index b620a160..db99f10b 100644 --- a/tests/ramble-tools.test.js +++ b/tests/ramble-tools.test.js @@ -4,7 +4,7 @@ import { createClient } from "@libsql/client"; import { createHash } from "node:crypto"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { createRambleServer } from "../bundles/ramble/server/server.js"; -import { WARMTH_DEFAULTS, isoWeek } from "../bundles/ramble/server/eggs.js"; +import { WARMTH_DEFAULTS, isoWeek, mintIncubatingEgg } from "../bundles/ramble/server/eggs.js"; import { encodeGeohash } from "../bundles/ramble/server/anchors.js"; import { nestFor, CELL7_LAT_STEP } from "../bundles/ramble/server/nests.js"; @@ -12,6 +12,10 @@ let db, h; before(async () => { db = createClient({ url: "file::memory:" }); await initRambleTables(db); + // Task 2 (spec 2026-09-08 §4.1): minting is deliberate now — these tests + // are about warmth accrual, not egg supply, so give them an explicit + // starter egg rather than weaken their assertions. + await mintIncubatingEgg(db, { now: Date.now() }); const handlers = {}; const compressed = (s) => "02" + createHash("sha256").update(s).digest("hex"); const fakeDerive = (seed, botId) => ({ secp256k1Pubkey: compressed(seed + botId), secp256k1Priv: Buffer.from(botId) }); diff --git a/tests/ramble-trades.test.js b/tests/ramble-trades.test.js index ff814fe2..58b2b5d5 100644 --- a/tests/ramble-trades.test.js +++ b/tests/ramble-trades.test.js @@ -7,7 +7,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; -import { ensureIncubatingEgg } from "../bundles/ramble/server/eggs.js"; +import { mintIncubatingEgg } from "../bundles/ramble/server/eggs.js"; import { pendingDeliveries, deleteDelivery } from "../bundles/ramble/server/delivery.js"; import { TRADE_TTL_MS, giftEgg, receiveGift, proposeSwap, acceptSwap, declineSwap, receiveTrade, expireTrades, @@ -36,7 +36,7 @@ const emitter = () => { const calls = []; return { calls, emit: async (t, op, ro test("giftEgg: shelf/received only, egg leaves as 'gifted', one queued ramble.egg without species/seed", async () => { const db = await freshDb(); await shelf(db, "g1", 30); - await ensureIncubatingEgg(db, { now: T0 }); + await mintIncubatingEgg(db, { now: T0 }); assert.deepEqual(await giftEgg(db, { eggId: "g1", toCrowId: "bad id", now: T0 }), { ok: false, reason: "bad-recipient" }); assert.equal((await egg(db, "g1")).status, "shelf", "a bad recipient never touches the egg"); const { calls, emit } = emitter(); From 9d71056bba369db67c5c7d645b95dbc9411191ee Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:58:01 -0500 Subject: [PATCH 09/20] ramble: absent egg reads as null, not a phantom 0% egg Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/panel/routes.js | 10 ++-- bundles/ramble/server/eggs.js | 17 +++--- bundles/ramble/server/server.js | 5 +- tests/ramble-egg-null-read.test.js | 87 ++++++++++++++++++++++++++++++ tests/ramble-eggs-supply.test.js | 2 +- 5 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 tests/ramble-egg-null-read.test.js diff --git a/bundles/ramble/panel/routes.js b/bundles/ramble/panel/routes.js index f225fcbb..5489935f 100644 --- a/bundles/ramble/panel/routes.js +++ b/bundles/ramble/panel/routes.js @@ -805,8 +805,8 @@ export default function rambleRouter(dashboardAuth, options = {}) { router.get("/api/ramble/pet", handle(async (req, res) => { const now = Date.now(); // One read for the whole companion strip: mood/energy/chores, the bird - // that hatched (null until the first one does), and just the egg's - // progress percent — the panel's full egg card reads /api/ramble/egg. + // that hatched (null until the first one does), and the incubating egg + // (null if none exists) — the panel's full egg card reads /api/ramble/egg. const pet = await mods.petMod.petState(db, { now }); const bird = await mods.eggsMod.activeBird(db); const egg = await mods.eggsMod.eggState(db, { now }); @@ -814,9 +814,9 @@ export default function rambleRouter(dashboardAuth, options = {}) { ...pet, bird, // Task 2 (spec 2026-09-08 §4.1): no egg is a valid state now — a read - // must not crash for it, so an absent egg shows as 0% rather than - // dereferencing a null. - egg: { percent: egg.egg ? egg.egg.percent : 0 }, + // must not crash for it, so an absent egg is reported as `null`, not + // dereferenced. + egg: egg.egg ?? null, seed: await mods.walletMod.seedBalance(db), hearts: await mods.heartsMod.heartsBalance(db), energy_max_cap: (await mods.heartsMod.readHeartSettings(db)).cap, diff --git a/bundles/ramble/server/eggs.js b/bundles/ramble/server/eggs.js index ee8624dd..5eb383bb 100644 --- a/bundles/ramble/server/eggs.js +++ b/bundles/ramble/server/eggs.js @@ -144,13 +144,16 @@ export async function getIncubatingEgg(db) { } /** - * Insert a fresh incubating egg. THE ONLY MINTING PRIMITIVE — as of phase 3 - * it is called from exactly two places, the starter grant and laying, and - * both are deliberate acts. It was called `ensureIncubatingEgg` and was - * invoked from four sites, two of them pure reads (`eggState` on every - * GET /api/ramble/egg, `flockState` on every flock screen), so merely looking - * at a screen recreated the egg. Read with `getIncubatingEgg` instead; the - * name is "mint" so that a future caller has to mean it. + * Insert a fresh incubating egg. THE ONLY MINTING PRIMITIVE. As of the end of + * this phase the only callers are the starter grant and laying, both + * deliberate acts; until those land, `flock.js`'s `flockState` still calls it + * on every flock screen (Task 3 removes that call), and `hatchIfReady` calls + * it to mint the successor egg after a hatch. It was called + * `ensureIncubatingEgg` and was invoked from four sites, two of them pure + * reads (`eggState` on every GET /api/ramble/egg, `flockState` on every flock + * screen), so merely looking at a screen recreated the egg. Read with + * `getIncubatingEgg` instead; the name is "mint" so that a future caller has + * to mean it. * * The INSERT ... SELECT ... WHERE NOT EXISTS guard (rather than a unique * index) makes this race-free within one process on a single SQLite diff --git a/bundles/ramble/server/server.js b/bundles/ramble/server/server.js index a6224112..5998bcbc 100644 --- a/bundles/ramble/server/server.js +++ b/bundles/ramble/server/server.js @@ -304,8 +304,9 @@ export function createRambleServer(db, options = {}) { try { const [state, bird, egg] = await Promise.all([petState(db), activeBird(db), eggState(db, { now: Date.now() })]); // Task 2 (spec 2026-09-08 §4.1): no egg is a valid state — a read - // must not throw for it, so an absent egg reports 0%. - return text(JSON.stringify({ ...state, bird, hearts: await heartsBalance(db), egg: { percent: egg.egg ? egg.egg.percent : 0 } })); + // must not throw for it, so an absent egg is reported as `null`, not + // dereferenced. + return text(JSON.stringify({ ...state, bird, hearts: await heartsBalance(db), egg: egg.egg ?? null })); } catch (err) { return errorText(err.message); } diff --git a/tests/ramble-egg-null-read.test.js b/tests/ramble-egg-null-read.test.js new file mode 100644 index 00000000..0a4898fe --- /dev/null +++ b/tests/ramble-egg-null-read.test.js @@ -0,0 +1,87 @@ +/** + * Task 2 review finding 2 — regression coverage for the null-egg guard. + * + * Both `GET /api/ramble/pet` (bundles/ramble/panel/routes.js) and the + * `ramble_pet_state` MCP tool (bundles/ramble/server/server.js) used to do + * `egg.egg.percent` unguarded. Once `eggState` can legitimately return + * `egg: null` (spec 2026-09-08 §4.1), that throws — a 500 from the route, an + * `isError` result from the tool. Every other suite that exercises these two + * reads mints an egg at module load (a deliberate Task 2 fixture repair), so + * none of them would have caught a regression back to the unguarded form. + * This file's whole purpose is to drive each read against a virgin db that + * has NEVER minted an egg. + * + * Each test gets its own fresh scratch dir / db — a dedicated file rather + * than reusing `tests/ramble-panel.test.js` or `tests/ramble-tools.test.js`, + * both of which mint an egg once at module load for the rest of their run + * and so cannot produce a virgin db partway through. + */ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { once } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createClient } from "@libsql/client"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dir, ".."); + +test("GET /api/ramble/pet on a virgin db (no egg ever minted) reports egg: null, not a crash", async () => { + const SCRATCH = mkdtempSync(join(tmpdir(), "ramble-egg-null-route-")); + const savedEnv = { + CROW_APP_ROOT: process.env.CROW_APP_ROOT, + CROW_DATA_DIR: process.env.CROW_DATA_DIR, + CROW_DB_PATH: process.env.CROW_DB_PATH, + }; + process.env.CROW_APP_ROOT = REPO_ROOT; + process.env.CROW_DATA_DIR = SCRATCH; + delete process.env.CROW_DB_PATH; + + let server; + try { + // Dynamic import so the env above is in place before the bundle's + // BUNDLE_DIR / db-path resolution runs. + const { default: rambleRouter } = await import(`../bundles/ramble/panel/routes.js?t=${Date.now()}`); + const routerInstance = rambleRouter((req, res, next) => (req.headers["x-test-auth"] ? next() : res.status(401).end())); + const app = express(); + app.use(routerInstance); + server = app.listen(0); + await once(server, "listening"); + const base = `http://127.0.0.1:${server.address().port}`; + + // The FIRST request to hit this router lazily creates+inits the scratch + // db (routes.js ensureLoaded()) — nothing else has touched ramble_eggs. + const res = await fetch(`${base}/api/ramble/pet`, { headers: { "x-test-auth": "1" } }); + assert.equal(res.status, 200, "must not 500 for a virgin db with no egg"); + const body = await res.json(); + assert.equal(body.egg, null, "no egg exists, so the read must report null — not a phantom 0%-warmth egg"); + } finally { + if (server) await new Promise((r) => server.close(r)); + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { rmSync(SCRATCH, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +test("ramble_pet_state on a virgin db (no egg ever minted) reports egg: null, not isError", async () => { + const { initRambleTables } = await import("../bundles/ramble/server/init-tables.js"); + const { createRambleServer } = await import("../bundles/ramble/server/server.js"); + + const db = createClient({ url: ":memory:" }); + await initRambleTables(db); + // Deliberately no mintIncubatingEgg call — this db has never had one. + + const handlers = {}; + const identity = { crowId: "crow_virgin", secp256k1Pubkey: "02" + "aa".repeat(32), secp256k1Priv: Buffer.from("virgin") }; + createRambleServer(db, { _exposeHandlers: handlers, identity, seed: "seed", emit: async () => {} }); + + const r = await handlers.ramble_pet_state({}); + assert.ok(!r.isError, `ramble_pet_state must not error for a virgin db: ${r.isError ? r.content?.[0]?.text : ""}`); + const state = JSON.parse(r.content[0].text); + assert.equal(state.egg, null, "no egg exists, so the read must report null — not a phantom 0%-warmth egg"); +}); diff --git a/tests/ramble-eggs-supply.test.js b/tests/ramble-eggs-supply.test.js index f0ad749a..465e21bb 100644 --- a/tests/ramble-eggs-supply.test.js +++ b/tests/ramble-eggs-supply.test.js @@ -13,7 +13,7 @@ import assert from "node:assert/strict"; import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { - mintIncubatingEgg, getIncubatingEgg, eggState, creditWarmth, localDay, + mintIncubatingEgg, getIncubatingEgg, eggState, creditWarmth, } from "../bundles/ramble/server/eggs.js"; import { feedAll } from "../bundles/ramble/server/feed.js"; From b1a22c0be78a64d7f5ead4ff1e1b1dafa557fd6d Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 15:12:00 -0500 Subject: [PATCH 10/20] ramble: the shelf refills the incubating slot, and hatching mints nothing Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/server/eggs.js | 149 +++++++++++++++++++++--- bundles/ramble/server/flock.js | 7 +- bundles/ramble/server/init-tables.js | 5 +- tests/ramble-eggs-supply.test.js | 163 +++++++++++++++++++++++++++ tests/ramble-eggs.test.js | 38 +++++-- tests/ramble-flock.test.js | 15 ++- tests/ramble-panel.test.js | 31 +++-- 7 files changed, 366 insertions(+), 42 deletions(-) diff --git a/bundles/ramble/server/eggs.js b/bundles/ramble/server/eggs.js index 5eb383bb..93e48cf0 100644 --- a/bundles/ramble/server/eggs.js +++ b/bundles/ramble/server/eggs.js @@ -12,13 +12,15 @@ * * When warmth reaches `hatch_at` the egg hatches into a bird (species rolled * from bird-svg's ROSTER, seed a uint32 from crypto.randomInt — never - * Math.random, so the roll can't be predicted or replayed), the next - * incubating egg starts immediately, and if no bird is active yet + * Math.random, so the roll can't be predicted or replayed), the shelf + * refills the incubating slot if it can (`promoteFromShelf`, spec §4.2 — NO + * successor is minted any more), and if no bird is active yet * (`ramble_pet.active_egg_id IS NULL`) the newly hatched egg becomes it. */ import crypto from "node:crypto"; import { createRequire } from "node:module"; +import { lockedEggIds } from "./egg-locks.js"; const require = createRequire(import.meta.url); const { ROSTER } = require("./bird-svg.cjs"); @@ -145,13 +147,13 @@ export async function getIncubatingEgg(db) { /** * Insert a fresh incubating egg. THE ONLY MINTING PRIMITIVE. As of the end of - * this phase the only callers are the starter grant and laying, both - * deliberate acts; until those land, `flock.js`'s `flockState` still calls it - * on every flock screen (Task 3 removes that call), and `hatchIfReady` calls - * it to mint the successor egg after a hatch. It was called - * `ensureIncubatingEgg` and was invoked from four sites, two of them pure - * reads (`eggState` on every GET /api/ramble/egg, `flockState` on every flock - * screen), so merely looking at a screen recreated the egg. Read with + * Task 3 the only callers are the starter grant and laying (both deliberate + * acts) and test fixtures; `flockState` no longer calls it (Task 3 removed + * that call) and neither does `hatchIfReady`, which promotes from the shelf + * instead of minting a successor (`promoteFromShelf`, spec §4.2). It was + * called `ensureIncubatingEgg` and was invoked from four sites, two of them + * pure reads (`eggState` on every GET /api/ramble/egg, `flockState` on every + * flock screen), so merely looking at a screen recreated the egg. Read with * `getIncubatingEgg` instead; the name is "mint" so that a future caller has * to mean it. * @@ -190,13 +192,129 @@ async function ensurePetRow(db) { return getPetRow(db); } +/** + * Refill an empty incubating slot from the shelf (spec §4.2). This is the + * release valve that makes D3 — warmth vanishing when there is no egg — + * tolerable: the user is only ever eggless when they genuinely have none. + * + * Order is `created_at ASC, egg_id ASC`: a TOTAL order and a pure function of + * rows that replicate, so two instances reach the same answer independently + * with nothing to exchange and nothing to emit beyond the row itself. + * + * ⚠ NOT the same mechanism as `RAMBLE_EGG_REPROMOTE_SQL` in + * servers/sharing/instance-sync.js, which promotes ONLY `shelf_origin='sync'` + * eggs and says a 'user' egg "must never be drafted back in". That is correct + * FOR SYNC: it is a convergence tie-break carrying no user intent, and + * drafting a deliberately-parked egg on a sync apply would override a choice + * the user made. This one is a game rule and DOES take user eggs — that is + * the point of §4.2. Do not unify them. + * + * An egg named by an open swap is skipped: it is promised to a contact, and + * incubating it would let the user spend it twice. + * + * Writes NOTHING when the slot is occupied or nothing is promotable. + * + * ⚠ CALLED FROM EXACTLY ONE PLACE: `hatchIfReady`. Never from a read path. + * + * Two earlier drafts of this plan called it from `eggState`/`flockState` too, + * so that a slot emptied by a sync arrival would refill without waiting for a + * hatch. Both were wrong, and the second was wrong in a subtler way than the + * first: + * + * 1. It is a write during a GET, and `applyRambleEgg` carries an explicit + * carve-out (instance-sync.js:855-860) refusing to re-promote on a peer's + * USER shelve, because the replacement egg's row "follows in the same + * drain" — a GET landing in that window drafts the egg the user just + * parked, and it then out-ranks their real choice on both machines. + * 2. The attempted fix — marking such a promote `shelf_origin = 'sync'` so + * it ranks below a real choice — LAUNDERS PROVENANCE. `flock.js:126` + * counts `status='shelf' AND shelf_origin='user'` for the nest shelf cap + * and `flock.js:253` for `shelf_count`; `instance-sync.js:831` rewrites + * a demoted egg to `'sync'` unconditionally; and + * `RAMBLE_EGG_REPROMOTE_SQL` drafts `'sync'` eggs only. A user egg + * relabelled 'sync' therefore stops consuming a shelf slot, is + * under-reported to the user, becomes draftable by the very sync rule + * the 'user' mark exists to protect it from, and is mislabelled "came + * back from another of your Crows" at `static/ramble.js:1776`. + * + * Honest inventory of every way the slot can empty, and what covers it: + * + * - a hatch -> covered HERE, and this is the main loop + * - `incubateEgg` swap -> never empties the slot (one conditional + * UPDATE), and it ends in `hatchIfReady` + * - gifting / swapping away -> the incubating egg is not giftable + * (`GIFTABLE = {shelf, received}`) + * - a gift or swap ARRIVING, or a swap expiring/declining and unlocking + * the last shelf egg, while the slot is empty + * -> NOT auto-promoted. The egg sits on the + * shelf and the panel says so, with a button + * that incubates it in one tap (Task 7). + * - a slot emptied by `applyRambleEgg` while the user holds ONLY 'user' + * shelf eggs -> same: `RAMBLE_EGG_REPROMOTE_SQL` drafts + * `shelf_origin='sync'` rows only. + * + * ⚠ An earlier draft promoted from `trades.js`'s closing paths to auto-cover + * rows 4 and 5. It was reverted: promoting inside `expireTrades` strands an + * in-flight `completed` envelope — the hand-over UPDATE (`WHERE status IN + * ('shelf','received')`) then matches nothing while `receivedEggStatement` + * still inserts, so the user keeps BOTH eggs. Manufacturing a free-egg race + * in the phase whose whole purpose is removing the free egg is not a trade + * worth making, and the underlying complaint was never "the slot is empty" — + * it was "the player has no signal and no way back". That is an affordance + * problem, and it is fixed with an affordance. + */ +/** + * The egg that WOULD be promoted, or null — a pure read, no writes. + * + * Extracted so the promote and the panel's "one's waiting on your shelf" card + * read exactly ONE rule. A card that offers an egg the promote would not take + * (or the reverse) is the map/payout split this project has already had to + * close once in phase 1. + */ +export async function nextPromotable(db) { + if (await getIncubatingEgg(db)) return null; + const locked = await lockedEggIds(db); + const { rows } = await db.execute({ + sql: `SELECT egg_id FROM ramble_eggs + WHERE status IN ('shelf', 'received') + ORDER BY created_at ASC, egg_id ASC`, + args: [], + }); + return rows.find((r) => !locked.has(r.egg_id)) ?? null; +} + +export async function promoteFromShelf(db, { now, emit } = {}) { + void now; + const next = await nextPromotable(db); + if (!next) return null; + + // Guarded exactly like mintIncubatingEgg: the "one incubating egg" rule is + // a query against the table's contents, not a schema constraint, so two + // overlapping promotes must not both succeed. + // The lock is re-checked in SQL, not only in nextPromotable's JS filter, so + // this matches incubateEgg's own guard (flock.js:189-192) exactly and a swap + // opened between the peek and the write cannot slip through. + const { rowsAffected } = await db.execute({ + sql: `UPDATE ramble_eggs SET status = 'incubating', shelf_origin = NULL + WHERE egg_id = ? AND status IN ('shelf', 'received') + AND NOT EXISTS (SELECT 1 FROM ramble_eggs WHERE status = 'incubating') + AND NOT EXISTS (SELECT 1 FROM ramble_trades + WHERE my_egg_id = ? AND state IN ('proposed', 'accepted'))`, + args: [next.egg_id, next.egg_id], + }); + if (rowsAffected === 0) return null; + + const promoted = await getIncubatingEgg(db); + if (promoted) await safeEmit(emit, "ramble_eggs", "update", promoted); + return promoted; +} + /** * Hatches the incubating egg if its warmth has reached hatch_at. The UPDATE - * that flips this egg to 'hatched' MUST run before the successor egg is - * inserted: mintIncubatingEgg's "one incubating egg" guard is a query + * that flips this egg to 'hatched' MUST run before the shelf is asked to + * refill the slot: `promoteFromShelf`'s "one incubating egg" guard is a query * against the table's current contents, not a schema constraint, so the old - * egg has to already be out of 'incubating' status before the next insert's - * WHERE NOT EXISTS check runs. + * egg has to already be out of 'incubating' status before that guard runs. */ export async function hatchIfReady(db, { now, emit } = {}) { const egg = await getIncubatingEgg(db); @@ -222,8 +340,9 @@ export async function hatchIfReady(db, { now, emit } = {}) { await safeEmit(emit, "ramble_pet", "update", updatedPet); } - const nextEgg = await mintIncubatingEgg(db, { now, emit }); - void nextEgg; + // Phase 3: the successor egg is NOT minted. The shelf refills the slot if + // it can; otherwise the player is genuinely eggless and the panel says so. + await promoteFromShelf(db, { now, emit }); return hatchedEgg; } diff --git a/bundles/ramble/server/flock.js b/bundles/ramble/server/flock.js index 4b2a13bd..c10e6753 100644 --- a/bundles/ramble/server/flock.js +++ b/bundles/ramble/server/flock.js @@ -17,7 +17,7 @@ import crypto from "node:crypto"; import { createRequire } from "node:module"; import { withinRange, haversineMeters } from "./anchors.js"; -import { isoWeek, startOfLocalDay, hatchIfReady, mintIncubatingEgg, readWarmthWeights } from "./eggs.js"; +import { isoWeek, startOfLocalDay, hatchIfReady, readWarmthWeights } from "./eggs.js"; import { nestFor, cellsInBbox, nestsInCells, NEST_RATE_DEFAULT, CELL7_RE, WEEK_RE } from "./nests.js"; import { isEggLocked, lockedEggIds } from "./egg-locks.js"; @@ -222,7 +222,10 @@ export async function activateBird(db, eggId, { emit } = {}) { /** The flock screen's data: hatched birds, unhatched eggs, and the species score. */ export async function flockState(db, { now = Date.now() } = {}) { - await mintIncubatingEgg(db, { now }); + // Phase 3: a flock screen is a READ, and now genuinely is one. It used to + // mint the incubating egg, so opening this view recreated one. It does NOT + // promote either: see promoteFromShelf's note on why a write during a GET + // both races the sync drain and launders shelf_origin provenance. const weights = await readWarmthWeights(db); const { shelfCap } = await readFlockSettings(db); const pet = await getPetRow(db); diff --git a/bundles/ramble/server/init-tables.js b/bundles/ramble/server/init-tables.js index f365893a..47a7c8a7 100644 --- a/bundles/ramble/server/init-tables.js +++ b/bundles/ramble/server/init-tables.js @@ -177,7 +177,10 @@ export async function initRambleTables(db) { // Phase 2: WHY an egg is on the shelf. 'sync' = a convergence loser (the // sync layer may re-promote it when the incubating slot empties); 'user' = // the user put it there (claimed from a nest, or swapped out by incubate) - // and it must NEVER be auto-promoted. Phase 1 only ever shelved convergence + // and THE SYNC LAYER must never draft it back in. Phase 3's app-level + // auto-promote (eggs.js promoteFromShelf, spec §4.2) DOES take 'user' eggs + // deliberately — that is the release valve; the two are different + // mechanisms with different triggers. Phase 1 only ever shelved convergence // losers, so a NULL shelf row on disk is one of those: backfill it to 'sync' // (idempotent, and a 'user' row is never NULL so it is never touched). await ensureColumn(db, "ramble_eggs", "shelf_origin", "TEXT"); diff --git a/tests/ramble-eggs-supply.test.js b/tests/ramble-eggs-supply.test.js index 465e21bb..7660736b 100644 --- a/tests/ramble-eggs-supply.test.js +++ b/tests/ramble-eggs-supply.test.js @@ -14,7 +14,9 @@ import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { mintIncubatingEgg, getIncubatingEgg, eggState, creditWarmth, + promoteFromShelf, nextPromotable, hatchIfReady, } from "../bundles/ramble/server/eggs.js"; +import { flockState } from "../bundles/ramble/server/flock.js"; import { feedAll } from "../bundles/ramble/server/feed.js"; const T0 = Date.UTC(2026, 8, 9, 12, 0, 0); @@ -117,3 +119,164 @@ test("getIncubatingEgg is a plain read that returns null rather than throwing", const read = await getIncubatingEgg(db); assert.equal(read.egg_id, egg.egg_id); }); + +async function shelveEgg(db, eggId, createdAt, { status = "shelf", origin = "user" } = {}) { + await db.execute({ + sql: `INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, created_at) VALUES (?, ?, ?, 0, ?)`, + args: [eggId, status, origin, createdAt], + }); +} + +async function statusOf(db, eggId) { + const { rows } = await db.execute({ sql: "SELECT status, shelf_origin FROM ramble_eggs WHERE egg_id = ?", args: [eggId] }); + return rows[0] ?? null; +} + +test("promoteFromShelf takes the OLDEST shelf egg and clears shelf_origin", async () => { + const db = await freshDb(); + await shelveEgg(db, "younger", T0 + 5000); + await shelveEgg(db, "older", T0); + + const promoted = await promoteFromShelf(db, { now: T0 + 9000 }); + assert.equal(promoted.egg_id, "older", "oldest created_at wins"); + assert.equal((await statusOf(db, "older")).status, "incubating"); + assert.equal((await statusOf(db, "older")).shelf_origin, null, + "a deliberate promote carries no shelf origin, same as the manual incubate path"); + assert.equal((await statusOf(db, "younger")).status, "shelf", "only one is drafted"); +}); + +test("promoteFromShelf emits, because the user really did move to a new egg", async () => { + const db = await freshDb(); + await shelveEgg(db, "next", T0); + const emitted = []; + await promoteFromShelf(db, { now: T0 + 1000, emit: (t, o, r) => emitted.push([t, o, r.egg_id]) }); + assert.deepEqual(emitted, [["ramble_eggs", "update", "next"]]); + assert.equal((await statusOf(db, "next")).shelf_origin, null, + "NULL, never 'sync': relabelling would widen the nest shelf cap (flock.js:126) and make the " + + "sync layer's own re-promote draftable on an egg the user parked"); +}); + +test("a READ never promotes — flockState and the egg route are pure", async () => { + const db = await freshDb(); + await shelveEgg(db, "parked", T0); + await flockState(db, { now: T0 + 1000 }); + assert.equal(await getIncubatingEgg(db), null, + "a GET must not queue a sync op, and a read-path promote races applyRambleEgg's " + + "isUserShelve carve-out (instance-sync.js:855)"); + assert.equal((await statusOf(db, "parked")).status, "shelf"); +}); + +test("promoteFromShelf breaks a created_at tie by the lower egg_id, so two instances agree", async () => { + const db = await freshDb(); + await shelveEgg(db, "bbb", T0); + await shelveEgg(db, "aaa", T0); + const promoted = await promoteFromShelf(db, { now: T0 }); + assert.equal(promoted.egg_id, "aaa"); +}); + +test("promoteFromShelf takes a RECEIVED (gifted) egg too", async () => { + const db = await freshDb(); + await shelveEgg(db, "gift", T0, { status: "received" }); + const promoted = await promoteFromShelf(db, { now: T0 }); + assert.equal(promoted.egg_id, "gift"); +}); + +test("promoteFromShelf SKIPS an egg spoken for by an open swap", async () => { + const db = await freshDb(); + await shelveEgg(db, "locked-one", T0); + await shelveEgg(db, "free-one", T0 + 1000); + // ⚠ 'proposed', not 'offered'. OPEN_SQL is "state IN ('proposed','accepted')", + // so a made-up state would leave the egg UNLOCKED and this test would be + // asserting nothing about locking. counterpart/role/expires_at are NOT NULL + // with no defaults — omitting them fails on the constraint, not the feature. + await db.execute({ + sql: `INSERT INTO ramble_trades + (trade_id, counterpart, role, my_egg_id, state, created_at, updated_at, expires_at) + VALUES ('t1', 'npub-them', 'proposer', 'locked-one', 'proposed', ?, ?, ?)`, + args: [T0, T0, T0 + 7 * 86400000], + }); + const promoted = await promoteFromShelf(db, { now: T0 + 2000 }); + assert.equal(promoted.egg_id, "free-one", "an egg promised to a contact is not drafted"); + assert.equal((await statusOf(db, "locked-one")).status, "shelf"); +}); + +test("an EXPIRED but unswept trade still locks its egg — do not 'fix' the predicate", async () => { + // expireTrades runs on the 15 s drain tick, so there is a window where a + // lapsed offer is still 'proposed' and its egg stays locked. Once it + // expires the egg is promotable again, but nothing auto-promotes it — the + // panel offers it instead (Task 7's "one's waiting on your shelf"). Pinned + // here so nobody widens OPEN_SQL to "fix" the window. + const db = await freshDb(); + await shelveEgg(db, "only-one", T0); + await db.execute({ + sql: `INSERT INTO ramble_trades + (trade_id, counterpart, role, my_egg_id, state, created_at, updated_at, expires_at) + VALUES ('t-expired', 'npub-them', 'proposer', 'only-one', 'proposed', ?, ?, ?)`, + args: [T0, T0, T0 - 1000], // already past expires_at, not yet swept + }); + assert.equal(await promoteFromShelf(db, { now: T0 + 5000 }), null); +}); + +test("nextPromotable answers the same question the promote acts on, and writes nothing", async () => { + const db = await freshDb(); + assert.equal(await nextPromotable(db), null); + await shelveEgg(db, "younger", T0 + 5000); + await shelveEgg(db, "older", T0); + + const peek = await nextPromotable(db); + assert.equal(peek.egg_id, "older"); + assert.equal((await statusOf(db, "older")).status, "shelf", "a peek must not move it"); + + const promoted = await promoteFromShelf(db, { now: T0 + 9000 }); + assert.equal(promoted.egg_id, peek.egg_id, "the card and the promote read ONE rule"); + assert.equal(await nextPromotable(db), null, "the slot is full now"); +}); + +test("promoteFromShelf is a NO-OP when the slot is full, and when there is nothing to promote", async () => { + const db = await freshDb(); + assert.equal(await promoteFromShelf(db, { now: T0 }), null, "empty shelf, empty slot"); + assert.equal(await eggCount(db), 0, "a no-op promote writes NOTHING — this is what makes it safe on a GET"); + + const sitting = await mintIncubatingEgg(db, { now: T0 }); + await shelveEgg(db, "waiting", T0 - 5000); + assert.equal(await promoteFromShelf(db, { now: T0 }), null, "the slot is occupied"); + assert.equal((await statusOf(db, "waiting")).status, "shelf"); + assert.equal((await getIncubatingEgg(db)).egg_id, sitting.egg_id); +}); + +test("hatching promotes from the shelf instead of minting a successor", async () => { + const db = await freshDb(); + const egg = await mintIncubatingEgg(db, { now: T0 }); + await shelveEgg(db, "next-you", T0 + 100); + await db.execute({ sql: "UPDATE ramble_eggs SET warmth = 100 WHERE egg_id = ?", args: [egg.egg_id] }); + + const hatched = await hatchIfReady(db, { now: T0 + 1000 }); + assert.ok(hatched, "it hatched"); + assert.equal(await eggCount(db), 2, "NO successor was minted"); + assert.equal((await getIncubatingEgg(db)).egg_id, "next-you", "the shelf refilled the slot"); +}); + +test("hatching with an EMPTY shelf leaves the slot empty — no free egg", async () => { + const db = await freshDb(); + const egg = await mintIncubatingEgg(db, { now: T0 }); + await db.execute({ sql: "UPDATE ramble_eggs SET warmth = 100 WHERE egg_id = ?", args: [egg.egg_id] }); + + const hatched = await hatchIfReady(db, { now: T0 + 1000 }); + assert.ok(hatched); + assert.equal(await getIncubatingEgg(db), null, "this is the whole phase: no successor appears"); + assert.equal(await eggCount(db), 1); +}); + +test("two instances promote the SAME egg independently, with no round trip", async () => { + const a = await freshDb(); + const b = await freshDb(); + for (const db of [a, b]) { + await shelveEgg(db, "zzz", T0); + await shelveEgg(db, "aaa", T0); // same created_at: the tie-break decides + await shelveEgg(db, "mmm", T0 + 1); + } + const pa = await promoteFromShelf(a, { now: T0 + 100 }); + const pb = await promoteFromShelf(b, { now: T0 + 100 }); + assert.equal(pa.egg_id, pb.egg_id, "the order is a pure function of replicated rows"); + assert.equal(pa.egg_id, "aaa"); +}); diff --git a/tests/ramble-eggs.test.js b/tests/ramble-eggs.test.js index 346a49f4..8fc33851 100644 --- a/tests/ramble-eggs.test.js +++ b/tests/ramble-eggs.test.js @@ -36,7 +36,7 @@ test("weights come from settings when set", async () => { assert.ok(rows[0].warmth >= 3); }); -test("hatch at the threshold: rolls a roster species + uint32 seed, activates, and starts the next egg", async () => { +test("hatch at the threshold: rolls a roster species + uint32 seed, activates, and mints no successor", async () => { const emitted = []; const emit = async (t, op, row) => emitted.push([t, op, row.egg_id || row.owner]); let r; @@ -47,17 +47,33 @@ test("hatch at the threshold: rolls a roster species + uint32 seed, activates, a const bird = await activeBird(db); assert.equal(bird.egg_id, r.hatched.egg_id); const { rows } = await db.execute("SELECT status, count(*) AS n FROM ramble_eggs GROUP BY status ORDER BY status"); - assert.deepEqual(rows.map((x) => [x.status, x.n]), [["hatched", 1], ["incubating", 1]]); + assert.deepEqual(rows.map((x) => [x.status, x.n]), [["hatched", 1]], "no successor was minted, and the shelf is empty"); assert.ok(emitted.some(([t, op]) => t === "ramble_eggs" && op === "update")); assert.ok(emitted.some(([t]) => t === "ramble_pet")); assert.equal(await hatchIfReady(db, { now: T0 }), null); // nothing else ready }); -test("eggState reports percent + checklist", async () => { +test("eggState reports egg: null right after a hatch, then percent + checklist once the shelf promotes", async () => { + const empty = await eggState(db, { now: T0 }); + assert.equal(empty.egg, null, "no successor was minted"); + assert.equal(typeof empty.checklist.new_places_week, "number"); + assert.equal(empty.checklist.checked_in_today, true); + + // Nothing else exercises a non-zero percent any more, now that nothing + // auto-mints: shelve an egg, mint+hatch a throwaway incubating egg so the + // hatch's own promoteFromShelf draws the shelved one in, then credit it. + await db.execute({ + sql: "INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, created_at) VALUES (?, 'shelf', 'user', 0, ?)", + args: ["waiting-on-shelf", T0], + }); + const throwaway = await mintIncubatingEgg(db, { now: T0 }); + await db.execute({ sql: "UPDATE ramble_eggs SET warmth = ? WHERE egg_id = ?", args: [WARMTH_DEFAULTS.hatch_at, throwaway.egg_id] }); + assert.ok(await hatchIfReady(db, { now: T0 }), "the throwaway egg hatched"); + + await creditWarmth(db, { type: "mark_left" }, { now: T0 }); const s = await eggState(db, { now: T0 }); - assert.ok(s.egg.egg_id); assert.equal(typeof s.egg.percent, "number"); - assert.equal(typeof s.checklist.new_places_week, "number"); - assert.equal(s.checklist.checked_in_today, true); + assert.equal(s.egg.egg_id, "waiting-on-shelf", "the shelf refilled the slot"); + assert.ok(s.egg.percent > 0, "the shelved egg now carries warmth"); }); test("week and day keys", () => { @@ -67,24 +83,26 @@ test("week and day keys", () => { test("visit_place without cell never credits (not treated as always-credited)", async () => { const before = await eggState(db, { now: T0 }); + const beforeWarmth = before.egg ? before.egg.warmth : 0; // Phase 3: a hatch can leave egg: null for (let i = 0; i < 3; i++) { const r = await creditWarmth(db, { type: "visit_place" }, { now: T0 }); assert.equal(r.credited, false); - assert.equal(r.warmth, before.egg.warmth); + assert.equal(r.warmth, beforeWarmth); } const after = await eggState(db, { now: T0 }); - assert.equal(after.egg.warmth, before.egg.warmth); + assert.equal(after.egg ? after.egg.warmth : 0, beforeWarmth); }); test("meet_crow without persona never credits (not treated as always-credited)", async () => { const before = await eggState(db, { now: T0 }); + const beforeWarmth = before.egg ? before.egg.warmth : 0; // Phase 3: a hatch can leave egg: null for (let i = 0; i < 3; i++) { const r = await creditWarmth(db, { type: "meet_crow" }, { now: T0 }); assert.equal(r.credited, false); - assert.equal(r.warmth, before.egg.warmth); + assert.equal(r.warmth, beforeWarmth); } const after = await eggState(db, { now: T0 }); - assert.equal(after.egg.warmth, before.egg.warmth); + assert.equal(after.egg ? after.egg.warmth : 0, beforeWarmth); }); test("chore on a fresh db is a pure read: no egg created, nothing emitted", async () => { diff --git a/tests/ramble-flock.test.js b/tests/ramble-flock.test.js index 21dd082d..08a4cee7 100644 --- a/tests/ramble-flock.test.js +++ b/tests/ramble-flock.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { encodeGeohash } from "../bundles/ramble/server/anchors.js"; -import { isoWeek, mintIncubatingEgg } from "../bundles/ramble/server/eggs.js"; +import { isoWeek, mintIncubatingEgg, getIncubatingEgg } from "../bundles/ramble/server/eggs.js"; import { nestFor, CELL7_LAT_STEP } from "../bundles/ramble/server/nests.js"; import { readFlockSettings, listNests, claimNest, @@ -141,13 +141,15 @@ test("incubateEgg swaps the slot: old egg shelved as 'user', target incubating, assert.deepEqual(await incubateEgg(d, "h1", { now: T0 }), { ok: false, reason: "not-an-egg" }); }); -test("incubateEgg hatches a swapped-in egg that is already past the threshold", async () => { +test("the parked egg is promoted back into the slot", async () => { const d = await freshDb(); - await mintIncubatingEgg(d, { now: T0 }); + const parked = await mintIncubatingEgg(d, { now: T0 }); await d.execute("INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, created_at) VALUES ('hot','shelf','user',100,5)"); const r = await incubateEgg(d, "hot", { now: T0 }); assert.ok(r.hatched && r.hatched.egg_id === "hot" && typeof r.hatched.species === "string"); - assert.equal((await d.execute("SELECT count(*) AS n FROM ramble_eggs WHERE status='incubating'")).rows[0].n, 1, "a successor egg was minted"); + // parked is 'user'-shelved by incubateEgg's swap, then hatchIfReady's own + // promoteFromShelf draws it straight back in — no successor was minted. + assert.equal((await getIncubatingEgg(d)).egg_id, parked.egg_id, "promoteFromShelf drew the parked egg back in"); }); test("activateBird points the pet at a hatched egg and refuses anything else", async () => { @@ -164,12 +166,15 @@ test("activateBird points the pet at a hatched egg and refuses anything else", a test("flockState: birds with the active one marked, eggs incubating-first, species count, shelf cap", async () => { const d = await freshDb(); + // Phase 3: flockState is a pure read and mints nothing, so the fixture + // must supply its own incubating egg explicitly. + await mintIncubatingEgg(d, { now: T0 }); await d.execute("INSERT INTO ramble_eggs (egg_id, status, warmth, species, seed, created_at, hatched_at) VALUES ('b1','hatched',100,'raven',9,1,20), ('b2','hatched',100,'crow',3,2,10), ('b3','hatched',100,'raven',4,3,30)"); await d.execute("INSERT INTO ramble_eggs (egg_id, status, shelf_origin, warmth, found_cell, found_week, created_at) VALUES ('s1','shelf','user',50,'9v6m21h','2026-W37',100), ('s0','shelf','sync',5,NULL,NULL,50)"); await activateBird(d, "b2"); const s = await flockState(d, { now: T0 }); assert.deepEqual(s.birds.map((b) => [b.egg_id, b.species, b.active]), [["b2", "crow", true], ["b1", "raven", false], ["b3", "raven", false]]); - assert.equal(s.eggs[0].status, "incubating", "the incubating egg is ensured and listed first"); + assert.equal(s.eggs[0].status, "incubating", "the fixture's own egg is listed first"); assert.deepEqual(s.eggs.slice(1).map((e) => [e.egg_id, e.status, e.percent, e.shelf_origin]), [["s0", "shelf", 5, "sync"], ["s1", "shelf", 50, "user"]]); // shelf_count is the user's own eggs (s1); the sync loser s0 is listed but does not use a spot. assert.deepEqual([s.shelf_count, s.shelf_cap, s.species_found, s.species_total, s.species.length], [1, SHELF_CAP_DEFAULT, 2, 8, 8]); diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index 511c9ead..543530b0 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -50,9 +50,10 @@ const { bboxAround } = await import("../bundles/ramble/server/around.js"); * Fog gates public terrain (spec 2026-09-08 §2.1), so a test that lists or * claims a nest must first walk to ground that actually unlocks it. Each walk * posts /api/ramble/area with `here`, which credits +20 visit_place warmth - * against a hatch_at of 100 — this file already churns hatches and later - * asserts an incubating egg exists, so the credit is suppressed around the - * walk rather than left to accumulate. + * against a hatch_at of 100 — this file's other warmth-accrual tests already + * push some hatches, and Phase 3 hatches no longer guarantee a successor + * (they promote from the shelf, or leave the slot empty), so the credit is + * suppressed around the walk rather than left to accumulate on top of that. * * ⚠ An unlock is permanent (spec §2.1): once a test calls this, that cell * stays unlocked for every test that runs afterward in this file. A fog @@ -147,9 +148,11 @@ const PK_BLOCKED = "ed".repeat(32); // Task 2 (spec 2026-09-08 §4.1): minting is deliberate now — a read no longer // creates an egg by being looked at. This suite's warmth-accrual tests are // about the DELTA an event credits, not about egg supply, so give them an -// explicit starter egg here rather than weaken any of their assertions; the -// file's own hatch churn (see the walkTo/unlockRadius comment above) keeps an -// incubating egg in place for the rest of the run. +// explicit starter egg here rather than weaken any of their assertions. +// ⚠ Phase 3: a hatch no longer guarantees a successor (hatchIfReady promotes +// from the shelf, or leaves the slot empty) — any test past this point that +// needs an incubating egg to exist must mint its own rather than assume this +// starter, or the file's later churn, left one in place. { const db = createDbClient(); try { @@ -1234,6 +1237,15 @@ test("POST /api/ramble/nests/claim: too far is a friendly refusal; in range clai }); test("GET /api/ramble/flock shows the shelf egg; incubate swaps it in and shelves the old egg as 'user'", async () => { + // Phase 3: flockState no longer mints or promotes on a read. This file's + // earlier warmth churn may already have hatched the incubating egg with + // nothing yet on the shelf to refill it (promoteFromShelf only runs from + // hatchIfReady), so make sure one exists rather than assume the old + // auto-mint left one lying around. + { + const db = createDbClient(); + try { await mintIncubatingEgg(db, { now: Date.now() }); } finally { db.close(); } + } const flock = await (await req("/api/ramble/flock")).json(); assert.equal(flock.species_total, 8); assert.equal(flock.shelf_cap, 5); @@ -1700,9 +1712,10 @@ async function withHeartSettings(pairs, fn) { } // `warmth.visit_place` is zeroed for the same reason walkTo() zeroes it: this -// file churns hatches and later asserts an incubating egg exists, and three or -// four +20 credits against a hatch_at of 100 is a hatch these tests did not ask -// for. +// file's warmth-accrual tests already churn hatches, and three or four +20 +// credits against a hatch_at of 100 is a hatch these tests did not ask for — +// and since Phase 3 a hatch is not guaranteed to leave a successor egg in +// place at all. const HEARTS_ON = [ ["heart.rate", "1"], ["heart.wild.rate", "999999"], ["unlock.max.accuracy.m", "100"], ["warmth.visit_place", "0"], From 92d92dbb72a5029396c7e7946ec33362d7cebb1e Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 15:18:46 -0500 Subject: [PATCH 11/20] ramble: an eggless bird lays after sustained care Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/server/eggs.js | 110 +++++++++++++++++++ bundles/ramble/server/pet.js | 8 +- tests/ramble-laying.test.js | 192 ++++++++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 tests/ramble-laying.test.js diff --git a/bundles/ramble/server/eggs.js b/bundles/ramble/server/eggs.js index 93e48cf0..18790be4 100644 --- a/bundles/ramble/server/eggs.js +++ b/bundles/ramble/server/eggs.js @@ -441,6 +441,116 @@ export async function activeBird(db) { return rows[0] ?? null; } +export const LAY_DAYS_DEFAULT = 14; +export const LAYDAY_KIND = "layday"; +export const LAY_KIND = "lay"; + +function intSetting(raw, fallback, min) { + if (raw == null) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n >= min ? n : fallback; +} + +/** `lay.days` (>= 1, default 14), read live so balance is a config change. */ +export async function readLaySettings(db) { + return { layDays: intSetting(await readSetting(db, "lay.days"), LAY_DAYS_DEFAULT, 1) }; +} + +/** + * Does the user hold an egg ANYWHERE — the slot, the shelf, or a gift not yet + * dealt with? A hatched bird is not an egg: you are not warming it. + */ +export async function hasAnyEggAnywhere(db) { + const { rows } = await db.execute({ + sql: `SELECT 1 FROM ramble_eggs WHERE status IN ('incubating', 'shelf', 'received') LIMIT 1`, + args: [], + }); + return rows.length > 0; +} + +/** + * Happy days banked since the last lay. The count RESETS without deleting a + * row: `lay` rows mark each laying, and only `layday` rows AFTER the most + * recent one count. The ledger stays append-only (spec §6.1). + * + * ⚠ ORDERED BY `key`, NEVER BY `created_at`. Both are tempting; only one + * converges. `applyRambleWallet` resolves a conflict with + * `created_at = MIN(local, incoming)` (instance-sync.js:620), so a sync apply + * can move a row's timestamp BACKWARDS — across the reset boundary, in either + * direction — and clock skew between the user's machines is enough to do it + * on its own. `key` is the local day (`YYYY-MM-DD`), it is half the primary + * key, it sorts lexically in true date order, and `applyRambleWallet` never + * rewrites it. Comparing keys therefore yields the same number on every + * instance from the same rows. It also excludes the lay-day itself, which is + * correct: the day you laid is spent. + */ +export async function layProgress(db) { + const { layDays } = await readLaySettings(db); + const { rows } = await db.execute({ + sql: `SELECT count(*) AS n FROM ramble_wallet + WHERE kind = ? + AND key > COALESCE((SELECT MAX(key) FROM ramble_wallet WHERE kind = ?), '')`, + args: [LAYDAY_KIND, LAY_KIND], + }); + return { days: Number(rows[0]?.n ?? 0), needed: layDays }; +} + +/** + * Count today toward laying, and lay if the threshold is reached (spec §4.3). + * + * Called from the pet's read and feed paths, so "ends the day happy" is really + * "was observed happy on this local day". The alternative — judging the last + * observation of the day — would punish opening the app after a good walk. + * + * Accrues ONLY while the user holds no egg anywhere. Were it always accruing, + * a player would run dry and lay at once, and the floor would become the main + * supply instead of a backstop. + * + * ⚠ delta is the literal 1. See applyRambleWallet's MAX(delta) rule. + * + * ⚠ Called ONLY from the write paths (`feed`, and `doChore` through it) — + * never from `petState`. `pet.js:18` records the invariant: "petState's + * decay-on-read write never emits, because a GET must never queue a sync op", + * and `petState` has no `emit` in scope to pass. A day is therefore earned by + * DOING something — a walk, a chore, a check-in — not by opening the app, + * which is also the truer reading of §4.3's "sustained care". + * + * ⚠ Laying REQUIRES real movement, and that is a design consequence, not an + * oversight. Decay is 10 per 6 h (-40/day); the most a player who never posts + * a location fix can earn is checkin 5 + 3 chores x 8 = 29/day. From the + * default 60 they bank three happy days and then fall below the 60 threshold + * for good. Do NOT write, in a comment or a doc, that chores and the check-in + * alone can reach `lay.days`. They cannot. + */ +export async function recordHappyDay(db, { now = Date.now(), mood, emit } = {}) { + if (mood !== "happy") return { recorded: false, laid: false }; + if (await hasAnyEggAnywhere(db)) return { recorded: false, laid: false }; + + const key = localDay(now); + const { rowsAffected } = await db.execute({ + sql: `INSERT OR IGNORE INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?)`, + args: [LAYDAY_KIND, key, now], + }); + if (rowsAffected === 0) return { recorded: false, laid: false }; + await safeEmit(emit, "ramble_wallet", "insert", { kind: LAYDAY_KIND, key, delta: 1, created_at: now }); + + const { days, needed } = await layProgress(db); + if (days < needed) return { recorded: true, laid: false }; + + // ⚠ The mint is gated on the `lay` row being NEW. Without checking + // rowsAffected the dedup key just written would be decorative, and two + // overlapping calls would each mint an egg. + const { rowsAffected: laidNow } = await db.execute({ + sql: `INSERT OR IGNORE INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?)`, + args: [LAY_KIND, key, now], + }); + if (laidNow === 0) return { recorded: true, laid: false }; + + await safeEmit(emit, "ramble_wallet", "insert", { kind: LAY_KIND, key, delta: 1, created_at: now }); + await mintIncubatingEgg(db, { now, emit }); + return { recorded: true, laid: true }; +} + export async function eggState(db, { now } = {}) { const egg = await getIncubatingEgg(db); const weights = await readWarmthWeights(db); diff --git a/bundles/ramble/server/pet.js b/bundles/ramble/server/pet.js index e5f106f2..c7752d35 100644 --- a/bundles/ramble/server/pet.js +++ b/bundles/ramble/server/pet.js @@ -20,7 +20,7 @@ * its own decay independently); last-writer-wins on the next sync settles it. */ -import { localDay } from "./eggs.js"; +import { localDay, recordHappyDay } from "./eggs.js"; import { maxEnergy, ENERGY_MAX_BASE_DEFAULT } from "./hearts.js"; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; @@ -159,6 +159,12 @@ export async function feed(db, event, { now = Date.now(), emit } = {}) { const updated = await ensureRow(db); await safeEmit(emit, "ramble_pet", "update", updated); + // Phase 3 (spec §4.3): a day counts when the bird is happy while wholly + // eggless. Idempotent per local day, and a no-op the moment the player + // holds any egg. `doChore` reaches this through feed(), so chores and the + // daily check-in both count. + await recordHappyDay(db, { now, mood, emit }); + return { owner: "self", mood, energy, energy_max: max, places_week, unlocks_week, crows_week, week_start, last_fed_at }; } diff --git a/tests/ramble-laying.test.js b/tests/ramble-laying.test.js new file mode 100644 index 00000000..7cb847cb --- /dev/null +++ b/tests/ramble-laying.test.js @@ -0,0 +1,192 @@ +/** + * Spec 2026-09-08 §4.3 — the laying floor. + * + * The count accrues ONLY while the user has no eggs at all. Were it always + * accruing, a player would run dry and lay almost immediately, undercutting + * nests as the real supply. + * + * Rows live in ramble_wallet with delta ALWAYS the literal 1: applyRambleWallet + * resolves conflicts with MAX(delta), which is only convergent when the value + * cannot differ between instances for the same key. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createClient } from "@libsql/client"; +import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; +import { + mintIncubatingEgg, getIncubatingEgg, recordHappyDay, layProgress, + hasAnyEggAnywhere, readLaySettings, LAY_DAYS_DEFAULT, localDay, +} from "../bundles/ramble/server/eggs.js"; +import { applyRambleWallet } from "../servers/sharing/instance-sync.js"; + +const DAY = 86400000; +const T0 = Date.UTC(2026, 8, 9, 12, 0, 0); + +async function freshDb() { + const db = createClient({ url: ":memory:" }); + await initRambleTables(db); + return db; +} +async function eggCount(db) { + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_eggs", args: [] }); + return Number(rows[0].n); +} +async function setLayDays(db, n) { + await db.execute({ sql: "INSERT INTO ramble_settings (key, value) VALUES ('lay.days', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", args: [String(n)] }); +} + +test("the default is the spec's 14", async () => { + const db = await freshDb(); + assert.equal(LAY_DAYS_DEFAULT, 14); + assert.deepEqual(await readLaySettings(db), { layDays: 14 }); + await setLayDays(db, 10); + assert.deepEqual(await readLaySettings(db), { layDays: 10 }); + await setLayDays(db, 0); + assert.deepEqual(await readLaySettings(db), { layDays: 14 }, "a junk setting falls back"); +}); + +test("hasAnyEggAnywhere counts eggs, not birds", async () => { + const db = await freshDb(); + assert.equal(await hasAnyEggAnywhere(db), false); + await mintIncubatingEgg(db, { now: T0 }); + assert.equal(await hasAnyEggAnywhere(db), true); + + const db2 = await freshDb(); + await db2.execute({ + sql: `INSERT INTO ramble_eggs (egg_id, status, species, seed, warmth, created_at, hatched_at) + VALUES ('bird', 'hatched', 'wren', 7, 100, ?, ?)`, args: [T0, T0], + }); + assert.equal(await hasAnyEggAnywhere(db2), false, "a hatched bird is not an egg you are warming"); +}); + +test("a happy day accrues ONLY while eggless, and only once per local day", async () => { + const db = await freshDb(); + const first = await recordHappyDay(db, { now: T0, mood: "happy" }); + assert.equal(first.recorded, true); + assert.equal((await layProgress(db)).days, 1); + + const again = await recordHappyDay(db, { now: T0 + 3600000, mood: "happy" }); + assert.equal(again.recorded, false, "same local day"); + assert.equal((await layProgress(db)).days, 1); + + await recordHappyDay(db, { now: T0 + DAY, mood: "tired" }); + assert.equal((await layProgress(db)).days, 1, "a tired day does not count"); + + await mintIncubatingEgg(db, { now: T0 + 2 * DAY }); + await recordHappyDay(db, { now: T0 + 2 * DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 1, "with an egg in hand, nothing accrues"); +}); + +test("days need NOT be consecutive", async () => { + const db = await freshDb(); + await setLayDays(db, 3); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "alarmed" }); + await recordHappyDay(db, { now: T0 + 5 * DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 2, "one bad day did not erase the streak"); + assert.equal(await eggCount(db), 0); +}); + +test("at the threshold the bird lays, and the count resets", async () => { + const db = await freshDb(); + await setLayDays(db, 3); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "happy" }); + assert.equal(await eggCount(db), 0, "not yet"); + + const out = await recordHappyDay(db, { now: T0 + 2 * DAY, mood: "happy" }); + assert.equal(out.laid, true); + assert.equal(await eggCount(db), 1); + const egg = await getIncubatingEgg(db); + assert.ok(egg, "the laid egg goes straight into the empty slot"); + assert.equal(egg.warmth, 0); + + assert.equal((await layProgress(db)).days, 0, "the count reset without deleting a single row"); + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_wallet WHERE kind = 'layday'", args: [] }); + assert.equal(Number(rows[0].n), 3, "the ledger is append-only"); +}); + +test("after laying, the count starts again only once the player is eggless again", async () => { + const db = await freshDb(); + await setLayDays(db, 2); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "happy" }); + assert.equal(await eggCount(db), 1); + + await recordHappyDay(db, { now: T0 + 2 * DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 0, "holding an egg, nothing accrues"); + + await db.execute({ sql: "DELETE FROM ramble_eggs", args: [] }); // stand-in for hatching it away + await recordHappyDay(db, { now: T0 + 3 * DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 1, "eggless again: the counter resumes from zero"); +}); + +test("every layday row carries delta exactly 1, whatever lay.days is set to", async () => { + const db = await freshDb(); + await setLayDays(db, 25); + await recordHappyDay(db, { now: T0, mood: "happy" }); + const { rows } = await db.execute({ sql: "SELECT delta FROM ramble_wallet WHERE kind = 'layday'", args: [] }); + assert.equal(Number(rows[0].delta), 1, "MAX(delta) is only convergent on a constant"); +}); + +test("layProgress reports what the panel needs", async () => { + const db = await freshDb(); + assert.deepEqual(await layProgress(db), { days: 0, needed: 14 }); + await recordHappyDay(db, { now: T0, mood: "happy" }); + assert.deepEqual(await layProgress(db), { days: 1, needed: 14 }); +}); + +/* ------------------------------------------------------- multi-instance + * Spec §8: "every currency ledger replicates, so both need multi-instance + * tests, not single-database ones." A sync defect already cost this project + * real data; prose review is not sufficient here. The pattern below follows + * tests/ramble-cells-sync.test.js. + */ + +test("MULTI-INSTANCE: a peer's layday rows converge to the same count", async () => { + const a = await freshDb(); + const b = await freshDb(); + await setLayDays(a, 5); await setLayDays(b, 5); + + const rows = []; + const emit = (table, op, row) => { if (table === "ramble_wallet") rows.push(row); }; + await recordHappyDay(a, { now: T0, mood: "happy", emit }); + await recordHappyDay(a, { now: T0 + DAY, mood: "happy", emit }); + + for (const r of rows) await applyRambleWallet(b, "insert", r, 1); + assert.equal((await layProgress(b)).days, 2, "b sees a's days"); + assert.equal((await layProgress(a)).days, 2, "and a is unchanged"); +}); + +test("MULTI-INSTANCE: created_at going BACKWARDS on apply must not change the count", async () => { + // applyRambleWallet does created_at = MIN(local, incoming). This is exactly + // why layProgress orders by `key` and not by `created_at`: an apply can move + // a row's timestamp across the reset boundary, and clock skew between the + // user's own machines is enough to do it. + const db = await freshDb(); + await setLayDays(db, 99); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "happy" }); + const before = (await layProgress(db)).days; + + await applyRambleWallet(db, "insert", + { kind: "layday", key: localDay(T0 + DAY), delta: 1, created_at: 0 }, 9); + + assert.equal((await layProgress(db)).days, before, + "a rewritten created_at must not move a day in or out of the count"); +}); + +test("MULTI-INSTANCE: a peer's lay row resets this instance's count too", async () => { + const db = await freshDb(); + await setLayDays(db, 99); + await recordHappyDay(db, { now: T0, mood: "happy" }); + await recordHappyDay(db, { now: T0 + DAY, mood: "happy" }); + assert.equal((await layProgress(db)).days, 2); + + // The peer laid on the later day. + await applyRambleWallet(db, "insert", + { kind: "lay", key: localDay(T0 + DAY), delta: 1, created_at: T0 + DAY }, 5); + + assert.equal((await layProgress(db)).days, 0, + "both instances agree the count is spent, with nothing deleted"); +}); From 32c351eea854533a15784a38e371815b25596563 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 15:24:37 -0500 Subject: [PATCH 12/20] ramble: one starter egg, once ever, and the two prologue flags Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/server/eggs.js | 65 ++++++++++++++++++++++++ tests/ramble-prologue.test.js | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 tests/ramble-prologue.test.js diff --git a/bundles/ramble/server/eggs.js b/bundles/ramble/server/eggs.js index 18790be4..cea331e8 100644 --- a/bundles/ramble/server/eggs.js +++ b/bundles/ramble/server/eggs.js @@ -585,3 +585,68 @@ export async function eggState(db, { now } = {}) { checklist: { new_places_week: newPlacesWeek, first_mark: firstMark, checked_in_today: checkedInToday }, }; } + +export const PROLOGUE_INTRO_KEY = "prologue.intro.seen"; +export const PROLOGUE_HATCH_KEY = "prologue.hatch.seen"; +const PROLOGUE_KEYS = { intro: PROLOGUE_INTRO_KEY, hatch: PROLOGUE_HATCH_KEY }; + +/** + * Mirrors grid.js's private writeSetting rather than importing it: eggs.js has + * no other reason to depend on grid.js, and the SQL is one statement. + */ +async function writeSetting(db, key, value, { emit } = {}) { + await db.execute({ + sql: `INSERT INTO ramble_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + args: [key, value], + }); + await safeEmit(emit, "ramble_settings", "update", { key, value }); +} + +/** + * Has ANY egg ever existed on this fleet — including one that has since + * hatched. Read from ramble_eggs, which replicates, so the answer is the same + * on every one of the user's Crows and a data wipe genuinely resets it (K1). + */ +async function anyEggEverExisted(db) { + const { rows } = await db.execute({ sql: "SELECT 1 FROM ramble_eggs LIMIT 1", args: [] }); + return rows.length > 0; +} + +export async function readPrologue(db) { + return { + intro_seen: (await readSetting(db, PROLOGUE_INTRO_KEY)) === "1", + hatch_seen: (await readSetting(db, PROLOGUE_HATCH_KEY)) === "1", + granted: await anyEggEverExisted(db), + }; +} + +export async function setPrologueSeen(db, which, { emit } = {}) { + // Object.hasOwn, not a truthiness check: PROLOGUE_KEYS is a plain literal, + // so `setPrologueSeen(db, "constructor")` would otherwise return a function + // and bind it into the SQL args instead of throwing. + const key = Object.hasOwn(PROLOGUE_KEYS, which) ? PROLOGUE_KEYS[which] : null; + if (!key) throw new Error(`unknown prologue beat: ${which}`); + await writeSetting(db, key, "1", { emit }); +} + +/** + * One starter egg, once ever (spec §4.4, D12), granted as a narrative gift. + * + * ⚠ The spec says to derive the id from the Crow identity so a simultaneous + * two-instance first run collapses into one insert. That cannot work: + * loadOrCreateIdentity generates a RANDOM PER-INSTANCE seed, so crowId differs + * between the user's own Crows and deriving from it would grant two eggs — the + * outcome it was meant to prevent. A fixed constant is worse still: a contact + * could gift you their starter egg and the ids would collide in the + * receivedEggStatement upsert. + * + * So: a random uuid, gated on the egg table being empty. If a genuine race + * ever happened, applyRambleEgg's existing "one incubating egg" rule keeps the + * older and SHELVES the younger with its warmth intact — a spare egg, not a + * duplicate disaster. That machinery is already built and tested. + */ +export async function grantStarterEgg(db, { now = Date.now(), emit } = {}) { + if (await anyEggEverExisted(db)) return null; + return mintIncubatingEgg(db, { now, emit }); +} diff --git a/tests/ramble-prologue.test.js b/tests/ramble-prologue.test.js new file mode 100644 index 00000000..065763ba --- /dev/null +++ b/tests/ramble-prologue.test.js @@ -0,0 +1,96 @@ +/** + * Spec 2026-09-08 §4.4 — one starter egg, once ever, as a narrative gift. + * + * ⚠ The grant condition is "has any egg EVER existed", read from ramble_eggs + * itself, NOT a flag. Kevin intends to reset his game state to a new game once + * phases 3 and 4 land, specifically to play the prologue as a new player; a + * flag that outlived the wipe would silently make that reset useless. + * + * ⚠ The spec's own race protection ("derive the id from the Crow identity") + * cannot work: loadOrCreateIdentity generates a random per-INSTANCE seed, so + * crowId differs between a user's own Crows and deriving from it would grant + * TWO eggs. A random uuid plus the existing applyRambleEgg convergence rule + * (older survives incubating, younger is shelved with its warmth) is the + * graceful answer. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createClient } from "@libsql/client"; +import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; +import { + grantStarterEgg, readPrologue, setPrologueSeen, getIncubatingEgg, mintIncubatingEgg, +} from "../bundles/ramble/server/eggs.js"; + +const T0 = Date.UTC(2026, 8, 9, 12, 0, 0); + +async function freshDb() { + const db = createClient({ url: ":memory:" }); + await initRambleTables(db); + return db; +} +async function eggCount(db) { + const { rows } = await db.execute({ sql: "SELECT count(*) AS n FROM ramble_eggs", args: [] }); + return Number(rows[0].n); +} + +test("a fresh player is granted exactly one starter egg, into the slot", async () => { + const db = await freshDb(); + assert.deepEqual(await readPrologue(db), { intro_seen: false, hatch_seen: false, granted: false }); + + const egg = await grantStarterEgg(db, { now: T0 }); + assert.ok(egg, "granted"); + assert.equal(await eggCount(db), 1); + assert.equal((await getIncubatingEgg(db)).egg_id, egg.egg_id); + assert.equal(egg.warmth, 0); + assert.equal((await readPrologue(db)).granted, true); +}); + +test("the grant is once EVER — a hatched bird still counts as an egg having existed", async () => { + const db = await freshDb(); + await grantStarterEgg(db, { now: T0 }); + assert.equal(await grantStarterEgg(db, { now: T0 + 1000 }), null, "twice is a no-op"); + assert.equal(await eggCount(db), 1); + + await db.execute({ sql: "UPDATE ramble_eggs SET status = 'hatched', species = 'wren', seed = 7, hatched_at = ?", args: [T0] }); + assert.equal(await grantStarterEgg(db, { now: T0 + 2000 }), null, + "having hatched and become eggless must NOT re-grant — that would restore the free egg"); + assert.equal(await eggCount(db), 1); +}); + +test("an existing player who already has an egg is never granted one", async () => { + const db = await freshDb(); + await mintIncubatingEgg(db, { now: T0 }); + assert.equal(await grantStarterEgg(db, { now: T0 + 1000 }), null); + assert.equal(await eggCount(db), 1); +}); + +test("wiping the eggs makes the prologue replayable — K1's reset", async () => { + const db = await freshDb(); + await grantStarterEgg(db, { now: T0 }); + await setPrologueSeen(db, "intro"); + await setPrologueSeen(db, "hatch"); + assert.deepEqual(await readPrologue(db), { intro_seen: true, hatch_seen: true, granted: true }); + + // A game reset clears both the eggs and the two flags. + await db.execute({ sql: "DELETE FROM ramble_eggs", args: [] }); + await db.execute({ sql: "DELETE FROM ramble_settings WHERE key LIKE 'prologue.%'", args: [] }); + + assert.deepEqual(await readPrologue(db), { intro_seen: false, hatch_seen: false, granted: false }); + assert.ok(await grantStarterEgg(db, { now: T0 + 5000 }), "the prologue genuinely replays"); +}); + +test("the two flags are independent and survive as replicated settings", async () => { + const db = await freshDb(); + await setPrologueSeen(db, "intro"); + assert.deepEqual(await readPrologue(db), { intro_seen: true, hatch_seen: false, granted: false }); + await setPrologueSeen(db, "hatch"); + assert.equal((await readPrologue(db)).hatch_seen, true); + + const { rows } = await db.execute({ sql: "SELECT key FROM ramble_settings WHERE key LIKE 'prologue.%' ORDER BY key", args: [] }); + assert.deepEqual(rows.map((r) => r.key), ["prologue.hatch.seen", "prologue.intro.seen"]); +}); + +test("setPrologueSeen refuses an unknown beat rather than writing junk", async () => { + const db = await freshDb(); + await assert.rejects(() => setPrologueSeen(db, "nonsense")); +}); From 561c21a1a81c5f18ace463eda69cb8613e5a82fb Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 15:32:19 -0500 Subject: [PATCH 13/20] ramble: the routes answer for a player with no egg Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/panel/routes.js | 37 +++++++- bundles/ramble/server/server.js | 14 ++- tests/ramble-prologue-routes.test.js | 125 +++++++++++++++++++++++++++ tests/ramble-tools.test.js | 12 +++ 4 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 tests/ramble-prologue-routes.test.js diff --git a/bundles/ramble/panel/routes.js b/bundles/ramble/panel/routes.js index 5489935f..7d1ee199 100644 --- a/bundles/ramble/panel/routes.js +++ b/bundles/ramble/panel/routes.js @@ -810,6 +810,10 @@ export default function rambleRouter(dashboardAuth, options = {}) { const pet = await mods.petMod.petState(db, { now }); const bird = await mods.eggsMod.activeBird(db); const egg = await mods.eggsMod.eggState(db, { now }); + const lay = await mods.eggsMod.layProgress(db); + // The egg sitting on the shelf that a tap would incubate, or null. Nothing + // auto-promotes it (see promoteFromShelf's note), so the card offers it. + const waiting = await mods.eggsMod.nextPromotable(db); res.json({ ...pet, bird, @@ -817,6 +821,8 @@ export default function rambleRouter(dashboardAuth, options = {}) { // must not crash for it, so an absent egg is reported as `null`, not // dereferenced. egg: egg.egg ?? null, + shelf_waiting: waiting ? waiting.egg_id : null, + lay, seed: await mods.walletMod.seedBalance(db), hearts: await mods.heartsMod.heartsBalance(db), energy_max_cap: (await mods.heartsMod.readHeartSettings(db)).cap, @@ -836,7 +842,32 @@ export default function rambleRouter(dashboardAuth, options = {}) { // `eggState` and `creditWarmth` have NO internal `now` default (the db layer // refuses an undefined argument), so every call from here passes one. router.get("/api/ramble/egg", handle(async (req, res) => { - res.json(await mods.eggsMod.eggState(db, { now: Date.now() })); + // No promote here. A GET must never queue a sync op, and see + // promoteFromShelf's note: a read-path promote both races the drain and + // launders shelf_origin. hatchIfReady is the only local emptier. + const state = await mods.eggsMod.eggState(db, { now: Date.now() }); + const lay = await mods.eggsMod.layProgress(db); + res.json({ ...state, lay }); + // NOTE: no `shelf_waiting` here. The waiting egg is offered on the PET + // card (Task 7), which reads GET /api/ramble/pet; adding it to this route + // too would give the egg view a field it never paints, and the two + // surfaces would then disagree — the egg view saying "No one on the way" + // while the pet card offers one. One surface owns this affordance. + })); + + router.get("/api/ramble/prologue", handle(async (req, res) => { + res.json(await mods.eggsMod.readPrologue(db)); + })); + + router.post("/api/ramble/prologue/intro", handle(async (req, res) => { + const egg = await mods.eggsMod.grantStarterEgg(db, { now: Date.now(), emit }); + await mods.eggsMod.setPrologueSeen(db, "intro", { emit }); + res.json({ egg: egg ? { egg_id: egg.egg_id, warmth: egg.warmth } : null, intro_seen: true }); + })); + + router.post("/api/ramble/prologue/hatch", handle(async (req, res) => { + await mods.eggsMod.setPrologueSeen(db, "hatch", { emit }); + res.json({ hatch_seen: true }); })); router.post("/api/ramble/egg/checkin", handle(async (req, res) => { @@ -849,6 +880,10 @@ export default function rambleRouter(dashboardAuth, options = {}) { credited, warmth, hatched: hatched ? { egg_id: hatched.egg_id, species: hatched.species, seed: hatched.seed } : null, + // Read AFTER feedAll deliberately: a check-in that hatches the last egg + // with an empty shelf reports false, and "nothing to warm yet" is then + // the true statement about what comes next. + egg: !!(await mods.eggsMod.getIncubatingEgg(db)), }); })); diff --git a/bundles/ramble/server/server.js b/bundles/ramble/server/server.js index 5998bcbc..d9c726ff 100644 --- a/bundles/ramble/server/server.js +++ b/bundles/ramble/server/server.js @@ -27,7 +27,7 @@ import { encodeGeohash } from "./anchors.js"; import { getGrid } from "./grid.js"; import { petState, doChore } from "./pet.js"; import { heartsBalance } from "./hearts.js"; -import { eggState, activeBird, isoWeek } from "./eggs.js"; +import { eggState, activeBird, isoWeek, layProgress, nextPromotable } from "./eggs.js"; import { feedAll } from "./feed.js"; import { flockState, listNests, claimNest } from "./flock.js"; import { resolveContact, resolveAudience, enqueueMark, contactsByPubkey, CROW_ID_RE, ID_RE } from "./delivery.js"; @@ -306,7 +306,12 @@ export function createRambleServer(db, options = {}) { // Task 2 (spec 2026-09-08 §4.1): no egg is a valid state — a read // must not throw for it, so an absent egg is reported as `null`, not // dereferenced. - return text(JSON.stringify({ ...state, bird, hearts: await heartsBalance(db), egg: egg.egg ?? null })); + return text(JSON.stringify({ + ...state, bird, hearts: await heartsBalance(db), + egg: egg.egg ?? null, + lay: await layProgress(db), + shelf_waiting: (await nextPromotable(db))?.egg_id ?? null, + })); } catch (err) { return errorText(err.message); } @@ -319,7 +324,10 @@ export function createRambleServer(db, options = {}) { {}, async () => { try { - return text(JSON.stringify(await eggState(db, { now: Date.now() }))); + const state = await eggState(db, { now: Date.now() }); + // `lay` matches GET /api/ramble/egg. NOT `shelf_waiting` here — that + // affordance belongs to ramble_pet_state / GET /api/ramble/pet only. + return text(JSON.stringify({ ...state, lay: await layProgress(db) })); } catch (err) { return errorText(err.message); } diff --git a/tests/ramble-prologue-routes.test.js b/tests/ramble-prologue-routes.test.js new file mode 100644 index 00000000..bbf90db2 --- /dev/null +++ b/tests/ramble-prologue-routes.test.js @@ -0,0 +1,125 @@ +/** + * Task 6 — the eggless-player routes on a genuinely virgin db. + * + * `tests/ramble-panel.test.js` cannot host these: that file creates ONE + * scratch db for the whole file in declaration order and, by the time its + * later tests run, a nest claim has already minted a starter egg and a + * `layday` row is already banked from a POST /api/ramble/area walk taken + * while eggless. These four assertions need a db that has never seen either. + * + * Harness copied by symbol from ramble-panel.test.js (mkdtemp + env setup, + * the dynamic import of routes.js, the express app + listen, req(), and the + * after() teardown) — see task-6-brief.md Step 1. + */ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { once } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dir, ".."); +const SCRATCH = mkdtempSync(join(tmpdir(), "ramble-prologue-routes-")); + +const savedEnv = { + CROW_APP_ROOT: process.env.CROW_APP_ROOT, + CROW_DATA_DIR: process.env.CROW_DATA_DIR, + CROW_DB_PATH: process.env.CROW_DB_PATH, +}; +process.env.CROW_APP_ROOT = REPO_ROOT; +process.env.CROW_DATA_DIR = SCRATCH; +delete process.env.CROW_DB_PATH; + +// Dynamic import: a static one would be hoisted above the env writes above. +const { default: rambleRouter } = await import("../bundles/ramble/panel/routes.js"); + +const routerInstance = rambleRouter( + (req, res, next) => (req.headers["x-test-auth"] ? next() : res.status(401).end()), + { emit: () => {} }, +); + +const app = express(); +app.use(routerInstance); +const server = app.listen(0); +await once(server, "listening"); +const BASE = `http://127.0.0.1:${server.address().port}`; + +const realFetch = globalThis.fetch.bind(globalThis); + +function req(path, opts = {}) { + const headers = { "x-test-auth": "1", ...(opts.headers || {}) }; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return realFetch(BASE + path, { + method: opts.method || "GET", + headers, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }); +} + +async function get(path) { + const res = await req(path); + return { status: res.status, body: await res.json() }; +} + +async function post(path, body) { + const res = await req(path, { method: "POST", body }); + return { status: res.status, body: await res.json() }; +} + +after(async () => { + await new Promise((r) => server.close(r)); + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { rmSync(SCRATCH, { recursive: true, force: true }); } catch { /* best effort */ } +}); + +// Declaration order matters: tests 1 and 2 assert egg: null on a virgin db, +// test 3 grants the starter egg, test 4 is order-independent. Nothing on +// these four routes mints, and none of them feeds (recordHappyDay is called +// only from pet.js:feed), so no layday row can appear either. + +test("GET /api/ramble/egg reports egg: null on a fresh install and mints nothing", async () => { + const r = await get("/api/ramble/egg"); + assert.equal(r.status, 200); + assert.equal(r.body.egg, null); + assert.ok(r.body.checklist, "the checklist still renders"); + assert.deepEqual(r.body.lay, { days: 0, needed: 14 }); + const again = await get("/api/ramble/egg"); + assert.equal(again.body.egg, null, "reading twice did not conjure one"); +}); + +test("GET /api/ramble/pet carries a null egg and lay progress", async () => { + const r = await get("/api/ramble/pet"); + assert.equal(r.status, 200); + assert.equal(r.body.egg, null); + assert.ok(r.body.lay, "the pet page needs the count for the eggless card"); +}); + +test("POST /api/ramble/prologue/intro grants once and is idempotent", async () => { + const first = await post("/api/ramble/prologue/intro", {}); + assert.equal(first.status, 200); + assert.ok(first.body.egg, "the starter egg arrives with the first beat"); + assert.equal(first.body.intro_seen, true); + + const second = await post("/api/ramble/prologue/intro", {}); + assert.equal(second.status, 200); + assert.equal(second.body.egg, null, "a double-tap grants nothing further"); + + const state = await get("/api/ramble/prologue"); + assert.equal(state.body.intro_seen, true); + assert.equal(state.body.granted, true); + + const egg = await get("/api/ramble/egg"); + assert.ok(egg.body.egg, "and the egg view now has something to show"); +}); + +test("POST /api/ramble/prologue/hatch flags the second beat and is idempotent", async () => { + assert.equal((await post("/api/ramble/prologue/hatch", {})).status, 200); + assert.equal((await post("/api/ramble/prologue/hatch", {})).status, 200); + assert.equal((await get("/api/ramble/prologue")).body.hatch_seen, true); +}); diff --git a/tests/ramble-tools.test.js b/tests/ramble-tools.test.js index db99f10b..549f2de4 100644 --- a/tests/ramble-tools.test.js +++ b/tests/ramble-tools.test.js @@ -110,6 +110,9 @@ test("ramble_pet_state returns the pet's current state", async () => { assert.equal(typeof state.crows_week, "number"); assert.equal(typeof state.hearts, "number", "the tool reports the same wallet the panel does"); assert.equal(typeof state.energy_max, "number"); + assert.equal(typeof state.lay.days, "number", "ramble_pet_state must match GET /api/ramble/pet's lay progress"); + assert.equal(typeof state.lay.needed, "number"); + assert.ok(Object.hasOwn(state, "shelf_waiting"), "ramble_pet_state owns shelf_waiting (the egg tool does not)"); }); test("ramble_unlock on an in-range open mark feeds unlock_mark (unlocks_week increments)", async () => { @@ -135,6 +138,9 @@ test("ramble_egg_state returns a numeric egg.percent between 0 and 100", async ( const payload = JSON.parse(r.content[0].text); assert.equal(typeof payload.egg.percent, "number"); assert.ok(payload.egg.percent >= 0 && payload.egg.percent <= 100); + assert.equal(typeof payload.lay.days, "number", "ramble_egg_state must match GET /api/ramble/egg's lay progress"); + assert.equal(typeof payload.lay.needed, "number"); + assert.ok(!Object.hasOwn(payload, "shelf_waiting"), "the egg tool deliberately does not carry shelf_waiting — that affordance is the pet card's alone"); }); test("ramble_checkin credits once per local day; a second same-day call is not credited", async () => { @@ -228,6 +234,12 @@ test("ramble_nests lists deterministic nests nearest-first; ramble_claim_nest cl const petAfter = JSON.parse((await h.ramble_pet_state({})).content[0].text); assert.equal(eggAfter.egg.warmth, eggBefore.egg.warmth); assert.equal(petAfter.energy, petBefore.energy); + // nextPromotable only offers a shelved egg while NO egg is incubating + // (eggAfter.egg is still present, unchanged, above) — so the freshly + // shelved egg does not surface here yet; this only confirms the field + // exists and agrees with that rule rather than dereferencing blindly. + assert.equal(petAfter.shelf_waiting, null, "an egg is already incubating, so nothing is offered for promotion yet"); + assert.ok(!Object.hasOwn(eggAfter, "shelf_waiting"), "ramble_egg_state must not disagree with GET /api/ramble/egg by carrying it"); }); test("phase 3 tools: gift and propose_swap validate the contact and the egg, queue one DM each; leave_mark reports recipients", async () => { From 55be2aa7318c6b167ca052ff68c8829acd0d36c2 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 15:47:17 -0500 Subject: [PATCH 14/20] ramble: the panel answers for a player with no egg Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/panel/ramble.js | 5 + bundles/ramble/panel/static/ramble-ar.js | 11 ++- bundles/ramble/panel/static/ramble.css | 4 + bundles/ramble/panel/static/ramble.js | 113 +++++++++++++++++------ tests/ramble-ar.test.js | 9 +- tests/ramble-panel.test.js | 75 ++++++++++++++- 6 files changed, 181 insertions(+), 36 deletions(-) diff --git a/bundles/ramble/panel/ramble.js b/bundles/ramble/panel/ramble.js index c8f19d22..95f73cbc 100644 --- a/bundles/ramble/panel/ramble.js +++ b/bundles/ramble/panel/ramble.js @@ -197,6 +197,7 @@ export default {

Reading the warmth…

+ diff --git a/bundles/ramble/panel/static/ramble-ar.js b/bundles/ramble/panel/static/ramble-ar.js index 0444d36a..679e1281 100644 --- a/bundles/ramble/panel/static/ramble-ar.js +++ b/bundles/ramble/panel/static/ramble-ar.js @@ -5,7 +5,7 @@ * every label is built with createElement + textContent, and the only markup * this file ever mounts is the bird; a caller-built art element is appended, never parsed. * - * Contract: renderAr({ anchors, pose, bird, camera }) -> frame. It knows + * Contract: renderAr({ anchors, pose, bird, camera, hasEgg }) -> frame. It knows * nothing about maps, marks or nests. An anchor is * { id, kind, lat, lon, accuracy_m, approx_m, locked, title, reach_m?, art? } * and the frame says where each label goes as FRACTIONS of the viewport, so @@ -226,6 +226,7 @@ visible: visible.map(function (it) { return it.id; }), say: sayFor(mode, reason, items, visible, coarse.length), bird: s.bird || null, + hasEgg: !!s.hasEgg, }; } @@ -372,10 +373,12 @@ } } - function paintBird(bird) { + function paintBird(bird, hasEgg) { var valid = !!(engine && bird && typeof engine.isValidBird === "function" && engine.isValidBird({ species: bird.species, seed: bird.seed })); if (e.bird) setHidden(e.bird, !valid); - if (e.egg) setHidden(e.egg, valid); + // Phase 3: with no bird AND no egg there is nothing to draw. Without the + // hasEgg term this un-hides a phantom seed-0 egg on every frame. + if (e.egg) setHidden(e.egg, valid || !hasEgg); if (!valid) { birdKey = null; return; } var key = bird.species + ":" + bird.seed + ":" + (bird.mood || "happy"); if (key === birdKey) return; @@ -432,7 +435,7 @@ e.more.textContent = more.join(" · "); } paintRadar(frame); - paintBird(frame.bird); + paintBird(frame.bird, frame.hasEgg); if (e.say) e.say.textContent = frame.say; var entered = false; var nowVisible = {}; diff --git a/bundles/ramble/panel/static/ramble.css b/bundles/ramble/panel/static/ramble.css index cdfdfd00..39b924ed 100644 --- a/bundles/ramble/panel/static/ramble.css +++ b/bundles/ramble/panel/static/ramble.css @@ -1011,3 +1011,7 @@ #ramble .rb-ar-label.rb-ar-fx-collect, #ramble .rb-ar-row.rb-ar-fx-collect { box-shadow: 0 0 0 6px color-mix(in oklab, var(--rb-accent-2) 55%, transparent); } } + +#ramble #rb-nextegg-empty, +#ramble #rb-nextegg-lay, +#ramble #rb-egg-empty { margin-top: 6px; } diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index e894701a..19c78ca2 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -233,11 +233,17 @@ if (perchTarget === "pet" && lastPet && lastPet.bird) { svg.setAttribute("class", "rb-here-bird"); Bird.mountBird(svg, Bird.rollGenome(lastPet.bird.seed, lastPet.bird.species), (lastPet && lastPet.mood) || "happy"); - } else { + } else if (eggSeedId) { /* The WALKING egg — legs and all. You are not carrying it, you are it. */ svg.setAttribute("class", "rb-here-egg"); svg.setAttribute("viewBox", "0 0 120 168"); drawWalkingEggSeed(svg, seedFromEggId(eggSeedId)); + } else { + /* No bird and no egg — the window between a first load and the + * prologue's Go button. seedFromEggId(null) is 0, so drawing here + * would show a phantom egg that does not exist. Return null and let + * hereIcon draw its documented plain dot (static/ramble.js:219-222). */ + return null; } } catch (e) { return null; } return svg; @@ -246,8 +252,9 @@ /* Re-skin the marker in place when the egg hatches or the mood changes. */ function paintHereArt() { if (!hereDot) return; - var art = hereArt(); - if (art) hereDot.setIcon(hereIcon(art)); + /* Unconditional: hereIcon(null) is the documented plain-dot path, and + * skipping setIcon here would leave a stale egg on the map forever. */ + hereDot.setIcon(hereIcon(hereArt())); /* The retired perch was a button with an aria-label that tracked its * state; a divIcon is a focusable div with neither. Restore both. */ var el = hereDot.getElement(); @@ -257,7 +264,7 @@ * aria-describedby to point at it, so a screen reader hears the status * on focus without us inventing anything. */ el.setAttribute("role", "img"); - el.setAttribute("aria-label", perchTarget === "pet" ? "You, and your bird" : "You, and your egg"); + el.setAttribute("aria-label", "You"); el.onkeydown = null; } } @@ -269,7 +276,7 @@ function paintPerchGo() { var go = $("rb-perch-open"); if (!go) return; - go.textContent = perchTarget === "pet" ? "Your bird" : "Your egg"; + go.textContent = "How you're doing"; } /* Shared with the area-post trigger: one notion of "moving" for both. */ @@ -837,6 +844,7 @@ var perchTarget = "egg"; var eggPercent = 0; var eggSeedId = null; + var lastWaitingEggId = null; function setRing(circle, percent) { if (!circle) return; @@ -883,7 +891,8 @@ function statusLine() { var line; if (perchTarget === "egg") { - line = "Your egg is " + Math.round(eggPercent) + "% warm."; + /* No egg at all: say nothing about warmth rather than claiming 0%. */ + line = eggSeedId ? "Your egg is " + Math.round(eggPercent) + "% warm." : "Quiet around here right now."; } else if (lastMarks.length === 0) { line = "Quiet around here right now."; } else { @@ -1204,19 +1213,23 @@ function paintEgg(state) { if (!state || hatchLock) return; - var egg = state.egg || {}; + var egg = state.egg; /* NULL when genuinely eggless */ var list = state.checklist || {}; - eggPercent = typeof egg.percent === "number" ? egg.percent : 0; - eggSeedId = egg.egg_id || null; + var has = !!egg; + eggPercent = has && typeof egg.percent === "number" ? egg.percent : 0; + eggSeedId = has ? egg.egg_id : null; setRing($("rb-egg-ring"), eggPercent); - setText($("rb-egg-percent"), Math.round(eggPercent) + "%"); - setText($("rb-egg-line"), "warmth " + (egg.warmth || 0) + " of " + (egg.hatch_at || 0) + - " · it warms every time you get somewhere new"); + setText($("rb-egg-percent"), has ? Math.round(eggPercent) + "%" : "—"); + setText($("rb-egg-line"), has + ? "warmth " + (egg.warmth || 0) + " of " + (egg.hatch_at || 0) + + " · it warms every time you get somewhere new" + : "No one on the way just now."); + setHidden($("rb-egg-empty"), has); var art = $("rb-egg-art"); - if (art) { setHidden(art, false); drawEggArt(art, egg.egg_id); } + if (art) { setHidden(art, !has); if (has) drawEggArt(art, egg.egg_id); } var birdEl = $("rb-hatch-bird"); if (birdEl) setHidden(birdEl, true); @@ -1241,9 +1254,12 @@ setText($("rb-egg-status"), "Checking in…"); jsonFetch("/api/ramble/egg/checkin", { method: "POST", body: {} }) .then(function (out) { - setText($("rb-egg-status"), (out && out.credited) - ? "Checked in. That is today's warmth." - : "Already checked in today — go somewhere instead."); + /* Three arms, not two. With no egg the credit was real but the + * warmth had nowhere to land (D3), and a repeat tap is neither. */ + setText($("rb-egg-status"), + !(out && out.credited) ? "Already checked in today — go somewhere instead." + : out.egg ? "Checked in. That is today's warmth." + : "Checked in. Nothing to warm yet — but it counted."); handleHatched(out && out.hatched); refreshPet(); return refreshEgg(); @@ -1334,16 +1350,41 @@ setText($("rb-stat-unlocks"), String(pet.unlocks_week || 0)); setText($("rb-stat-crows"), String(pet.crows_week || 0)); - /* The successor egg, and the only route back to the egg view (and its - * daily check-in) once the perch belongs to a hatched bird. */ - var nextPct = (pet.egg && typeof pet.egg.percent === "number") ? pet.egg.percent : eggPercent; - /* statusLine renders the world view's warmth line from this, and the - * ring that used to show live progress is gone, so this is now the only - * thing keeping that line honest between egg-view visits. */ - if (pet.egg && typeof pet.egg.percent === "number") eggPercent = pet.egg.percent; + /* The next you, and the ONLY route back to the egg view (and its daily + * check-in) once the perch belongs to a hatched bird. It changes state + * when there is no egg; it is never hidden. */ + var nextEgg = pet.egg || null; + var hasNext = !!nextEgg; + var nextPct = hasNext && typeof nextEgg.percent === "number" ? nextEgg.percent : 0; + if (hasNext) { eggPercent = nextPct; eggSeedId = nextEgg.egg_id; } + else { eggPercent = 0; eggSeedId = null; } + setRing($("rb-nextegg-ring"), nextPct); - setText($("rb-nextegg-percent"), Math.round(nextPct) + "%"); - drawEggArt($("rb-nextegg-art"), eggSeedId); + setText($("rb-nextegg-percent"), hasNext ? Math.round(nextPct) + "%" : "—"); + var nextArt = $("rb-nextegg-art"); + if (nextArt) { setHidden(nextArt, !hasNext); if (hasNext) drawEggArt(nextArt, nextEgg.egg_id); } + setHidden($("rb-nextegg-empty"), hasNext); + + /* Nothing auto-promotes a shelf egg into an empty slot (see the server's + * promoteFromShelf note), so offer it here rather than leaving the player + * with no signal and no way back. It also means they are NOT eggless, so + * the lay line must not claim they are. */ + var waiting = pet.shelf_waiting || null; + setHidden($("rb-nextegg-waiting"), hasNext || !waiting); + setHidden($("rb-nextegg-warm"), hasNext || !waiting); + lastWaitingEggId = waiting; + + var lay = pet.lay || null; + var layEl = $("rb-nextegg-lay"); + if (layEl) { + setHidden(layEl, hasNext || !lay || !!waiting); + if (!hasNext && lay && !waiting) { + setText(layEl, lay.days > 0 + ? "You've had " + lay.days + " good " + (lay.days === 1 ? "day" : "days") + + " — keep it up and you'll manage one yourself." + : "Keep yourself happy and you'll manage one yourself, in time."); + } + } /* "My bird" only exists once there is one. */ var myBird = $("rb-my-bird"); @@ -1377,6 +1418,13 @@ var myBirdBtn = $("rb-my-bird"); if (myBirdBtn) myBirdBtn.addEventListener("click", function () { showView("pet"); }); + var warmBtn = $("rb-nextegg-warm"); + if (warmBtn) warmBtn.addEventListener("click", function () { + if (!lastWaitingEggId) return; + incubate({ egg_id: lastWaitingEggId }, warmBtn) + .catch(function (err) { setText($("rb-nextegg-waiting"), err.message); }); + }); + /* ---------------------------------------------------------------- nests */ var lastNests = []; @@ -1795,7 +1843,7 @@ function incubate(egg, btn) { btn.disabled = true; - jsonFetch("/api/ramble/eggs/" + encodeURIComponent(egg.egg_id) + "/incubate", { method: "POST", body: {} }) + return jsonFetch("/api/ramble/eggs/" + encodeURIComponent(egg.egg_id) + "/incubate", { method: "POST", body: {} }) .then(function (out) { flockStatus("Swapped. The other one keeps its warmth on the shelf."); handleHatched(out && out.hatched); @@ -1803,7 +1851,14 @@ refreshPet(); return refreshFlock(); }) - .catch(function (err) { flockStatus(err.message); btn.disabled = false; }); + .catch(function (err) { + flockStatus(err.message); + btn.disabled = false; + /* Rethrown so a caller off the flock view (the pet card's Warm it + * button) can report the same failure somewhere ITS tap can see — + * the flock view's own status line is elsewhere in the DOM. */ + throw err; + }); } function giftEgg(egg, btn) { @@ -1852,7 +1907,7 @@ } var acts = document.createElement("div"); acts.className = "rb-acts"; - acts.appendChild(shelfAction(egg, "Incubate", function (b) { incubate(egg, b); })); + acts.appendChild(shelfAction(egg, "Incubate", function (b) { incubate(egg, b).catch(function () { /* already reported via flockStatus */ }); })); acts.appendChild(shelfAction(egg, "Gift", function (b) { giftEgg(egg, b); })); acts.appendChild(shelfAction(egg, "Swap", function (b) { proposeSwap(egg, b); })); row.appendChild(acts); @@ -2122,7 +2177,7 @@ /* A compass that stopped reporting (screen lock, sensor hiccup) must not * keep placing labels with confidence: a stale heading falls back to the ring. */ if (arPose.heading != null && Date.now() - arHeadingAt > AR_HEADING_STALE_MS) arPose.heading = null; - arSession.render({ anchors: arAnchors, pose: arPose, bird: arBirdState(), camera: arCamera }); + arSession.render({ anchors: arAnchors, pose: arPose, bird: arBirdState(), camera: arCamera, hasEgg: !!eggSeedId }); }); } diff --git a/tests/ramble-ar.test.js b/tests/ramble-ar.test.js index da18686d..e42a006a 100644 --- a/tests/ramble-ar.test.js +++ b/tests/ramble-ar.test.js @@ -238,12 +238,17 @@ test("mountAr paints labels with textContent, routes taps by id, mounts the bird session.render({ anchors: [anchors[0]], pose: pose(0), bird: { species: "crow", seed: 7, mood: "happy" } }); assert.equal(els.labels.children.length, 1); assert.equal(east.parentNode, null); - // No bird: the egg shows; no heading: radar mode label and rows. - const f2 = session.render({ anchors, pose: pose(null), bird: null }); + // No bird: the egg shows (given an egg to show); no heading: radar mode + // label and rows. + const f2 = session.render({ anchors, pose: pose(null), bird: null, hasEgg: true }); assert.equal(f2.mode, "radar"); assert.equal(els.mode.textContent, "Radar · no compass"); assert.equal(els.bird.hasAttribute("hidden"), true); assert.equal(els.egg.hasAttribute("hidden"), false); assert.equal(els.labels.children.length, 0); + // Neither bird nor egg: the renderer, not startAr, must hide it — it + // repaints every frame and would otherwise undo anything startAr set. + session.render({ anchors, pose: pose(null), bird: null, hasEgg: false }); + assert.equal(els.egg.hasAttribute("hidden"), true, "nothing to draw with no bird and no egg"); assert.equal(els.list.children.length, 2); assert.notEqual(els.list.children, rowsBefore, "the mode flip repaints the list (its key includes the mode)"); els.list.children[0].click(); diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index 543530b0..08ded820 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -585,6 +585,29 @@ test("POST /api/ramble/egg/checkin credits warmth once per local day", async () assert.equal(checklist.checked_in_today, true); }); +// Carried from Task 6's review: the `egg` boolean on the checkin response had +// no coverage, and Task 7's confirmation copy branches on it (S2) — a +// regression here would silently produce the wrong three-way message. +test("POST /api/ramble/egg/checkin reports whether an egg is incubating, independent of the day's credit", async () => { + const gone = createDbClient(); + try { + await gone.execute({ sql: "DELETE FROM ramble_eggs WHERE status = 'incubating'", args: [] }); + } finally { + gone.close(); + } + const eggless = await (await req("/api/ramble/egg/checkin", { method: "POST", body: {} })).json(); + assert.equal(eggless.egg, false, "no incubating egg after the delete above"); + + const minted = createDbClient(); + try { + await mintIncubatingEgg(minted, { now: Date.now() }); + } finally { + minted.close(); + } + const withEgg = await (await req("/api/ramble/egg/checkin", { method: "POST", body: {} })).json(); + assert.equal(withEgg.egg, true, "an incubating egg exists after the mint above"); +}); + test("POST /api/ramble/pet/chore completes each kind once a day and 400s an unknown kind", async () => { const first = await req("/api/ramble/pet/chore", { method: "POST", body: { kind: "preen" } }); assert.equal(first.status, 200); @@ -926,10 +949,60 @@ test("GET /ramble/static/ramble.js serves the client script as JavaScript", asyn "storage can throw outright in private mode — a remembered preference must never break the panel"); assert.ok(body.includes('perchOpenBtn.addEventListener("click"'), "the door is wired independently of the map marker"); - assert.ok(body.includes("eggPercent = pet.egg.percent"), "the world view's warmth line follows the pet refresh"); + assert.ok(body.includes("eggPercent = nextPct"), "the world view's warmth line follows the pet refresh"); assert.ok(body.includes('opts.className = "rb-here-pet rb-here-plain"'), "a plain dot survives the bird engine failing to load"); }); +// -------------------------------------------------------------- eggless (Task 7) + +test("the Next egg card is never hidden — it is the only route to the check-in", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.ok(!/setHidden\(\s*\$\("rb-pet-nextegg"\)/.test(src), + "hiding it would delete the daily check-in for an eggless player (phase 1's defect)"); + assert.ok(src.includes("rb-nextegg-empty"), "it changes state instead"); +}); + +test("a shelf egg waiting for an empty slot is offered, not hidden", () => { + const shell = readFileSync("bundles/ramble/panel/ramble.js", "utf8"); + assert.ok(shell.includes("waiting on your shelf")); + assert.ok(shell.includes('id="rb-nextegg-warm"'), "and a one-tap way to act on it"); + + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.ok(src.includes("lastWaitingEggId"), "wired to the shipped incubate endpoint"); + // The lay line must not claim you are eggless while an egg sits on the shelf. + assert.ok(/setHidden\(\$\("rb-nextegg-lay"\)|!!waiting/.test(src)); +}); + +test("the eggless copy is present and written from inside the premise", () => { + // ⚠ TWO FILES. Static copy lives in the server-rendered shell; only strings + // the client BUILDS live in the client. An earlier draft asserted both + // against the client, and asserted a "good days" literal the client never + // contains — it is concatenated around a pluralised day/days. + const shell = readFileSync("bundles/ramble/panel/ramble.js", "utf8"); + assert.ok(shell.includes("Nothing warming just now.")); + assert.ok(shell.includes("Nests hold them. So do friends.")); + + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.ok(src.includes("No one on the way just now.")); + assert.ok(src.includes("keep it up and you'll manage one yourself"), "K4: the soft count is named"); + assert.ok(src.includes('" good "'), "pluralised around the count"); +}); + +test("the AR renderer hides the egg when there is neither bird nor egg", () => { + // In the RENDERER, not startAr: ramble-ar.js repaints every frame and would + // otherwise un-hide the egg whenever there is no valid bird. + const src = readFileSync("bundles/ramble/panel/static/ramble-ar.js", "utf8"); + assert.ok(/setHidden\(e\.egg,\s*valid\s*\|\|\s*!.*hasEgg/.test(src), + "seedFromEggId(null) is 0, so an unguarded frame shows an egg that does not exist"); +}); + +test("the map marker does not draw a phantom egg for a player who has neither", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + const fn = src.slice(src.indexOf("function hereArt()"), src.indexOf("function paintHereArt()")); + assert.ok(/else if \(eggSeedId\)/.test(fn), + "hereArt must fall through to the plain dot when there is no bird and no egg"); +}); + test("the map draws heart pips, counts them, and says something when one is taken", async () => { const body = await (await req("/ramble/static/ramble.js")).text(); assert.ok(body.includes("function paintHeartPips("), "the map shows where a heart is waiting"); From 1ef28fb230c4e70b9230b274f84ec100b287a263 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 15:55:11 -0500 Subject: [PATCH 15/20] ramble: the shelf-waiting card no longer contradicts itself Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/panel/static/ramble.js | 44 +++++++++++++------ tests/ramble-panel.test.js | 62 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index 19c78ca2..6f7925e5 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -1297,6 +1297,23 @@ var lastPet = null; + /* Pure decision table for the Next-egg card's three states — an egg + * incubating, nothing anywhere, one waiting on the shelf — extracted so it + * is unit-testable without a DOM. A shelf egg waiting AND the empty line + * both showing at once was a real regression here (two contradictory + * lines on the one card that must never be hidden); paintPet is the only + * caller, and does nothing but apply what this returns. */ + function nextEggVisibility(hasNext, waiting, hasLay) { + var isWaiting = !!waiting; + return { + art: !hasNext, + empty: hasNext || isWaiting, + waiting: hasNext || !isWaiting, + warm: hasNext || !isWaiting, + lay: hasNext || !hasLay || isWaiting, + }; + } + function paintPet(pet) { if (!pet) return; lastPet = pet; @@ -1359,26 +1376,29 @@ if (hasNext) { eggPercent = nextPct; eggSeedId = nextEgg.egg_id; } else { eggPercent = 0; eggSeedId = null; } - setRing($("rb-nextegg-ring"), nextPct); - setText($("rb-nextegg-percent"), hasNext ? Math.round(nextPct) + "%" : "—"); - var nextArt = $("rb-nextegg-art"); - if (nextArt) { setHidden(nextArt, !hasNext); if (hasNext) drawEggArt(nextArt, nextEgg.egg_id); } - setHidden($("rb-nextegg-empty"), hasNext); - /* Nothing auto-promotes a shelf egg into an empty slot (see the server's * promoteFromShelf note), so offer it here rather than leaving the player * with no signal and no way back. It also means they are NOT eggless, so - * the lay line must not claim they are. */ + * the lay line must not claim they are — and NOR may the empty line, + * which must not say "Nothing warming just now." while the waiting line + * says one is. */ var waiting = pet.shelf_waiting || null; - setHidden($("rb-nextegg-waiting"), hasNext || !waiting); - setHidden($("rb-nextegg-warm"), hasNext || !waiting); + var lay = pet.lay || null; + var vis = nextEggVisibility(hasNext, waiting, !!lay); + + setRing($("rb-nextegg-ring"), nextPct); + setText($("rb-nextegg-percent"), hasNext ? Math.round(nextPct) + "%" : "—"); + var nextArt = $("rb-nextegg-art"); + if (nextArt) { setHidden(nextArt, vis.art); if (hasNext) drawEggArt(nextArt, nextEgg.egg_id); } + setHidden($("rb-nextegg-empty"), vis.empty); + setHidden($("rb-nextegg-waiting"), vis.waiting); + setHidden($("rb-nextegg-warm"), vis.warm); lastWaitingEggId = waiting; - var lay = pet.lay || null; var layEl = $("rb-nextegg-lay"); if (layEl) { - setHidden(layEl, hasNext || !lay || !!waiting); - if (!hasNext && lay && !waiting) { + setHidden(layEl, vis.lay); + if (!vis.lay) { setText(layEl, lay.days > 0 ? "You've had " + lay.days + " good " + (lay.days === 1 ? "day" : "days") + " — keep it up and you'll manage one yourself." diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index 08ded820..b3dc816f 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -186,6 +186,31 @@ function req(path, opts = {}) { }); } +/** AST-lite bracket-depth scan (no JS parser pulled in) — copied verbatim + * from tests/bird-drawer-core.test.js's own helper. */ +function matchBrace(src, braceStart) { + let depth = 0, end = -1; + for (let i = braceStart; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}") { depth--; if (depth === 0) { end = i; break; } } + } + return end; +} + +/** Extract a top-level `function NAME(...){...}` declaration's full source + * (signature through closing brace) from the client script, so its PURE + * logic can be unit-tested standalone without a DOM (there is no jsdom or vm + * sandbox harness for this file, unlike ramble-ar.js). */ +function extractFunction(src, name) { + const marker = "function " + name + "("; + const start = src.indexOf(marker); + if (start < 0) return null; + const braceStart = src.indexOf("{", start); + const end = matchBrace(src, braceStart); + if (end < 0) return null; + return src.slice(start, end + 1); +} + // --------------------------------------------------------------- panel shape test("panel handler object has the registry-required shape", () => { @@ -973,6 +998,43 @@ test("a shelf egg waiting for an empty slot is offered, not hidden", () => { assert.ok(/setHidden\(\$\("rb-nextegg-lay"\)|!!waiting/.test(src)); }); +// A source grep cannot catch a wrong boolean passed to setHidden — paintPet +// itself has no DOM harness to run against, so the visibility DECISION is +// pulled out as a pure function (nextEggVisibility) and exercised directly, +// standalone, exactly like tests/bird-drawer-core.test.js does for its own +// client-side pure helpers. This caught a real regression: the empty line +// ("Nothing warming just now.") and the waiting line ("One's waiting on your +// shelf.") both rendering at once on the one card that must never be hidden. +test("nextEggVisibility: exactly the intended lines show in each of the three card states", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + const fnSrc = extractFunction(src, "nextEggVisibility"); + assert.ok(fnSrc, "nextEggVisibility must be defined and extractable"); + const nextEggVisibility = new Function(fnSrc + "\nreturn nextEggVisibility;")(); + + // An egg is incubating: only the ring/percent/art card; every eggless + // affordance is hidden. + assert.deepEqual( + nextEggVisibility(true, null, true), + { art: false, empty: true, waiting: true, warm: true, lay: true }, + "incubating: art shows, nothing else does", + ); + + // Nothing anywhere: the empty line (and the lay line, if there is a count). + assert.deepEqual( + nextEggVisibility(false, null, true), + { art: true, empty: false, waiting: true, warm: true, lay: false }, + "eggless with nothing waiting: only the empty line and the lay line", + ); + + // One waiting on the shelf: ONLY the waiting line and the Warm it button. + // The empty line must NOT also show — that was the regression. + assert.deepEqual( + nextEggVisibility(false, "egg-1", true), + { art: true, empty: true, waiting: false, warm: false, lay: true }, + "a shelf egg waiting: the empty line and the lay line must both stay hidden", + ); +}); + test("the eggless copy is present and written from inside the premise", () => { // ⚠ TWO FILES. Static copy lives in the server-rendered shell; only strings // the client BUILDS live in the client. An earlier draft asserted both From fc09eba2e7d7edecf70b1de7f91f53f9b23b1d51 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 16:03:07 -0500 Subject: [PATCH 16/20] ramble: the prologue, in the game's own voice Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/panel/ramble.js | 23 ++++++++++ bundles/ramble/panel/static/ramble.css | 28 ++++++++++++ bundles/ramble/panel/static/ramble.js | 61 +++++++++++++++++++++++++- tests/ramble-panel.test.js | 17 +++++++ 4 files changed, 128 insertions(+), 1 deletion(-) diff --git a/bundles/ramble/panel/ramble.js b/bundles/ramble/panel/ramble.js index 95f73cbc..2eaea5de 100644 --- a/bundles/ramble/panel/ramble.js +++ b/bundles/ramble/panel/ramble.js @@ -467,6 +467,29 @@ export default {
+ + + + diff --git a/bundles/ramble/panel/static/ramble.css b/bundles/ramble/panel/static/ramble.css index 39b924ed..d071b527 100644 --- a/bundles/ramble/panel/static/ramble.css +++ b/bundles/ramble/panel/static/ramble.css @@ -600,6 +600,34 @@ #ramble .rb-meter-bar i { transition: none; } } +/* ------------------------------------------------------------- the prologue */ + +#ramble .rb-prologue { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + place-items: center; + background: rgba(20, 22, 36, .5); + padding: 24px; +} +#ramble .rb-prologue[hidden] { display: none; } +#ramble .rb-prologue-card { + max-width: 30rem; + border-radius: var(--rb-radius); + padding: 22px 20px; + background: var(--rb-surface); + color: var(--rb-text); + border: var(--rb-line-w) solid var(--rb-line); + box-shadow: var(--rb-pop); + animation: rb-sheet-up .22s ease-out; +} +#ramble .rb-prologue-card p { margin: 0 0 12px; line-height: 1.5; } +#ramble .rb-prologue-lead { font: 800 1.15rem var(--rb-font-display); } +@media (prefers-reduced-motion: reduce) { + #ramble .rb-prologue-card { animation: none; } +} + /* ------------------------------------------------------------- narrow phones */ @media (max-width: 420px) { diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index 6f7925e5..4ec0531c 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -2056,6 +2056,7 @@ /* ---------------------------------------------------------------- hatch */ var shownHatch = null; + var lastHatched = null; /* set by handleHatched, read by the beat */ /** * The one hatch entry point: an SSE "ramble-hatched" frame and the "hatched" @@ -2067,6 +2068,7 @@ var key = h.egg_id || (h.species + ":" + h.seed); if (key === shownHatch) return; shownHatch = key; + lastHatched = h; /* AFTER showView: showView() ends any hatch in progress, so locking first * would immediately unlock again when the view actually changes. */ showView("egg"); @@ -2114,7 +2116,63 @@ } var meetBtn = $("rb-meet-bird"); - if (meetBtn) meetBtn.addEventListener("click", function () { showView("pet"); }); + if (meetBtn) meetBtn.addEventListener("click", function () { + showView("pet"); + maybeHatchBeat(lastHatched); + }); + + /* ------------------------------------------------------------ prologue */ + + function showPrologue(which) { + var root = $("rb-prologue"); + if (!root) return; + setHidden($("rb-prologue-intro"), which !== "intro"); + setHidden($("rb-prologue-hatch"), which !== "hatch"); + setHidden(root, false); + } + + function hidePrologue() { setHidden($("rb-prologue"), true); } + + /* Beat one is for a player who has never had an egg at all. Both the button + * and a dismissal grant it, so skipping the words never costs the egg. */ + function maybeIntro() { + return jsonFetch("/api/ramble/prologue").then(function (p) { + if (p && !p.intro_seen && !p.granted) showPrologue("intro"); + }).catch(function () { /* the prologue is never load-bearing */ }); + } + + var goBtn = $("rb-prologue-go"); + if (goBtn) goBtn.addEventListener("click", function () { + hidePrologue(); + jsonFetch("/api/ramble/prologue/intro", { method: "POST", body: {} }) + .then(function () { refreshEgg(); refreshPet(); }) + .catch(function () { /* the next load retries */ }); + }); + + /* Beat two rides the existing hatch reveal: the bird is already on screen, + * so this names what just happened rather than interrupting it. */ + function maybeHatchBeat(bird) { + jsonFetch("/api/ramble/prologue").then(function (p) { + if (!p || p.hatch_seen) return; + var lead = $("rb-prologue-hatch-lead"); + if (lead && bird && bird.species) { + /* Same lookup as handleHatched's reveal line: species is a lowercase + * key ("blackswan"), never the display name ("Black swan"). */ + var sp = Bird && Bird.SPECIES ? Bird.SPECIES[bird.species] : null; + setText(lead, "You're out. A " + ((sp && sp.name) || bird.species) + + " — the only one rolled quite like you."); + } + showPrologue("hatch"); + }).catch(function () { /* cosmetic */ }); + } + + var seenBtn = $("rb-prologue-seen"); + if (seenBtn) seenBtn.addEventListener("click", function () { + hidePrologue(); + jsonFetch("/api/ramble/prologue/hatch", { method: "POST", body: {} }) + .then(function () { showView("pet"); }) + .catch(function () { /* the next load retries */ }); + }); /* ------------------------------------------------------------------- ar */ @@ -2476,6 +2534,7 @@ jsonFetch("/api/ramble/grid").then(paintGrid).catch(function () { /* leave the chip at off */ }); refreshContacts(); refreshEgg().then(refreshPet); + maybeIntro(); if (map) { startMapWatch(); diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index b3dc816f..d806ab05 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -2045,3 +2045,20 @@ test("the energy bar is drawn against the server's ceiling, not a hardcoded 100" assert.ok(body.includes("paintHearts(hearts)"), "the map-bar counter is painted from the pet read too, not only from a position fix"); }); + +test("both prologue beats are present, in the game's voice", () => { + const shell = readFileSync("bundles/ramble/panel/ramble.js", "utf8"); + assert.ok(shell.includes("You are an egg.")); + assert.ok(shell.includes("wandered off from your nest")); + assert.ok(shell.includes("Nobody knows what’s inside you yet") || + shell.includes("Nobody knows what's inside you yet")); + assert.ok(shell.includes("Nothing here is ever lost."), + "the no-fail-state promise lives in the game, not only in the docs"); + assert.ok(shell.includes("rb-prologue"), "the overlay exists in the server-rendered shell"); +}); + +test("the prologue is skippable and both beats dismiss", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + assert.ok(src.includes("/api/ramble/prologue/intro")); + assert.ok(src.includes("/api/ramble/prologue/hatch")); +}); From 815b675f5b2348b6004e9b547656185248beedac Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 16:11:13 -0500 Subject: [PATCH 17/20] ramble: structurally test the prologue gate and hook point Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/panel/static/ramble.js | 13 +++++-- tests/ramble-panel.test.js | 51 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index 4ec0531c..f1da9c34 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -2133,11 +2133,18 @@ function hidePrologue() { setHidden($("rb-prologue"), true); } - /* Beat one is for a player who has never had an egg at all. Both the button - * and a dismissal grant it, so skipping the words never costs the egg. */ + /* Beat one is for a player who has never had an egg at all — pulled out as + * a pure function so the gate itself (not just "is it wired at all") is + * unit-testable, the way nextEggVisibility is above. */ + function shouldShowIntro(p) { + return !!(p && !p.intro_seen && !p.granted); + } + + /* Both the button and a dismissal grant it, so skipping the words never + * costs the egg. */ function maybeIntro() { return jsonFetch("/api/ramble/prologue").then(function (p) { - if (p && !p.intro_seen && !p.granted) showPrologue("intro"); + if (shouldShowIntro(p)) showPrologue("intro"); }).catch(function () { /* the prologue is never load-bearing */ }); } diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index d806ab05..9437308f 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -211,6 +211,21 @@ function extractFunction(src, name) { return src.slice(start, end + 1); } +/** Extract the balanced `{...}` block that begins at the first `{` AFTER a + * given anchor substring — for an anonymous listener body (`$("id") + * .addEventListener("click", function () { ... })`) that has no name for + * extractFunction to find. Lets a test assert a call happens WITHIN a + * specific handler, not merely somewhere in the file (which a whole-file + * `includes()` cannot distinguish from the wrong handler entirely). */ +function extractAfter(src, anchor) { + const anchorAt = src.indexOf(anchor); + if (anchorAt < 0) return null; + const braceStart = src.indexOf("{", anchorAt); + const end = matchBrace(src, braceStart); + if (end < 0) return null; + return src.slice(braceStart, end + 1); +} + // --------------------------------------------------------------- panel shape test("panel handler object has the registry-required shape", () => { @@ -2062,3 +2077,39 @@ test("the prologue is skippable and both beats dismiss", () => { assert.ok(src.includes("/api/ramble/prologue/intro")); assert.ok(src.includes("/api/ramble/prologue/hatch")); }); + +// A source grep proves the two prologue endpoints are called SOMEWHERE, but +// not that beat one's gate is right or that beat two is hooked to the right +// event — the exact two ways this feature can silently regress. Pulled out +// and slice-checked the same way nextEggVisibility and the ramble-ar handler +// tests do, rather than another whole-file includes(). + +test("shouldShowIntro: beat one shows only for a player who has never had an egg at all", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + const fnSrc = extractFunction(src, "shouldShowIntro"); + assert.ok(fnSrc, "shouldShowIntro must be defined and extractable"); + const shouldShowIntro = new Function(fnSrc + "\nreturn shouldShowIntro;")(); + + assert.equal(shouldShowIntro({ intro_seen: false, granted: false }), true, + "never had an egg, never seen the intro: show it"); + assert.equal(shouldShowIntro({ intro_seen: true, granted: false }), false, + "already tapped Go: never show it again"); + assert.equal(shouldShowIntro({ intro_seen: false, granted: true }), false, + "already has an egg some other way (e.g. a gift): the egg arrived without the words, don't retro-show them"); + assert.equal(shouldShowIntro({ intro_seen: true, granted: true }), false); +}); + +test("the hatch beat hooks the rb-meet-bird handler, not clearHatch", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + + const meetHandler = extractAfter(src, '$("rb-meet-bird")'); + assert.ok(meetHandler, "the rb-meet-bird click handler must be extractable"); + assert.ok(meetHandler.includes("maybeHatchBeat("), + "beat two rides the rb-meet-bird click, where the just-hatched bird is still known"); + + const clearHatchFn = extractFunction(src, "clearHatch"); + assert.ok(clearHatchFn, "clearHatch must be defined and extractable"); + assert.ok(!clearHatchFn.includes("maybeHatchBeat("), + "clearHatch fires on ANY view change and has no access to the hatched bird — " + + "hooking it here would show the beat at random moments with no species name"); +}); From e238c33b46776135300d499581b094d9dcccf568 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 16:13:41 -0500 Subject: [PATCH 18/20] ramble 0.11.0: eggs are earned --- bundles/ramble/manifest.json | 2 +- bundles/ramble/package.json | 2 +- docs/es/guide/ramble.md | 20 ++++++++++++++++++-- docs/guide/ramble.md | 21 +++++++++++++++++++-- registry/add-ons.json | 2 +- 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/bundles/ramble/manifest.json b/bundles/ramble/manifest.json index a487759b..44d8dfed 100644 --- a/bundles/ramble/manifest.json +++ b/bundles/ramble/manifest.json @@ -1,7 +1,7 @@ { "id": "ramble", "name": "Ramble", - "version": "0.10.0", + "version": "0.11.0", "type": "mcp-server", "author": "Crow", "category": "social", diff --git a/bundles/ramble/package.json b/bundles/ramble/package.json index 09a5baba..ba3dce5a 100644 --- a/bundles/ramble/package.json +++ b/bundles/ramble/package.json @@ -1,6 +1,6 @@ { "name": "crow-ramble", - "version": "0.10.0", + "version": "0.11.0", "description": "Ramble MCP server — proximity marks, caws, privacy grid, egg and bird companion, gifts and swaps", "type": "module", "main": "server/index.js", diff --git a/docs/es/guide/ramble.md b/docs/es/guide/ramble.md index d0c8d78e..919abe19 100644 --- a/docs/es/guide/ramble.md +++ b/docs/es/guide/ramble.md @@ -65,7 +65,23 @@ El barrido corre en el mismo tick de 15 s. Las filas expiradas se borran localme ## Tu huevo y tu pájaro -Cada instancia siempre tiene un huevo incubando. La actividad real acredita **calor** hacia él; cuando el calor alcanza el umbral de eclosión, el huevo eclosiona en un pájaro. +Tú eres el huevo. La actividad real acredita **calor** hacia él; cuando el calor alcanza el umbral de eclosión, eclosionas en un pájaro, y el huevo incubando después de eso eres **tú de nuevo**. + +Los huevos vienen de tres lugares, y ninguno es gratis: + +- **Nidos** — camina a uno en el mapa, un reclamo por día local, dentro de 75 m. +- **Regalos e intercambios** de contactos. +- **Poner** — mientras no tengas ningún huevo, cada día local que termines feliz cuenta como uno. En + `lay.days` (predeterminado 14) pones uno tú misma. Los días no necesitan ser consecutivos, y el conteo solo + corre mientras estés sin huevo, así que esto es un piso más que un caño. + +Cuando se vacía el hueco incubando, el huevo más viejo en tu repisa se promueve a él automáticamente — +así que solo estás sin huevo cuando realmente no tienes ninguno. **El calor ganado sin ningún huevo +desaparece**; eso es deliberado, y la auto-promoción es lo que lo mantiene raro. + +| Configuración | Predeterminado | Rige | +|---|---|---| +| `lay.days` | 14 | Días felices sin huevo antes de poner uno tú misma | | Evento | Calor | |---|---| @@ -85,7 +101,7 @@ Cada evento es idempotente según su propia clave, así que repetir la misma acc Encontrarte con Crows tiene además un tope de **5 acreditaciones por día calendario local** (`MEET_CROW_DAILY_CAP` en `bundles/ramble/server/eggs.js`): una persona no es más que una clave pública que cualquiera puede generar, así que sin ese techo una avalancha de personas falsas podría forzar eclosión tras eclosión; los encuentros por encima del tope no acreditan nada ni dejan fila en el registro. -Cuando el calor alcanza el umbral de eclosión, se sortean una especie y una semilla del lado del servidor (`crypto.randomInt`, nunca `Math.random`, para que la tirada no se pueda predecir ni repetir); el aspecto del pájaro es único para esa semilla. Un huevo nuevo empieza a incubar de inmediato. +Cuando el calor alcanza el umbral de eclosión, se sortean una especie y una semilla del lado del servidor (`crypto.randomInt`, nunca `Math.random`, para que la tirada no se pueda predecir ni repetir); el aspecto del pájaro es único para esa semilla. Si tienes un huevo esperando en la repisa, pasa al hueco; si no, no empieza nada nuevo, y el siguiente hay que encontrarlo, recibirlo o ponerlo. Tu pájaro activo viaja en tus caws y marcas **públicas** — el JSON del cable lleva `bird: { species, seed }`, así que otras personas lo ven en tus pines. Las marcas de contactos y "solo para mí" nunca llegan al cable de Nostr (ver abajo), así que el pájaro se omite solo del **cable**: esas filas siguen guardando `bird_species` / `bird_seed` localmente y se replican, con pájaro incluido, a tus propias instancias enlazadas. diff --git a/docs/guide/ramble.md b/docs/guide/ramble.md index a95952b4..3bf64ee4 100644 --- a/docs/guide/ramble.md +++ b/docs/guide/ramble.md @@ -65,7 +65,24 @@ The sweep runs on the same 15 s drain tick. Expired rows are deleted locally and ## Your egg and your bird -Every instance always has one egg incubating. Real-world activity credits **warmth** toward it; once warmth reaches the hatch threshold, the egg hatches into a bird. +You are the egg. Real-world activity credits **warmth** toward it; at the hatch threshold you hatch +into a bird, and the incubating egg after that is the **next you**. + +Eggs come from three places, and none of them is free: + +- **Nests** — walk to one on the map, one claim per local day, within 75 m. +- **Gifts and swaps** from contacts. +- **Laying** — while you hold no egg at all, every local day you end happy counts one. At + `lay.days` (default 14) you lay one yourself. Days need not be consecutive, and the count only + runs while you are eggless, so this is a floor rather than a faucet. + +When the incubating slot empties, the oldest egg on your shelf is promoted into it automatically — +so you are only ever eggless when you genuinely have none. **Warmth earned with no egg at all +vanishes**; that is deliberate, and auto-promote is what keeps it rare. + +| Setting | Default | Governs | +|---|---|---| +| `lay.days` | 14 | Happy days while eggless before you lay one yourself | | Event | Warmth | |---|---| @@ -85,7 +102,7 @@ Each event is idempotent per its own key, so repeating the same real-world actio Meeting crows is additionally capped at **5 credits per local calendar day** (`MEET_CROW_DAILY_CAP` in `bundles/ramble/server/eggs.js`) — a persona is just a pubkey anyone can mint, so without that ceiling a flood of spoofed personas could force hatch after hatch; meetings past the cap credit nothing and leave no ledger row. -When warmth reaches the hatch threshold, a species and a seed are rolled server-side (`crypto.randomInt`, never `Math.random`, so the roll can't be predicted or replayed); the bird's look is unique to that seed. A new egg starts incubating immediately. +When warmth reaches the hatch threshold, a species and a seed are rolled server-side (`crypto.randomInt`, never `Math.random`, so the roll can't be predicted or replayed); the bird's look is unique to that seed. If an egg is waiting on your shelf it moves into the slot; otherwise nothing new starts, and the next one has to be found, given, or laid. Your active bird rides on your **public** caws and marks — the wire JSON carries `bird: { species, seed }` so other people see it on your pins. Contacts and "Just me" marks never reach the Nostr wire (see below), so the bird is omitted from the **wire** only: those rows still store `bird_species` / `bird_seed` locally and replicate, bird and all, to your own linked instances. diff --git a/registry/add-ons.json b/registry/add-ons.json index e404cc20..57fd5652 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -4939,7 +4939,7 @@ { "id": "ramble", "name": "Ramble", - "version": "0.10.0", + "version": "0.11.0", "type": "mcp-server", "author": "Crow", "category": "social", From b1e4f85bd3b17a4e020432a3a6ed0f9009b341b4 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 16:32:53 -0500 Subject: [PATCH 19/20] ramble: fix Warm it re-enable, sticky error text, and two stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B1: paintPet now clears rb-nextegg-warm's disabled state whenever it repaints the card visible, not just on incubate()'s own failure path — a successful warm otherwise leaves the button dead the next time a gift, claimed nest, or lapsed swap re-shows it. - N5: restore the static "One's waiting on your shelf." copy when the waiting card repaints, instead of leaving a failed warm's error message stuck there for the life of the page. - N1: correct the rb-pet-nextegg comment, which claimed an auto-mint this branch removed. - N7: correct the prologue comment, which claimed a dismissal control that does not exist — only the Go button closes it. Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- bundles/ramble/panel/ramble.js | 6 +++--- bundles/ramble/panel/static/ramble.js | 22 +++++++++++++++++---- tests/ramble-panel.test.js | 28 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/bundles/ramble/panel/ramble.js b/bundles/ramble/panel/ramble.js index 2eaea5de..06cd38c4 100644 --- a/bundles/ramble/panel/ramble.js +++ b/bundles/ramble/panel/ramble.js @@ -308,9 +308,9 @@ export default { - +

Next egg

diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index f1da9c34..1f307b56 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -1391,8 +1391,21 @@ var nextArt = $("rb-nextegg-art"); if (nextArt) { setHidden(nextArt, vis.art); if (hasNext) drawEggArt(nextArt, nextEgg.egg_id); } setHidden($("rb-nextegg-empty"), vis.empty); - setHidden($("rb-nextegg-waiting"), vis.waiting); - setHidden($("rb-nextegg-warm"), vis.warm); + var waitingEl = $("rb-nextegg-waiting"); + setHidden(waitingEl, vis.waiting); + /* A failed warm attempt overwrites this line with err.message (see the + * rb-nextegg-warm click handler below); nothing else ever restores the + * static copy, so it stays an error for the life of the page. Restore it + * here, every time the card comes back to this state. */ + if (waitingEl && !vis.waiting) setText(waitingEl, "One's waiting on your shelf."); + var warmEl = $("rb-nextegg-warm"); + setHidden(warmEl, vis.warm); + /* incubate()'s own .catch only re-enables on FAILURE. A successful warm + * leaves this disabled, and a later gift / claimed nest / lapsed swap + * can repaint the card visible again with no incubate() call in + * between — clear it here, every time the card comes back visible, so + * the button is never shown dead. */ + if (warmEl && !vis.warm) warmEl.disabled = false; lastWaitingEggId = waiting; var layEl = $("rb-nextegg-lay"); @@ -2140,8 +2153,9 @@ return !!(p && !p.intro_seen && !p.granted); } - /* Both the button and a dismissal grant it, so skipping the words never - * costs the egg. */ + /* There is no backdrop-click or close control — hidePrologue() only runs + * from the Go button below, whose own handler also marks the intro seen, + * so skipping the words never costs the egg. */ function maybeIntro() { return jsonFetch("/api/ramble/prologue").then(function (p) { if (shouldShowIntro(p)) showPrologue("intro"); diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index 9437308f..b85b45e7 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -1050,6 +1050,34 @@ test("nextEggVisibility: exactly the intended lines show in each of the three ca ); }); +// incubate() only re-enables the button it disabled from its OWN .catch — +// the failure path. A successful warm leaves rb-nextegg-warm disabled and +// then hidden; the bug is that nothing clears .disabled again before the +// button can next become visible (a later gift, a claimed nest, or a lapsed +// swap unlocking a shelf egg all repaint this card waiting with no incubate() +// call in between). Slice-checked the same way nextEggVisibility is above, +// anchored on the exact toggle line so the test can't pass against unrelated +// disabled-handling elsewhere in paintPet. +test("paintPet clears the Warm it button's disabled state whenever it repaints the card visible", () => { + const src = readFileSync("bundles/ramble/panel/static/ramble.js", "utf8"); + const paintPetSrc = extractFunction(src, "paintPet"); + assert.ok(paintPetSrc, "paintPet must be defined and extractable"); + + const warmAt = paintPetSrc.indexOf('rb-nextegg-warm"'); + assert.ok(warmAt >= 0, "paintPet must reference rb-nextegg-warm"); + const afterAt = paintPetSrc.indexOf("lastWaitingEggId = waiting;", warmAt); + assert.ok(afterAt > warmAt, "the waiting-egg id bookkeeping must follow the warm-button toggle"); + const warmBlock = paintPetSrc.slice(warmAt, afterAt); + + assert.ok( + warmBlock.includes("disabled = false"), + "paintPet must clear the Warm it button's disabled state itself, right where it toggles the " + + "button's visibility — incubate()'s own .catch only re-enables on FAILURE, so a successful " + + "warm leaves the button disabled, and the next paintPet that un-hides it (a gift, a claimed " + + "nest, or a lapsed swap freeing a shelf egg) shows a dead button with no message", + ); +}); + test("the eggless copy is present and written from inside the premise", () => { // ⚠ TWO FILES. Static copy lives in the server-rendered shell; only strings // the client BUILDS live in the client. An earlier draft asserted both From 548267f7d7848c902f8a45cc9adb0adcb1d9c84b Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 16:32:58 -0500 Subject: [PATCH 20/20] docs(ramble): correct guides on shelf refill, both languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2: both guides claimed the incubating slot auto-promotes from the shelf whenever it empties. promoteFromShelf's docstring in bundles/ramble/server/eggs.js is explicit that it is called from exactly one place, hatchIfReady — a gift, a swap, or an egg a lapsed swap frees while the slot is empty all wait on the shelf instead, with a one-tap Warm it on the pet page. Warmth earned with an empty slot still vanishes even while eggs sit on the shelf, so the old text materially overstated how safe it is to walk around without warming a waiting egg first. Claude-Session: https://claude.ai/code/session_01BpkA6EtBWm2rnr7miS8yAV --- docs/es/guide/ramble.md | 10 +++++++--- docs/guide/ramble.md | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/es/guide/ramble.md b/docs/es/guide/ramble.md index 919abe19..7284f6d6 100644 --- a/docs/es/guide/ramble.md +++ b/docs/es/guide/ramble.md @@ -75,9 +75,13 @@ Los huevos vienen de tres lugares, y ninguno es gratis: `lay.days` (predeterminado 14) pones uno tú misma. Los días no necesitan ser consecutivos, y el conteo solo corre mientras estés sin huevo, así que esto es un piso más que un caño. -Cuando se vacía el hueco incubando, el huevo más viejo en tu repisa se promueve a él automáticamente — -así que solo estás sin huevo cuando realmente no tienes ninguno. **El calor ganado sin ningún huevo -desaparece**; eso es deliberado, y la auto-promoción es lo que lo mantiene raro. +La repisa rellena la ranura de incubación cuando un huevo **eclosiona** — el más viejo de tu repisa +entra automáticamente. Si un huevo llega mientras tu ranura está vacía en cambio — un regalo, un +intercambio, o uno liberado cuando un intercambio caduca y desbloquea el último de tu repisa — +**no** se promueve automáticamente: espera en tu repisa, y la página de tu mascota ofrece un +**Calentarlo** de un toque para traerlo. **El calor ganado con la ranura vacía desaparece, incluso +si tienes huevos esperando en tu repisa** — así que calienta un huevo en espera antes de salir a +caminar. | Configuración | Predeterminado | Rige | |---|---|---| diff --git a/docs/guide/ramble.md b/docs/guide/ramble.md index 3bf64ee4..02c395d6 100644 --- a/docs/guide/ramble.md +++ b/docs/guide/ramble.md @@ -76,9 +76,11 @@ Eggs come from three places, and none of them is free: `lay.days` (default 14) you lay one yourself. Days need not be consecutive, and the count only runs while you are eggless, so this is a floor rather than a faucet. -When the incubating slot empties, the oldest egg on your shelf is promoted into it automatically — -so you are only ever eggless when you genuinely have none. **Warmth earned with no egg at all -vanishes**; that is deliberate, and auto-promote is what keeps it rare. +The shelf refills the incubating slot when an egg **hatches** — the oldest egg on your shelf moves +in automatically. If an egg arrives while your slot is empty instead — a gift, a swap, or one freed +when a lapsed swap unlocks it — it does **not** auto-promote: it waits on your shelf, and the pet +page offers a one-tap **Warm it** to bring it in. **Warmth earned with an empty slot vanishes, even +if you are holding eggs on your shelf** — so warm a waiting egg before you go walking. | Setting | Default | Governs | |---|---|---|