From a2f225392e5fc3e1e15b99b21db2bd977ae8f68b Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 10:15:57 -0500 Subject: [PATCH 01/15] plan: ramble phase 2, heart containers and maximum energy --- .../plans/2026-09-09-ramble-hearts-phase2.md | 1994 +++++++++++++++++ 1 file changed, 1994 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md diff --git a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md new file mode 100644 index 00000000..7c408cf2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md @@ -0,0 +1,1994 @@ +# Ramble — heart containers and maximum energy (Phase 2) — 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:** Walking new ground occasionally turns up a **heart container**, which permanently raises the bird's maximum energy — so a bigger bar means the bird stays happy far longer between walks, and the ~25 cells a player already unlocked hold hearts they can go back and collect. + +**Architecture:** No new table and no schema change. Hearts are rows in the existing `ramble_wallet` ledger under `kind = 'heart'`, which phase 1 already registered at all five sync sites. One new pure module (`hearts.js`) owns placement — a hash of the cell decides, exactly like `nestFor` and `seedFor` — and owns **one availability predicate that both the map and the payout call**, because a heart drawn but not granted is the bug phase 1 shipped and had to fix. Maximum energy is derived from the ledger (`base + hearts x per-heart`, capped), never stored, so it converges for free. + +**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` — §2.3 (spawn rules), §3 (the economy), §6.1/§6.4 (ledgers and settings), §7, §8, and phase 2 of §9. Decisions D6 (two currencies, hearts raise the bar only) and D2 (energy's stakes). + +**Phase 1 handoff (read it):** `docs/superpowers/handoffs/2026-09-08-ramble-map-phase1-shipped.md` — in particular the three blocking defects the whole-branch review caught, all three of which this plan is shaped to avoid repeating. + +## Kevin's rulings for this phase (2026-09-09, before planning) + +| # | Question | Ruling | +|---|---|---| +| K1 | Keep strict phase order, given seed stays earn-only until phase 4? | **Yes.** Phase 2 is hearts. **Do not build a shop, a wardrobe, an accessory catalogue, or any spend path.** Hearts are self-spending: earning one immediately lengthens the bar. | +| K2 | What does a player with ~25 already-unlocked cells get on the day hearts ship? | **Nothing is granted silently.** The hash decides identically for every cell, past or future, so roughly a third of those 25 cells hold a heart — and each one **sits on the map as an uncollected pip until the player walks back to it**. Existing players get destinations, not a number that jumped. | +| K3 | Is a heart visible before you take it? | **Yes in unlocked ground, no in fog.** Wild and retroactive hearts draw as pips. A first-unlock heart cannot be previewed because the cell was fogged, so it arrives as a moment when you walk in. | +| K4 | Also build D2's sad-portrait-to-contacts? | **No.** Out of scope. `servers/sharing/profile-avatar.js` keeps hardcoding mood `"happy"`. D2's delivery vehicle is the avatar broadcast whose pacing requirement (§5.4, coalesce into one broadcast per settled state) belongs to the accessories phase; pulling the portrait forward pulls that requirement forward with it. **Do not touch `servers/sharing/profile-avatar.js`.** | + +## Global Constraints + +- **Base:** `origin/main` @`e089ad4d`. Worktree `/home/kh0pp/crow-wt-ramble-hearts`, branch `feat/ramble-hearts`, 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`. +- **Suite baseline:** 4301 pass / 0 fail at `e089ad4d`. The suite must not regress. +- **No schema change, no migration, no `SCHEMA_GENERATION` bump.** Hearts ride `ramble_wallet` (`kind`, `key`, `delta`, `created_at`, `lamport_ts`), which already exists on every host. `bundles/ramble/server/init-tables.js` and `scripts/init-db.js` are **not modified by this plan**. If a task finds itself editing either file, stop — the design has drifted. +- **Ledgers, not balances (spec §6.1):** every heart is an append-only row under a natural idempotent key. Never store a heart count, and never store maximum energy — both are derived by reading the ledger. +- **A heart row's `delta` is ALWAYS the integer `1`** — a count of containers, never an energy amount. Two reasons, both load-bearing: (a) `applyRambleWallet` in `servers/sharing/instance-sync.js` resolves conflicts with `MAX(delta)`, which is only convergent when the value cannot differ between instances for the same key — phase 1 shipped a bug here precisely because seed's `delta` is the live, mutable `seed.per.pickup`; a constant `1` cannot disagree. (b) Storing energy would freeze `energy.max.per.heart` into history, so retuning that setting would not retune the bar. +- **⚠ Never write a NEGATIVE delta in this phase.** `applyRambleWallet`'s doc comment records why: `MAX(delta)` would resolve a `-10`/`-5` disagreement to `-5`, deducting less. There are no spends in phase 2. Phase 4 must key a spend by the purchase. +- **The map and the payout MUST read the same rule.** A heart drawn on the map that a walk does not grant (or the reverse) is the exact defect phase 1 shipped in its seed layer and had to fix. `availableHearts()` (what the map draws) and `recordHeartPickup()` (what a walk grants) both call the single pure `heartCandidate()`, and Task 2 has an executable test that the two agree cell-for-cell. +- **Fail closed on unlock.** A heart is only ever granted in a cell that is already in `ramble_cells`. `recordHeartPickup` checks that itself rather than trusting its caller, so a position fix refused by the `unlock.max.accuracy.m` gate can never pay a heart in ground the user did not enter. +- **Mood thresholds stay ABSOLUTE at 60 and 30** (`moodFor` in `pet.js` is unchanged). This is the spec's own reading: §4.3 says "a larger maximum energy means more slack before dropping below happy". A percentage threshold would give a bigger bar no benefit at all and would make hearts cosmetic. +- **Sparse features need explicit fixtures, not lucky hashes.** Phase 1's seed sparsity broke ledger tests written against values that happened to hash right. Every behavioural test in this plan sets `heart.rate = 1` (or `heart.wild.rate = 1`) so **every** cell holds a heart and the fixture is whatever you name. Rate itself is covered separately by one statistical test and one pinned regression vector. +- **Replication is EXPLICIT.** `ramble_wallet` is already registered for inbound apply; nothing goes outward unless a writer calls `emit`. `recordHeartPickup` takes `{ now, emit }` and emits after a successful insert, exactly as `recordSeedPickup` does. +- **Privacy (spec §2.4, §7):** hearts derive from the unlocked-cell set, which is a precise permanent record of everywhere the user has been. Nothing in this phase may add a heart, a cell or a balance to any contact-facing payload. Contacts-channel code (`delivery.js`, `trades.js`, `nostr-map.js`) is not touched. +- **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`. +- **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.9.5` -> `0.10.0`; then `npm run build-registry`. Without the bump, `repairInstalledBundleAssets` never refreshes the installed copy on grackle and the deploy silently ships nothing. +- **`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`). + +## Deviations from the spec, recorded + +Two settings the spec does not list are added here. Both follow the precedent of `seed.rate`, which phase 1 shipped as 0.9.5 for exactly this reason, and both are settings rather than constants so balance is a configuration change. + +1. **`heart.wild.rate` (default 40).** Spec §6.4 gives `heart.wild.days` (30) as "the minimum gap before a heart may reappear in already-unlocked ground" but gives wild hearts no rarity. Without a rate, **every** unlocked cell yields a wild heart every 30 days: a 300-cell map would pay 10 hearts a month, "rarely" would be false, and the map would be carpeted in hearts — the same complaint that forced seed to become sparse in 0.9.5. At 1-in-40 per 30-day window, 25 cells pay about 0.6 hearts a month and 300 cells about 7.5, which keeps §2.3's deliberate concession (grindable by a heavy walker) without making exploration pointless. +2. **`energy.max.cap` (default 300).** The spec caps nothing. Uncapped, a long-lived map pushes maximum energy high enough that the -10-per-6h decay can never reach the 60 threshold, which makes mood permanent, hearts worthless past a point, and — critically — pre-breaks **phase 3**, whose laying floor is gated on "the bird ends the day happy". 300 is base 100 plus 20 hearts, which is exactly Zelda's 20-heart cap; D6 makes that mapping the frame of the whole currency design. + +Both are noted in the PR body so a reviewer reads them as decisions, not drift. + +--- + +## File structure + +**Create** +- `bundles/ramble/server/hearts.js` — the whole heart mechanic: placement (`heartFor`, `wildHeartFor`, `heartCandidate`, `wildWindow`), settings (`readHeartSettings`), the ledger half (`availableHearts`, `recordHeartPickup`, `heartsBalance`), and the derived ceiling (`maxEnergy`). Imports only `nests.js` (for `CELL7_RE`) and `anchors.js` (for `decodeGeohash`) — never `pet.js`, which imports *this* module. +- `tests/ramble-hearts.test.js` — placement and settings (pure). +- `tests/ramble-hearts-ledger.test.js` — pickup, availability, the same-rule agreement test, and multi-instance convergence. + +**Modify** +- `bundles/ramble/server/pet.js` — clamp against the derived maximum instead of a hardcoded 100; report `energy_max`. +- `bundles/ramble/server/feed.js` — its `readPet` helper must carry the same `energy_max`. +- `bundles/ramble/server/bird-svg.cjs` — `drawHeart()` / `mountHeart()`. +- `bundles/ramble/panel/routes.js` — load `hearts.js` into `mods`; grant on a fix in `POST /api/ramble/area`; heart pips in `GET /api/ramble/zones`; `hearts` on `GET /api/ramble/pet`. +- `bundles/ramble/panel/static/ramble.js` — the heart-pip layer, the counter, the pickup moment, the scaled energy bar, the heart row. +- `bundles/ramble/panel/static/ramble.css` — pip, fallback dot, pop, counter and heart-row rules. +- `bundles/ramble/panel/ramble.js` — the heart chip in the map bar; the heart row on the pet card. +- `tests/ramble-pet.test.js`, `tests/ramble-panel.test.js`, `tests/ramble-bird-svg.test.js` — extended 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:** `bundles/ramble/server/init-tables.js`, `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/trades.js`. + +--- + +## Task 1: `hearts.js` — where a heart is, and the numbers that govern it + +**Files:** +- Create: `bundles/ramble/server/hearts.js` +- Create: `tests/ramble-hearts.test.js` + +**Interfaces:** +- Consumes: `CELL7_RE` from `bundles/ramble/server/nests.js`; `decodeGeohash` from `bundles/ramble/server/anchors.js`. +- Produces, for Tasks 2-4: + - `HEART_KIND = "heart"`, `HEART_SALT`, `HEART_WILD_SALT` + - `HEART_RATE_DEFAULT = 3`, `HEART_WILD_DAYS_DEFAULT = 30`, `HEART_WILD_RATE_DEFAULT = 40`, `ENERGY_MAX_BASE_DEFAULT = 100`, `ENERGY_MAX_PER_HEART_DEFAULT = 10`, `ENERGY_MAX_CAP_DEFAULT = 300` + - `wildWindow(now, days) -> integer` + - `heartFor(cell, { rate }) -> { cell, key, source: "first", lat, lon } | null` + - `wildHeartFor(cell, window, { wildRate }) -> { cell, key, source: "wild", lat, lon } | null` + - `heartCandidate(cell, window, { rate, wildRate }) -> the same shape | null` + - `readHeartSettings(db) -> { rate, wildDays, wildRate, energyBase, perHeart, cap }` + +**Why placement is a hash and not a table:** this copies `nestFor` and `seedFor` deliberately. The answer is identical on every one of the user's devices with nothing stored and nothing to sync, and it cannot be re-rolled by leaving a cell and coming back. + +- [ ] **Step 1: Write the failing test** + +Create `tests/ramble-hearts.test.js`: + +```js +/** + * Spec 2026-09-08 §2.3 and §6.4 — heart container placement. + * + * Placement is a hash of the cell, like nestFor and seedFor: identical on + * every device, nothing stored, and not re-rollable by walking out and back. + * + * ⚠ FIXTURES, NOT LUCKY HASHES. Phase 1's sparse seed broke tests that were + * written against cells which happened to hash right. Every behavioural test + * below sets rate 1 so EVERY cell holds a heart and the fixture is whatever we + * name. The rate itself is covered by one statistical test and one pinned + * vector, which are the only two places a specific hash value matters. + */ +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 { decodeGeohash, encodeGeohash } from "../bundles/ramble/server/anchors.js"; +import { + HEART_KIND, HEART_SALT, HEART_WILD_SALT, + HEART_RATE_DEFAULT, HEART_WILD_DAYS_DEFAULT, HEART_WILD_RATE_DEFAULT, + ENERGY_MAX_BASE_DEFAULT, ENERGY_MAX_PER_HEART_DEFAULT, ENERGY_MAX_CAP_DEFAULT, + wildWindow, heartFor, wildHeartFor, heartCandidate, readHeartSettings, +} from "../bundles/ramble/server/hearts.js"; + +const CELL = "9vk79ed"; +const ALL = { rate: 1, wildRate: 1 }; // rate 1: every cell holds one + +function inside(cell, spot) { + const c = decodeGeohash(cell); + return spot.lat >= c.lat - c.latErr && spot.lat <= c.lat + c.latErr + && spot.lon >= c.lon - c.lonErr && spot.lon <= c.lon + c.lonErr; +} + +test("the defaults are the spec's numbers, plus the two recorded deviations", () => { + assert.equal(HEART_KIND, "heart"); + assert.equal(HEART_RATE_DEFAULT, 3); + assert.equal(HEART_WILD_DAYS_DEFAULT, 30); + assert.equal(HEART_WILD_RATE_DEFAULT, 40); + assert.equal(ENERGY_MAX_BASE_DEFAULT, 100); + assert.equal(ENERGY_MAX_PER_HEART_DEFAULT, 10); + assert.equal(ENERGY_MAX_CAP_DEFAULT, 300); + assert.notEqual(HEART_SALT, HEART_WILD_SALT, "the two sources must not share a salt"); +}); + +test("heartFor: at rate 1 every valid cell holds one, at a point INSIDE the cell", () => { + const spot = heartFor(CELL, ALL); + assert.ok(spot, "rate 1 always hits"); + assert.equal(spot.cell, CELL); + assert.equal(spot.source, "first"); + assert.equal(spot.key, CELL, "a first heart is keyed by the bare cell"); + assert.ok(inside(CELL, spot), "the pip sits inside its own cell"); + // Not the centre: a row of hearts along a street must not line up. + const c = decodeGeohash(CELL); + assert.ok(spot.lat !== c.lat || spot.lon !== c.lon, "hash-placed, not centred"); +}); + +test("heartFor: deterministic, and junk is refused rather than thrown", () => { + assert.deepEqual(heartFor(CELL, ALL), heartFor(CELL, ALL)); + assert.equal(heartFor("not-a-cell", ALL), null); + assert.equal(heartFor("", ALL), null); + assert.equal(heartFor(null, ALL), null); + assert.equal(heartFor(CELL, { rate: 0 }), heartFor(CELL, {}), "a junk rate falls back to the default"); +}); + +test("heartFor at the default rate hits roughly one cell in three", () => { + let hits = 0; + const total = 3000; + let n = 0; + for (let i = 0; i < total; i++) { + // A spread of real coordinates, not sequential strings: geohash prefixes + // are not uniform over arbitrary text. + const lat = -60 + ((i * 7919) % 12000) / 100; + const lon = -170 + ((i * 6271) % 34000) / 100; + const cell = encodeGeohash(lat, lon, 7); + n += 1; + if (heartFor(cell, { rate: HEART_RATE_DEFAULT })) hits += 1; + } + const share = hits / n; + assert.ok(share > 0.28 && share < 0.39, `expected ~1/3, got ${share}`); +}); + +test("wildHeartFor: window-scoped, and a DIFFERENT place from the first heart", () => { + const w = 610; + const wild = wildHeartFor(CELL, w, ALL); + assert.ok(wild); + assert.equal(wild.source, "wild"); + assert.equal(wild.key, CELL + ":" + w, "a wild heart is keyed by cell AND window"); + assert.ok(inside(CELL, wild)); + const first = heartFor(CELL, ALL); + assert.ok(wild.lat !== first.lat || wild.lon !== first.lon, + "independent salts: the two sources must not land on the same spot"); + assert.notDeepEqual(wildHeartFor(CELL, w + 1, ALL), wild, "a new window is a new roll"); + assert.equal(wildHeartFor(CELL, "nope", ALL), null); + assert.equal(wildHeartFor("bad", w, ALL), null); +}); + +test("wildWindow buckets by whole days and falls back on junk", () => { + const day = 24 * 3600 * 1000; + assert.equal(wildWindow(0, 30), 0); + assert.equal(wildWindow(30 * day - 1, 30), 0); + assert.equal(wildWindow(30 * day, 30), 1); + assert.equal(wildWindow(60 * day, 30), 2); + assert.equal(wildWindow(60 * day, 0), wildWindow(60 * day, HEART_WILD_DAYS_DEFAULT)); + assert.equal(wildWindow(60 * day, "x"), wildWindow(60 * day, HEART_WILD_DAYS_DEFAULT)); +}); + +test("heartCandidate prefers the permanent heart when both would hit", () => { + const both = heartCandidate(CELL, 610, ALL); + assert.equal(both.source, "first", "the once-ever heart outranks the regrowing one"); + assert.deepEqual(both, heartFor(CELL, ALL)); + + // No first heart available: the wild one is the candidate. The huge rate is + // asserted to miss rather than assumed to, so a surprise hit reads as a + // precondition failure instead of a confusing candidate-source failure. + assert.equal(heartFor(CELL, { rate: 999999 }), null, "precondition: no first heart at this rate"); + const onlyWild = heartCandidate(CELL, 610, { rate: 999999, wildRate: 1 }); + assert.equal(onlyWild.source, "wild"); + + assert.equal(wildHeartFor(CELL, 610, { wildRate: 999999 }), null, "precondition: no wild heart either"); + assert.equal(heartCandidate(CELL, 610, { rate: 999999, wildRate: 999999 }), null); + assert.equal(heartCandidate("bad", 610, ALL), null); +}); + +test("readHeartSettings reads all six keys, and refuses junk", async () => { + const db = createClient({ url: "file::memory:" }); + await initRambleTables(db); + const put = (k, v) => db.execute({ + sql: "INSERT INTO ramble_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + args: [k, v], + }); + + assert.deepEqual(await readHeartSettings(db), { + rate: 3, wildDays: 30, wildRate: 40, energyBase: 100, perHeart: 10, cap: 300, + }, "an untouched db reads the defaults"); + + await put("heart.rate", "1"); + await put("heart.wild.days", "7"); + await put("heart.wild.rate", "2"); + await put("energy.max.base", "80"); + await put("energy.max.per.heart", "25"); + await put("energy.max.cap", "500"); + assert.deepEqual(await readHeartSettings(db), { + rate: 1, wildDays: 7, wildRate: 2, energyBase: 80, perHeart: 25, cap: 500, + }); + + for (const k of ["heart.rate", "heart.wild.days", "heart.wild.rate", "energy.max.base", "energy.max.per.heart", "energy.max.cap"]) { + await put(k, "banana"); + } + assert.deepEqual(await readHeartSettings(db), { + rate: 3, wildDays: 30, wildRate: 40, energyBase: 100, perHeart: 10, cap: 300, + }, "junk everywhere falls all the way back"); + + // A cap below the base would clamp a heartless bird's energy DOWN. Refuse it. + await put("energy.max.base", "100"); + await put("energy.max.cap", "40"); + assert.equal((await readHeartSettings(db)).cap, 100, "the cap is never below the base"); +}); +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +node scripts/run-suite.mjs tests/ramble-hearts.test.js +``` + +Expected: FAIL — `Cannot find module .../bundles/ramble/server/hearts.js`. + +- [ ] **Step 3: Write `bundles/ramble/server/hearts.js`** + +```js +/** + * Heart containers (spec 2026-09-08 §2.3, §3, D6). + * + * The rare currency. A heart raises the bird's MAXIMUM energy and does nothing + * else — the Zelda mapping D6 names: seed buys gear, hearts only extend the + * bar. There is no spend path in this phase. + * + * WHERE A HEART IS is a hash of the cell, copying nestFor and seedFor: the + * answer is identical on every one of the user's devices with nothing stored + * and nothing to sync, and it cannot be re-rolled by leaving and coming back. + * Two independent sources, two independent salts: + * + * - the FIRST heart, keyed by the bare cell, once ever, roughly 1 in + * `heart.rate` cells. This is the one a new unlock can surprise you with. + * - a WILD heart, keyed `cell:window`, in ground already unlocked, roughly + * 1 in `heart.wild.rate` cells per `heart.wild.days` window. §2.3 accepts + * that this makes maximum energy grindable by a heavy walker: it is not + * competitive power, only a longer buffer, and it lets someone who cannot + * range far still progress. + * + * ⚠ ONE RULE, TWO READERS. `heartCandidate` is the ONLY place that decides + * whether a cell holds a heart. The map (availableHearts) and the payout + * (recordHeartPickup) both go through it. Phase 1 shipped a seed layer where + * the map and the payout disagreed and had to fix it; this module exists in + * this shape so that cannot happen again. + * + * ⚠ A HEART ROW'S `delta` IS ALWAYS 1 — a count of containers, never an + * energy amount. applyRambleWallet resolves conflicts with MAX(delta), which + * is only convergent when the value cannot differ between instances for the + * same key; a constant cannot disagree. Storing energy would also freeze + * `energy.max.per.heart` into history and make retuning it a no-op. + */ +import { createHash } from "node:crypto"; +import { CELL7_RE } from "./nests.js"; +import { decodeGeohash } from "./anchors.js"; + +export const HEART_KIND = "heart"; +export const HEART_SALT = "ramble-heart-v1:"; +export const HEART_WILD_SALT = "ramble-heart-wild-v1:"; + +export const HEART_RATE_DEFAULT = 3; +export const HEART_WILD_DAYS_DEFAULT = 30; +export const HEART_WILD_RATE_DEFAULT = 40; +export const ENERGY_MAX_BASE_DEFAULT = 100; +export const ENERGY_MAX_PER_HEART_DEFAULT = 10; +export const ENERGY_MAX_CAP_DEFAULT = 300; + +/** Which regrowth window `now` falls in. Same cell, same window = one wild heart. */ +export function wildWindow(now, days) { + const d = Number.isFinite(Number(days)) && Number(days) >= 1 ? Number(days) : HEART_WILD_DAYS_DEFAULT; + return Math.floor(Number(now) / (d * 24 * 3600 * 1000)); +} + +/** + * The shared body: hash `salt + material`, keep 1 in `rate`, and place the + * result at a hash-derived point INSIDE the cell rather than at its centre, so + * a street's worth of hearts does not line up like a pegboard. + */ +function place(salt, material, rate, fallbackRate, cell, key, source) { + if (typeof cell !== "string" || !CELL7_RE.test(cell)) return null; + const r = Number.isInteger(rate) && rate >= 1 ? rate : fallbackRate; + const h = createHash("sha256").update(salt + material).digest(); + if (h.readUInt32BE(0) % r !== 0) return null; + let c; + try { c = decodeGeohash(cell); } catch { return null; } + if (!c || !Number.isFinite(c.lat) || !Number.isFinite(c.lon)) return null; + const fy = h.readUInt32BE(4) / 0x100000000; + const fx = h.readUInt32BE(8) / 0x100000000; + return { + cell, key, source, + lat: c.lat - c.latErr + fy * 2 * c.latErr, + lon: c.lon - c.lonErr + fx * 2 * c.lonErr, + }; +} + +/** The once-ever heart in this cell, or null. Keyed by the bare cell. */ +export function heartFor(cell, { rate = HEART_RATE_DEFAULT } = {}) { + return place(HEART_SALT, String(cell), rate, HEART_RATE_DEFAULT, cell, String(cell), "first"); +} + +/** The regrowing heart in this cell in this window, or null. Keyed `cell:window`. */ +export function wildHeartFor(cell, window, { wildRate = HEART_WILD_RATE_DEFAULT } = {}) { + if (!Number.isFinite(Number(window))) return null; + const w = Number(window); + return place(HEART_WILD_SALT, String(cell) + ":" + String(w), wildRate, HEART_WILD_RATE_DEFAULT, + cell, String(cell) + ":" + String(w), "wild"); +} + +/** + * THE ONE RULE. What heart, if any, this cell holds right now. + * + * The permanent heart outranks the wild one when both would hit: it is the + * rarer of the two and it disappears forever once taken, so handing it over + * first is strictly better for the player. The wild heart will come round + * again next window. + */ +export function heartCandidate(cell, window, { rate = HEART_RATE_DEFAULT, wildRate = HEART_WILD_RATE_DEFAULT } = {}) { + return heartFor(cell, { rate }) || wildHeartFor(cell, window, { wildRate }); +} + +/** Live settings (spec §6.4 plus the two deviations). Junk or a negative falls back. */ +export async function readHeartSettings(db) { + const out = { + rate: HEART_RATE_DEFAULT, + wildDays: HEART_WILD_DAYS_DEFAULT, + wildRate: HEART_WILD_RATE_DEFAULT, + energyBase: ENERGY_MAX_BASE_DEFAULT, + perHeart: ENERGY_MAX_PER_HEART_DEFAULT, + cap: ENERGY_MAX_CAP_DEFAULT, + }; + try { + const { rows } = await db.execute({ + sql: `SELECT key, value FROM ramble_settings WHERE key IN + ('heart.rate', 'heart.wild.days', 'heart.wild.rate', + 'energy.max.base', 'energy.max.per.heart', 'energy.max.cap')`, + args: [], + }); + for (const r of rows || []) { + const n = parseInt(r.value, 10); + if (!Number.isInteger(n)) continue; + if (r.key === "heart.rate" && n >= 1) out.rate = n; + if (r.key === "heart.wild.days" && n >= 1) out.wildDays = n; + if (r.key === "heart.wild.rate" && n >= 1) out.wildRate = n; + if (r.key === "energy.max.base" && n >= 1) out.energyBase = n; + if (r.key === "energy.max.per.heart" && n >= 0) out.perHeart = n; + if (r.key === "energy.max.cap" && n >= 1) out.cap = n; + } + } catch { /* defaults */ } + // A cap below the base would clamp a heartless bird's energy DOWN, which is + // a punishment no setting in this design is allowed to hand out. + if (out.cap < out.energyBase) out.cap = out.energyBase; + return out; +} +``` + +- [ ] **Step 4: Run the test and watch it pass** + +```bash +node scripts/run-suite.mjs tests/ramble-hearts.test.js +``` + +Expected: PASS, 8 tests. + +- [ ] **Step 5: Pin the salt with a regression vector** + +The statistical test proves the *shape* of the distribution; it would not notice the salt changing. Pin the actual values once. Run exactly this, in the worktree: + +```bash +node --input-type=module -e ' +import { heartFor, wildHeartFor } from "./bundles/ramble/server/hearts.js"; +for (const c of ["9vk79ed", "9v6m2xt", "dr5regw", "gcpvj0d", "u33dc0e", "wecnrmd"]) { + const f = heartFor(c, {}); + const w = wildHeartFor(c, 610, {}); + console.log(c, f ? "FIRST" : "-", w ? "WILD" : "-"); +}' +``` + +Paste the observed truth table into a new test in `tests/ramble-hearts.test.js` — whatever it says, unchanged. Do **not** pick cells to make it look tidy; this test's whole job is to fail if the salt or the hash arithmetic ever moves. + +```js +test("pinned: the default-rate placement for six fixed cells never moves", () => { + // Generated once from the salts in hearts.js. If this fails, someone changed + // a salt or the hash arithmetic, and every existing player's map moved. + const expected = { + // { first: true|false, wild: true|false } from the snippet above> + }; + for (const [cell, want] of Object.entries(expected)) { + assert.equal(!!heartFor(cell, {}), want.first, cell + " first heart"); + assert.equal(!!wildHeartFor(cell, 610, {}), want.wild, cell + " wild heart"); + } +}); +``` + +- [ ] **Step 6: Run it, then run the whole ramble slice** + +```bash +node scripts/run-suite.mjs tests/ramble-hearts.test.js +node scripts/run-suite.mjs tests/ramble-wallet.test.js tests/ramble-cells.test.js tests/ramble-nests.test.js +``` + +Expected: all PASS. + +- [ ] **Step 7: Commit** + +```bash +git add bundles/ramble/server/hearts.js tests/ramble-hearts.test.js +git commit bundles/ramble/server/hearts.js tests/ramble-hearts.test.js -m "ramble: where a heart container is, decided by the cell" +git show --stat HEAD +``` + +--- + +## Task 2: the ledger half — taking a heart, and what the map may draw + +**Files:** +- Modify: `bundles/ramble/server/hearts.js` (append; do not restructure Task 1's code) +- Create: `tests/ramble-hearts-ledger.test.js` + +**Interfaces:** +- Consumes: everything Task 1 produced, plus `ramble_wallet` and `ramble_cells` (both already exist on every host), plus `applyRambleWallet` from `servers/sharing/instance-sync.js` for the convergence test. +- Produces, for Tasks 3-4: + - `recordHeartPickup(db, cell, { now, emit }) -> { picked: boolean, amount: 0 | 1, source?: "first" | "wild" }` + - `availableHearts(db, cells, { now }) -> [{ cell, key, source, lat, lon }]` + - `heartsBalance(db) -> integer` + - `maxEnergy(db) -> integer` + +**Three rules this task exists to enforce:** + +1. **Same rule, two readers.** `availableHearts` and `recordHeartPickup` both go through `heartCandidate`. Step 1's `same rule` test asserts they agree cell-for-cell over a fixture, before and after collection. This is the phase 1 defect made executable. +2. **Fail closed on unlock.** `recordHeartPickup` verifies the cell is in `ramble_cells` itself. A fix that the `unlock.max.accuracy.m` gate refused must never pay a heart in ground the user did not enter, and the route is not trusted to check. +3. **`delta` is the constant `1`.** Never `perHeart`, never a spend. + +**Why reading every heart row is fine here** (and why `harvestableCells` had to be cleverer for seed): a heart row exists only per heart actually *taken*. Firsts are capped by the player's unlocked-cell count at 1-in-3, and wilds are 1-in-40 per 30-day window. The table's heart slice is the player's lifetime collection — dozens of rows, not the roughly-one-per-cell-per-day the seed ledger accrues. + +- [ ] **Step 1: Write the failing test** + +Create `tests/ramble-hearts-ledger.test.js`: + +```js +/** + * Spec 2026-09-08 §6.1, §8 — the heart ledger. + * + * Hearts are append-only rows in ramble_wallet under kind 'heart'. There is no + * heart TABLE and no stored balance: the count and the maximum energy derived + * from it are both read out of the ledger, which is what makes them converge + * across the user's own instances for free. + * + * ⚠ rate 1 throughout, so every cell in a fixture holds a heart and no test + * depends on a cell that happens to hash lucky (the phase 1 lesson). + */ +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 { applyRambleWallet } from "../servers/sharing/instance-sync.js"; +import { + HEART_KIND, heartCandidate, wildWindow, + recordHeartPickup, availableHearts, heartsBalance, maxEnergy, +} from "../bundles/ramble/server/hearts.js"; + +const NOW = 1_757_000_000_000; +// Six real, distinct geohash-7 cells, checked with decodeGeohash while this +// was written: Houston, Texas hill country, New York, London, Berlin, Hong +// Kong. At rate 1 every one of them holds a heart, so this fixture is exactly +// what it looks like — no cell here was chosen for hashing lucky. +const CELLS = ["9vk79ed", "9v6m2xt", "dr5regw", "gcpvj0d", "u33dc0e", "wecnrmd"]; + +async function freshDb({ rate = 1, wildRate = 999999, base, per, cap } = {}) { + const db = createClient({ url: "file::memory:" }); + await initRambleTables(db); + const put = (k, v) => db.execute({ + sql: "INSERT INTO ramble_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + args: [k, String(v)], + }); + await put("heart.rate", rate); + await put("heart.wild.rate", wildRate); + if (base != null) await put("energy.max.base", base); + if (per != null) await put("energy.max.per.heart", per); + if (cap != null) await put("energy.max.cap", cap); + return db; +} + +async function unlock(db, cells, at = NOW) { + for (const c of cells) { + await db.execute({ + sql: "INSERT INTO ramble_cells (cell, first_unlocked_at) VALUES (?, ?) ON CONFLICT(cell) DO NOTHING", + args: [c, at], + }); + } +} + +const walletRows = async (db) => + (await db.execute({ sql: "SELECT * FROM ramble_wallet WHERE kind = ? ORDER BY key", args: [HEART_KIND] })).rows; + +test("a heart is NEVER granted in a cell that is not unlocked — fail closed", async () => { + const db = await freshDb(); + const out = await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.deepEqual(out, { picked: false, amount: 0 }); + assert.equal((await walletRows(db)).length, 0, "no row, so no heart"); + assert.equal(await heartsBalance(db), 0); +}); + +test("the first pickup writes exactly one row with delta 1, and emits it", async () => { + const db = await freshDb(); + await unlock(db, [CELLS[0]]); + const seen = []; + const emit = (table, op, row) => { seen.push({ table, op, row }); }; + + const out = await recordHeartPickup(db, CELLS[0], { now: NOW, emit }); + assert.deepEqual(out, { picked: true, amount: 1, source: "first" }); + + const rows = await walletRows(db); + assert.equal(rows.length, 1); + assert.equal(rows[0].key, CELLS[0], "a first heart is keyed by the bare cell"); + assert.equal(Number(rows[0].delta), 1, "delta is a COUNT of containers, never an energy amount"); + assert.equal(Number(rows[0].created_at), NOW); + + assert.equal(seen.length, 1, "the outbound half: without this the ledger syncs one way only"); + assert.equal(seen[0].table, "ramble_wallet"); + assert.equal(seen[0].op, "insert"); + assert.equal(seen[0].row.kind, HEART_KIND); + assert.equal(seen[0].row.delta, 1); +}); + +test("the permanent heart is gone for good — a second visit pays nothing, ever", async () => { + const db = await freshDb(); + await unlock(db, [CELLS[0]]); + await recordHeartPickup(db, CELLS[0], { now: NOW }); + + assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: NOW }), { picked: false, amount: 0 }); + const muchLater = NOW + 400 * 24 * 3600 * 1000; + assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: muchLater }), { picked: false, amount: 0 }); + assert.equal((await walletRows(db)).length, 1); +}); + +test("a wild heart regrows: once per window, again in the next", async () => { + // No first hearts at all, so every hit here is unambiguously a wild one. + const db = await freshDb({ rate: 999999, wildRate: 1 }); + await unlock(db, [CELLS[0]]); + const day = 24 * 3600 * 1000; + // Asserted, not assumed: a surprise first heart at this rate would otherwise + // fail below as a baffling "source" mismatch. + assert.equal(heartCandidate(CELLS[0], wildWindow(NOW, 30), { rate: 999999, wildRate: 999999 }), null, + "precondition: neither source hits at these rates"); + + const first = await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.deepEqual(first, { picked: true, amount: 1, source: "wild" }); + assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: NOW + day }), { picked: false, amount: 0 }, + "still the same 30-day window"); + + const next = await recordHeartPickup(db, CELLS[0], { now: NOW + 31 * day }); + assert.deepEqual(next, { picked: true, amount: 1, source: "wild" }); + + const rows = await walletRows(db); + assert.equal(rows.length, 2); + for (const r of rows) assert.match(String(r.key), /^[0-9b-hjkmnp-z]{7}:\d+$/, "wild keys carry their window"); +}); + +test("THE SAME RULE: what the map draws is exactly what a walk would grant", async () => { + // The phase 1 defect, made executable. A heart shown but not granted (or + // granted but never shown) is the bug that shipped in the seed layer. + const db = await freshDb(); + await unlock(db, CELLS); + + const drawn = await availableHearts(db, CELLS, { now: NOW }); + assert.equal(drawn.length, CELLS.length, "rate 1: every unlocked cell in the fixture"); + for (const spot of drawn) { + assert.deepEqual(spot, heartCandidate(spot.cell, wildWindow(NOW, 30), { rate: 1, wildRate: 999999 }), + "the map draws the candidate itself, not a re-derived guess"); + } + + // Take three of them, then assert the two readers STILL agree. + const taken = CELLS.slice(0, 3); + for (const c of taken) { + assert.equal((await recordHeartPickup(db, c, { now: NOW })).picked, true); + } + const after = await availableHearts(db, CELLS, { now: NOW }); + assert.deepEqual(after.map((s) => s.cell).sort(), CELLS.slice(3).sort(), + "a collected heart leaves the map"); + for (const c of taken) { + assert.equal((await recordHeartPickup(db, c, { now: NOW })).picked, false, + "and a cell the map no longer draws grants nothing"); + } + for (const c of CELLS.slice(3)) { + assert.equal((await recordHeartPickup(db, c, { now: NOW })).picked, true, + "while every cell the map still draws does grant"); + } +}); + +test("availableHearts never leaves unlocked ground, and never throws on junk", async () => { + const db = await freshDb(); + await unlock(db, [CELLS[0]]); + const asked = [CELLS[0], CELLS[1], "not-a-cell", "", null, 7]; + const drawn = await availableHearts(db, asked, { now: NOW }); + assert.deepEqual(drawn.map((s) => s.cell), [CELLS[0]], + "a cell the caller has not unlocked is not drawn even when it is asked for"); + assert.deepEqual(await availableHearts(db, [], { now: NOW }), []); + assert.deepEqual(await availableHearts(db, null, { now: NOW }), []); +}); + +test("heartsBalance counts containers; maxEnergy derives the bar and honours the cap", async () => { + const db = await freshDb({ rate: 1, base: 100, per: 10, cap: 130 }); + assert.equal(await heartsBalance(db), 0); + assert.equal(await maxEnergy(db), 100, "no hearts: the base"); + + await unlock(db, CELLS); + for (const c of CELLS.slice(0, 2)) await recordHeartPickup(db, c, { now: NOW }); + assert.equal(await heartsBalance(db), 2); + assert.equal(await maxEnergy(db), 120, "base + hearts x per-heart"); + + for (const c of CELLS.slice(2)) await recordHeartPickup(db, c, { now: NOW }); + assert.equal(await heartsBalance(db), 6); + assert.equal(await maxEnergy(db), 130, "the cap holds"); +}); + +test("seed rows are not hearts and hearts are not seed", async () => { + const db = await freshDb(); + await unlock(db, [CELLS[0]]); + await db.execute({ + sql: "INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES ('seed', ?, 5, ?)", + args: [CELLS[0] + ":1", NOW], + }); + await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.equal(await heartsBalance(db), 1, "the seed pile does not inflate the heart count"); +}); + +test("two instances converge on the same heart count whatever order rows arrive in", async () => { + // Spec §8: anything that replicates needs a multi-instance test, not a + // single-database one. delta is a constant 1, so MAX(delta) — which phase 1 + // had to fix for seed — is safe here BY CONSTRUCTION. This test is what says + // so out loud. + const rows = [ + { kind: HEART_KIND, key: CELLS[0], delta: 1, created_at: NOW }, + { kind: HEART_KIND, key: CELLS[1], delta: 1, created_at: NOW + 10 }, + { kind: HEART_KIND, key: CELLS[2] + ":610", delta: 1, created_at: NOW + 20 }, + ]; + const a = await freshDb(); + const b = await freshDb(); + for (let i = 0; i < rows.length; i++) await applyRambleWallet(a, "insert", rows[i], 10 + i); + for (let i = rows.length - 1; i >= 0; i--) await applyRambleWallet(b, "insert", rows[i], 10 + i); + // And a duplicate arriving late on both. + await applyRambleWallet(a, "insert", rows[0], 99); + await applyRambleWallet(b, "insert", rows[0], 99); + + assert.equal(await heartsBalance(a), 3); + assert.equal(await heartsBalance(b), 3); + assert.equal(await maxEnergy(a), await maxEnergy(b)); + for (const r of await walletRows(a)) assert.equal(Number(r.delta), 1, "no row ever grew"); +}); +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +node scripts/run-suite.mjs tests/ramble-hearts-ledger.test.js +``` + +Expected: FAIL — `recordHeartPickup is not a function`. + +- [ ] **Step 3: Append the ledger half to `bundles/ramble/server/hearts.js`** + +```js +/** Mirrors eggs.js's helper: an emit must never be able to fail the write. */ +async function safeEmit(emit, table, op, row) { + if (typeof emit !== "function") return; + try { await emit(table, op, row); } + catch (err) { try { console.warn(`[ramble] emit ${table} failed:`, err?.message); } catch {} } +} + +/** Every heart key already taken. Bounded by the player's lifetime collection. */ +async function takenKeys(db) { + const { rows } = await db.execute({ + sql: "SELECT key FROM ramble_wallet WHERE kind = ?", args: [HEART_KIND], + }); + return new Set((rows || []).map((r) => String(r.key))); +} + +/** + * Take the heart in this cell, if there is one and it is still there. + * + * ⚠ FAIL CLOSED. The cell must already be in ramble_cells. A position fix + * vaguer than `unlock.max.accuracy.m` is refused an unlock, and it must be + * refused a heart on exactly the same grounds — otherwise a 2 km wifi fix pays + * out in ground the user never entered. Checked HERE rather than trusted to + * the caller, because this function is the payout. + */ +export async function recordHeartPickup(db, cell, { now = Date.now(), emit } = {}) { + const none = { picked: false, amount: 0 }; + if (!db || typeof cell !== "string" || !CELL7_RE.test(cell)) return none; + try { + const { rows } = await db.execute({ + sql: "SELECT 1 AS ok FROM ramble_cells WHERE cell = ?", args: [cell], + }); + if (!rows || rows.length === 0) return none; + + const { rate, wildDays, wildRate } = await readHeartSettings(db); + // NOT `Number(now) || Date.now()` — that treats `now: 0` as falsy and + // silently substitutes the real clock (the phase 1 note on this still holds). + const at = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + const spot = heartCandidate(cell, wildWindow(at, wildDays), { rate, wildRate }); + if (!spot) return none; + + const res = await db.execute({ + sql: `INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?) + ON CONFLICT(kind, key) DO NOTHING`, + args: [HEART_KIND, spot.key, at], + }); + if (Number(res.rowsAffected) === 0) return none; + await safeEmit(emit, "ramble_wallet", "insert", + { kind: HEART_KIND, key: spot.key, delta: 1, created_at: at }); + return { picked: true, amount: 1, source: spot.source }; + } catch (err) { + try { console.warn("[ramble] heart pickup failed:", err?.message); } catch {} + return none; + } +} + +/** + * Which of these cells still hold a heart to walk to. + * + * ⚠ THE SAME RULE THE PAYOUT USES. This goes through heartCandidate, exactly + * as recordHeartPickup does, and returns the candidate object itself rather + * than a re-derived position — so the map cannot drift from the payout. A + * heart shown but not granted is the defect phase 1 shipped in its seed layer. + * + * Cells the caller has not unlocked are dropped even when they are asked for: + * a heart in fog would be a preview of ground you have not earned (K3). + */ +export async function availableHearts(db, cells, { now = Date.now() } = {}) { + const asked = (Array.from(cells || [])).filter((c) => typeof c === "string" && CELL7_RE.test(c)); + if (!db || asked.length === 0) return []; + try { + const { rate, wildDays, wildRate } = await readHeartSettings(db); + const at = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + const window = wildWindow(at, wildDays); + const { rows } = await db.execute({ sql: "SELECT cell FROM ramble_cells", args: [] }); + const unlocked = new Set((rows || []).map((r) => String(r.cell))); + const taken = await takenKeys(db); + const out = []; + for (const cell of asked) { + if (!unlocked.has(cell)) continue; + const spot = heartCandidate(cell, window, { rate, wildRate }); + if (spot && !taken.has(spot.key)) out.push(spot); + } + return out; + } catch (err) { + // A map that cannot say where a heart is should still draw. Never throw. + try { console.warn("[ramble] availableHearts failed:", err?.message); } catch {} + return []; + } +} + +/** How many containers the player holds. Every row is worth exactly one. */ +export async function heartsBalance(db) { + try { + const { rows } = await db.execute({ + sql: "SELECT COALESCE(SUM(delta), 0) AS total FROM ramble_wallet WHERE kind = ?", + args: [HEART_KIND], + }); + return Number(rows?.[0]?.total) || 0; + } catch { return 0; } +} + +/** + * The bird's ceiling: the base plus one step per container, capped. + * + * DERIVED, never stored. A stored maximum would be a balance, and §6.1's whole + * point is that a balance loses increments to last-writer-wins. It also means + * retuning `energy.max.per.heart` retunes every existing player's bar, which is + * what makes these numbers settings rather than a redesign. + */ +export async function maxEnergy(db) { + try { + const { energyBase, perHeart, cap } = await readHeartSettings(db); + return Math.min(cap, energyBase + (await heartsBalance(db)) * perHeart); + } catch { return ENERGY_MAX_BASE_DEFAULT; } +} +``` + +- [ ] **Step 4: Run the test and watch it pass** + +```bash +node scripts/run-suite.mjs tests/ramble-hearts-ledger.test.js +node scripts/run-suite.mjs tests/ramble-hearts.test.js +``` + +Expected: both PASS. + +- [ ] **Step 5: Prove nothing else moved** + +```bash +node scripts/run-suite.mjs tests/ramble-wallet.test.js tests/ramble-cells.test.js tests/ramble-cells-sync.test.js tests/ramble-sync.test.js +``` + +Expected: PASS. `ramble_wallet` gained a second `kind` and nothing else; if a seed test fails here, the change leaked. + +- [ ] **Step 6: Commit** + +```bash +git add tests/ramble-hearts-ledger.test.js +git commit bundles/ramble/server/hearts.js tests/ramble-hearts-ledger.test.js -m "ramble: taking a heart, and the one rule the map and the payout share" +git show --stat HEAD +``` + +--- + +## Task 3: the bar actually gets longer — `pet.js` clamps against the derived maximum + +**Files:** +- Modify: `bundles/ramble/server/pet.js` +- Modify: `bundles/ramble/server/feed.js` (its `readPet` helper only) +- Modify: `tests/ramble-pet.test.js` (append cases; change nothing that exists) + +**Interfaces:** +- Consumes: `maxEnergy(db)` and `ENERGY_MAX_BASE_DEFAULT` from Task 1/2's `hearts.js`. +- Produces, for Task 4 and the panel: `feed()`, `doChore().pet` and `petState()` all carry `energy_max`; `petFromRow(row, energyMax)` takes a second argument. + +**Import direction:** `pet.js` imports `hearts.js`. `hearts.js` must never import `pet.js` — that would be a cycle, the same trap `doChore` already documents about `feed.js`. + +**Two rulings this task encodes:** + +- **`moodFor` is UNCHANGED — happy at 60, tired at 30, absolute.** Spec §4.3: "a larger maximum energy means more slack before dropping below happy". A percentage threshold would hand a bigger bar no benefit whatsoever and make hearts purely cosmetic — the exact complaint that started this arc. +- **The number shown and the number clamped are the same number.** `petState().energy_max` is what the panel draws the bar against, and it is the identical `maxEnergy(db)` call `feed()` clamps with. Phase 1's lesson, applied to the second surface. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/ramble-pet.test.js` (keep the file's existing imports; add `maxEnergy`, and the settings helper if the file has none): + +```js +/* --- Phase 2: heart containers raise the ceiling (spec §3, §4.3, D6). --- */ + +import { maxEnergy } from "../bundles/ramble/server/hearts.js"; + +const HEART_CELLS = ["9vk79ed", "9v6m2xt", "dr5regw", "gcpvj0d", "u33dc0e", "wecnrmd"]; + +async function giveHearts(db, n) { + // Ledger rows directly: this file tests the PET, not the pickup path. + for (let i = 0; i < n; i++) { + await db.execute({ + sql: "INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES ('heart', ?, 1, 0)", + args: [HEART_CELLS[i % HEART_CELLS.length] + ":pet" + i], + }); + } +} + +test("with no hearts the ceiling is still 100, exactly as before", async () => { + const db = await freshDb(); + assert.equal(await maxEnergy(db), 100); + for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); + const pet = await petState(db, { now: 1000 }); + assert.equal(pet.energy, 100, "the old ceiling holds for a player with no hearts"); + assert.equal(pet.energy_max, 100); +}); + +test("five hearts raise the ceiling to 150, and feed() fills to it", async () => { + const db = await freshDb(); + await giveHearts(db, 5); + assert.equal(await maxEnergy(db), 150); + for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); + const pet = await petState(db, { now: 1000 }); + assert.equal(pet.energy, 150, "the bird fills the longer bar"); + assert.equal(pet.energy_max, 150, "and the number drawn is the number clamped"); +}); + +test("mood thresholds stay ABSOLUTE, so a longer bar buys real slack", async () => { + // Spec §4.3: hearts buy resilience. At max 150, energy 70 is still happy — + // a percentage threshold would have made it tired and hearts pointless. + const db = await freshDb(); + await giveHearts(db, 5); + for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); + const six = 6 * 60 * 60 * 1000; + // 150 -> 70 is eight decay intervals; a 100-max bird would be at 20 by now. + const pet = await petState(db, { now: 1000 + 8 * six }); + assert.equal(pet.energy, 70); + assert.equal(pet.mood, "happy", "still happy at 70 because 60 is an absolute threshold"); +}); + +test("decay still bottoms out at 0 whatever the ceiling is", async () => { + const db = await freshDb(); + await giveHearts(db, 20); + await feed(db, { type: "meet_crow" }, { now: 1000 }); + const year = 365 * 24 * 60 * 60 * 1000; + const pet = await petState(db, { now: 1000 + year }); + assert.equal(pet.energy, 0); + assert.equal(pet.mood, "alarmed"); +}); + +test("lowering a setting clamps a bird that is already over the new ceiling, on read", async () => { + const db = await freshDb(); + await giveHearts(db, 5); + for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); + assert.equal((await petState(db, { now: 1000 })).energy, 150); + + await db.execute({ + sql: "INSERT INTO ramble_settings (key, value) VALUES ('energy.max.per.heart', '2') ON CONFLICT(key) DO UPDATE SET value = excluded.value", + args: [], + }); + const pet = await petState(db, { now: 1000 }); + assert.equal(pet.energy_max, 110); + assert.equal(pet.energy, 110, "an over-ceiling bird is brought down to the new bar"); + const row = (await db.execute({ sql: "SELECT energy FROM ramble_pet WHERE owner = 'self'", args: [] })).rows[0]; + assert.equal(Number(row.energy), 110, "and it is persisted, so the next read does not redo it"); +}); + +test("every pet shape carries the same energy_max — feed, chore, and the no-op chore", async () => { + const db = await freshDb(); + await giveHearts(db, 3); + const fed = await feed(db, { type: "checkin" }, { now: 1000 }); + assert.equal(fed.energy_max, 130); + const chore = await doChore(db, "feed", { now: 1000 }); + assert.equal(chore.pet.energy_max, 130); + const repeat = await doChore(db, "feed", { now: 1000 }); + assert.equal(repeat.done, false); + assert.equal(repeat.pet.energy_max, 130, "the no-op branch must not report a different bar"); +}); +``` + +**Note for the implementer:** `tests/ramble-pet.test.js` already defines `freshDb()` (line 22) — an in-memory client with `initRambleTables` run over it, which therefore already has `ramble_wallet` and `ramble_settings`. Reuse it; do not add a second helper. The file's existing imports (`feed`, `petState`, `moodFor`, `FEED_DELTAS`, `doChore`) stay as they are. + +- [ ] **Step 2: Run and watch it fail** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +node scripts/run-suite.mjs tests/ramble-pet.test.js +``` + +Expected: FAIL — `energy_max` is `undefined`, and the 150 assertions read 100. + +- [ ] **Step 3: Make `pet.js` read the ceiling** + +Four edits, all inside `bundles/ramble/server/pet.js`: + +1. Import at the top, under the existing `localDay` import: + +```js +import { maxEnergy, ENERGY_MAX_BASE_DEFAULT } from "./hearts.js"; +``` + +2. Replace `clampEnergy`: + +```js +/** + * The ceiling is DERIVED from the heart ledger (spec §3, D6), so it is passed + * in rather than read here — every caller has already fetched it once and a + * second read would risk clamping against a different number than the one the + * panel is about to draw. + */ +function clampEnergy(v, max) { + return Math.max(0, Math.min(Number.isFinite(max) ? max : ENERGY_MAX_BASE_DEFAULT, v)); +} +``` + +3. In `feed()`, read the ceiling once and report it. Replace the `const delta` / `const energy` / `const mood` block with: + +```js + const delta = FEED_DELTAS[type]; + const max = await maxEnergy(db); + const energy = clampEnergy(row.energy + delta, max); + const mood = moodFor(energy); +``` + +and add `energy_max: max` to the object `feed()` returns: + +```js + return { owner: "self", mood, energy, energy_max: max, places_week, unlocks_week, crows_week, week_start, last_fed_at }; +``` + +4. In `petState()`, clamp against the ceiling — including when the ceiling has *moved down* under a bird that is already over it, which a settings change can do: + +```js +export async function petState(db, { now = Date.now() } = {}) { + const row = await ensureRow(db); + const max = await maxEnergy(db); + + let energy = row.energy; + let mood = row.mood; + let last_fed_at = row.last_fed_at; + let dirty = false; + + if (last_fed_at != null) { + const elapsed = now - last_fed_at; + if (elapsed >= DECAY_INTERVAL_MS) { + const intervals = Math.floor(elapsed / DECAY_INTERVAL_MS); + energy = energy - intervals * DECAY_PER_INTERVAL; + last_fed_at = now; + dirty = true; + } + } + // Also catches a bird sitting ABOVE a ceiling that just moved down, which + // `energy.max.per.heart` or `energy.max.cap` can do at any time. + const clamped = clampEnergy(energy, max); + if (clamped !== row.energy) dirty = true; + energy = clamped; + mood = moodFor(energy); + + if (dirty) { + await db.execute({ + sql: "UPDATE ramble_pet SET energy = ?, mood = ?, last_fed_at = ? WHERE owner = 'self'", + args: [energy, mood, last_fed_at], + }); + } + + return { + mood, + energy, + energy_max: max, + places_week: row.places_week, + unlocks_week: row.unlocks_week, + crows_week: row.crows_week, + last_fed_at, + active_egg_id: row.active_egg_id ?? null, + chores: readChores(row, now), + }; +} +``` + +5. `petFromRow` takes the ceiling as a second argument: + +```js +export function petFromRow(row, energyMax) { + if (!row) return null; + return { + owner: "self", + mood: row.mood, + energy: row.energy, + energy_max: Number.isFinite(energyMax) ? energyMax : ENERGY_MAX_BASE_DEFAULT, + places_week: row.places_week, + unlocks_week: row.unlocks_week, + crows_week: row.crows_week, + week_start: row.week_start, + last_fed_at: row.last_fed_at, + }; +} +``` + +6. `doChore`'s no-op branch passes it: + +```js + if (chores[kind] === true) { + return { done: false, chores, pet: petFromRow(row, await maxEnergy(db)) }; + } +``` + +- [ ] **Step 4: Fix the second `petFromRow` caller in `feed.js`** + +`readPet` is the other caller and must report the same ceiling, or a not-credited response would claim a different bar than a credited one: + +```js +import { creditWarmth } from "./eggs.js"; +import { feed as petFeed, petFromRow } from "./pet.js"; +import { maxEnergy } from "./hearts.js"; + +// ... + +async function readPet(db) { + const { rows } = await db.execute({ sql: "SELECT * FROM ramble_pet WHERE owner = 'self'", args: [] }); + return petFromRow(rows[0] ?? null, await maxEnergy(db)); +} +``` + +- [ ] **Step 5: Run the pet tests, then everything that reads a pet** + +```bash +node scripts/run-suite.mjs tests/ramble-pet.test.js +node scripts/run-suite.mjs tests/ramble-feed.test.js tests/ramble-eggs.test.js tests/ramble-flock.test.js tests/ramble-hearts-ledger.test.js +``` + +Expected: all PASS. If a `ramble-feed` test compares a whole pet object with `deepEqual`, it now needs `energy_max` — add the field to the expectation rather than dropping it from the shape. + +- [ ] **Step 6: Commit** + +```bash +git commit bundles/ramble/server/pet.js bundles/ramble/server/feed.js tests/ramble-pet.test.js -m "ramble: the energy bar ends where the heart containers say it does" +git show --stat HEAD +``` + +--- + +## Task 4: the routes — granting on a walk, drawing on the map, reporting on the pet + +**Files:** +- Modify: `bundles/ramble/panel/routes.js` +- Modify: `tests/ramble-panel.test.js` (append cases) + +**Interfaces:** +- Consumes: `recordHeartPickup`, `availableHearts`, `heartsBalance`, `maxEnergy` from `hearts.js`. +- Produces, for Tasks 6-7: + - `POST /api/ramble/area` with a fix gains `heart_picked: 1` (only when one was taken) and always-on `hearts` and `energy_max` (only when the post carried `here`). + - `GET /api/ramble/zones?pips=1` gains `hearts: [{ cell, key, source, lat, lon }]`. + - `GET /api/ramble/pet` gains `hearts`; `energy_max` already rides in from `petState`. + +**Three things to get exactly right:** + +1. **A heart is attempted on EVERY fix, not only on a first unlock.** This is what K2 and K3 buy: a first unlock grants on the spot (the cell was fogged, so it is a surprise), and a cell unlocked months ago whose heart was never taken pays when the player walks back. Seed's `if (!out.unlocked)` guard is a seed rule — do not copy it. +2. **Order matters.** `recordUnlock` runs first. `recordHeartPickup` fails closed on an un-unlocked cell, so an inaccurate fix that was refused an unlock is also refused a heart, automatically and without the route knowing the rule. +3. **An area post with no `here` must keep its response byte for byte.** An existing test deep-equals it. Every new field rides inside the `here` branch. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/ramble-panel.test.js`. The file's idiom, which these tests use verbatim: `req(path, { method, body })` returns a `Response` (it adds `x-test-auth` and JSON headers itself), and `createDbClient()` opens the same scratch database the router is using — the `warmth.hatch_at` test at line 531 is the model for writing a settings row and cleaning it up. There is `walkTo(lat, lon)` too, but do not use it here: it zeroes `warmth.visit_place` and unlocks permanently for the rest of the file. + +```js +/* --- Phase 2: heart containers (spec §2.3, §3). --- */ + +/** + * ⚠ THIS FILE SHARES ONE SCRATCH DATABASE ACROSS EVERY TEST, so heart counts + * accumulate as tests run and an unlock is permanent for every test after it. + * Assert DELTAS, never absolute totals — an absolute assertion here passes + * alone and fails in the suite, which is exactly the flake shape this repo has + * hunted before. + * + * rate 1 so every cell in these tests holds a heart: no test may depend on a + * cell that happens to hash lucky (the phase 1 lesson). + */ +async function withHeartSettings(pairs, fn) { + const db = createDbClient(); + try { + for (const [k, v] of pairs) { + // eslint-disable-next-line no-await-in-loop + await db.execute({ + sql: `INSERT INTO ramble_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + args: [k, v], + }); + } + return await fn(); + } finally { + for (const [k] of pairs) { + // eslint-disable-next-line no-await-in-loop + await db.execute({ sql: "DELETE FROM ramble_settings WHERE key = ?", args: [k] }); + } + db.close(); + } +} + +const HEARTS_ON = [["heart.rate", "1"], ["heart.wild.rate", "999999"], ["unlock.max.accuracy.m", "100"]]; +const jsonOf = async (path, opts) => (await req(path, opts)).json(); + +test("POST /api/ramble/area grants a heart on a first unlock, and reports the new ceiling", async () => { + await withHeartSettings(HEARTS_ON, async () => { + const here = { lat: 30.2672, lon: -97.7431, accuracy_m: 20 }; + const before = await jsonOf("/api/ramble/pet"); + + const first = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"], here } }); + assert.equal(first.heart_picked, 1, "the fogged cell had a heart in it"); + assert.equal(first.hearts, before.hearts + 1); + assert.equal(first.energy_max, before.energy_max + 10, "the bar grew by energy.max.per.heart"); + + const again = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"], here } }); + assert.equal(again.heart_picked, undefined, "a permanent heart is taken once, ever"); + assert.equal(again.hearts, first.hearts, "the count still rides on every fix"); + assert.equal(again.energy_max, first.energy_max); + }); +}); + +test("a fix too vague to unlock is also too vague to pay a heart", async () => { + await withHeartSettings(HEARTS_ON, async () => { + const before = (await jsonOf("/api/ramble/pet")).hearts; + const res = await jsonOf("/api/ramble/area", { + method: "POST", + // `cells` is only the active-area list; the cell that matters is derived + // from `here` (Chicago -> dp3wjzt), which is fresh ground for this file. + body: { cells: ["dp3wjzt"], here: { lat: 41.8781, lon: -87.6298, accuracy_m: 2000 } }, + }); + assert.equal(res.unlocked, undefined, "no unlock"); + assert.equal(res.heart_picked, undefined, "and therefore no heart"); + assert.equal((await jsonOf("/api/ramble/pet")).hearts, before, "nothing was granted"); + }); +}); + +test("POST /api/ramble/area WITHOUT `here` keeps its exact historical shape", async () => { + const res = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"] } }); + assert.deepEqual(res, { cells: ["9v6m2xt"] }, + "no fix, no currency: panning the map must not report a wallet"); +}); + +test("GET /api/ramble/zones?pips=1 draws hearts only in unlocked ground", async () => { + await withHeartSettings(HEARTS_ON, async () => { + const here = { lat: 30.2672, lon: -97.7431, accuracy_m: 20 }; + await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"], here } }); + + const bbox = "30.25,-97.76,30.29,-97.72"; + const withPips = await jsonOf("/api/ramble/zones?bbox=" + bbox + "&pips=1"); + assert.ok(Array.isArray(withPips.hearts), "the field is always an array"); + for (const h of withPips.hearts) { + assert.ok(withPips.unlocked.some((b) => + h.lat >= b.south && h.lat <= b.north && h.lon >= b.west && h.lon <= b.east), + "a heart pip only ever sits in unlocked ground"); + } + + const noPips = await jsonOf("/api/ramble/zones?bbox=" + bbox); + assert.deepEqual(noPips.hearts, [], "pips are a close-zoom detail; the client asks for them"); + }); +}); + +test("a heart in ground unlocked before this feature existed waits on the map", async () => { + // The K2 case, end to end: a row put straight into ramble_cells (exactly what + // phase 1's backfill left behind) still has its heart to walk back to. + await withHeartSettings(HEARTS_ON, async () => { + const db = createDbClient(); + try { + await db.execute({ + sql: "INSERT INTO ramble_cells (cell, first_unlocked_at) VALUES ('u33dc0e', 1) ON CONFLICT(cell) DO NOTHING", + args: [], + }); + } finally { db.close(); } + // u33dc0e decodes to 52.5181, 13.4081 (Berlin); this bbox contains it. + const zones = await jsonOf("/api/ramble/zones?bbox=52.50,13.35,52.54,13.46&pips=1"); + assert.equal(zones.hearts.filter((h) => h.cell === "u33dc0e").length, 1, + "a cell unlocked before this feature shipped still has its heart waiting"); + }); +}); + +test("GET /api/ramble/pet carries the heart count and the ceiling", async () => { + const body = await jsonOf("/api/ramble/pet"); + assert.equal(typeof body.hearts, "number"); + assert.equal(typeof body.energy_max, "number"); + assert.equal(body.energy_max, 100 + body.hearts * 10, + "the ceiling is derived from the count the same response reports"); +}); +``` + +**Note for the implementer:** `withHeartSettings` and `jsonOf` are the only new helpers; everything else (`req`, `createDbClient`) already exists in the file. **Do not add a settings HTTP route** — none exists, and this phase must not add one. The cells above were checked against `decodeGeohash` while this plan was written: `9v6m2xt` = 30.4960/-98.0564, `dp3wjzt` = Chicago, `u33dc0e` = 52.5181/13.4081. Note that a POST to `/api/ramble/area` derives the cell it unlocks from `here`, not from the `cells` array — Austin's 30.2672/-97.7431 is `9v6kpvc`. + +- [ ] **Step 2: Run and watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: FAIL — `heart_picked` and `hearts` are `undefined`, `zones.hearts` is `undefined`. + +- [ ] **Step 3: Load `hearts.js` into `mods`** + +In `bundles/ramble/panel/routes.js`, `ensureLoaded` builds every module in one `Promise.all`. Add `heartsMod` in **all four** places — the destructuring, the import list, the null-check, and the `mods` object — following `walletMod` exactly: + +```js + const [dbMod, initMod, marksMod, gridMod, personaMod, anchorsMod, appRootMod, petMod, eggsMod, feedMod, flockMod, nestsMod, deliveryMod, tradesMod, aroundMod, zonesMod, cellsMod, walletMod, heartsMod] = await Promise.all([ + // ... existing entries, unchanged ... + bundleImport("server/wallet.js"), + bundleImport("server/hearts.js"), + ]).catch((err) => { +``` + +```js + if (!dbMod || !initMod || !marksMod || !gridMod || !personaMod || !anchorsMod || !appRootMod || !petMod || + !eggsMod || !feedMod || !flockMod || !nestsMod || !deliveryMod || !tradesMod || !aroundMod || !zonesMod || + !cellsMod || !walletMod || !heartsMod) { +``` + +```js + mods = { dbMod, initMod, marksMod, gridMod, personaMod, anchorsMod, petMod, eggsMod, feedMod, flockMod, nestsMod, deliveryMod, tradesMod, aroundMod, zonesMod, cellsMod, walletMod, heartsMod, appImport: appRootMod.appImport }; +``` + +- [ ] **Step 4: Grant on a fix in `POST /api/ramble/area`** + +Inside the existing `if (here) { ... }` block, after the seed branch: + +```js + let unlockedNow = null; + let seedPicked = 0; + let heartPicked = 0; + if (here) { + const cell = mods.anchorsMod.encodeGeohash(here.lat, here.lon, 7); + await feedActivity({ type: "visit_place", cell }); + const out = await mods.cellsMod.recordUnlock(db, cell, { now: Date.now(), emit, accuracyM: here.accuracy_m }); + if (out.unlocked) unlockedNow = mods.zonesMod.cellBox(out.cell); + if (!out.unlocked && out.cell) { + seedPicked = (await mods.walletMod.recordSeedPickup(db, cell, { now: Date.now(), emit })).amount; + } + // 2026-09-08 §2.3: hearts are tried on EVERY fix, not only a first + // unlock. A first unlock grants on the spot (the cell was fogged, so it + // is a surprise); a cell unlocked long ago whose heart was never taken + // pays when the player walks back to it, which is what makes the pips on + // their existing map real destinations. recordHeartPickup fails closed on + // a cell that is not in ramble_cells, so a fix too vague to unlock is + // refused a heart on exactly the same grounds, without this route + // restating the rule. + heartPicked = (await mods.heartsMod.recordHeartPickup(db, cell, { now: Date.now(), emit })).amount; + } +``` + +and the response, keeping every new field inside the `here` branch: + +```js + res.json({ + cells, + ...(unlockedNow ? { unlocked: unlockedNow } : {}), + ...(seedPicked ? { seed_picked: seedPicked } : {}), + ...(heartPicked ? { heart_picked: heartPicked } : {}), + ...(here ? { seed: await mods.walletMod.seedBalance(db) } : {}), + ...(here ? { + hearts: await mods.heartsMod.heartsBalance(db), + energy_max: await mods.heartsMod.maxEnergy(db), + } : {}), + }); +``` + +- [ ] **Step 5: Draw the pips in `GET /api/ramble/zones`** + +In the `pips === "1"` branch, beside the existing seed read: + +```js + let seed = []; + let hearts = []; + if (req.query?.pips === "1") { + seed = await mods.walletMod.harvestableCells(db, out.unlocked.map((b) => b.cell), { now: Date.now() }); + // The SAME rule the payout uses (hearts.js: availableHearts and + // recordHeartPickup both go through heartCandidate). A heart drawn here + // that a walk would not grant is the phase 1 seed defect all over again. + hearts = await mods.heartsMod.availableHearts(db, out.unlocked.map((b) => b.cell), { now: Date.now() }); + } +``` + +and add `hearts` to the response object beside `seed`. + +- [ ] **Step 6: Report on `GET /api/ramble/pet`** + +```js + res.json({ + ...pet, + bird, + egg: { percent: egg.egg.percent }, + seed: await mods.walletMod.seedBalance(db), + hearts: await mods.heartsMod.heartsBalance(db), + }); +``` + +`energy_max` needs nothing here — `petState` already returns it and the spread carries it. + +- [ ] **Step 7: Run the route tests, then the map slice** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +node scripts/run-suite.mjs tests/ramble-map-gating.test.js tests/ramble-around.test.js tests/ramble-tools.test.js +``` + +Expected: all PASS. + +- [ ] **Step 8: Commit** + +```bash +git commit bundles/ramble/panel/routes.js tests/ramble-panel.test.js -m "ramble: a walk takes the heart, and the map says where the next one is" +git show --stat HEAD +``` + +--- + +## Task 5: the heart, drawn + +**Files:** +- Modify: `bundles/ramble/server/bird-svg.cjs` +- Modify: `tests/ramble-bird-svg.test.js` (append cases) + +**Interfaces:** +- Produces: `drawHeart() -> string` (SVG children for a `0 0 24 24` viewBox) and `mountHeart(el)`, both on the engine's exported object, mirroring `drawSeed`/`mountSeed` exactly. + +**Why the engine and not the panel:** `bundles/ramble/panel/static/ramble.js` is capped at exactly two markup sinks. `mountSeed` is how the seed pip gets real art without spending one, because the panel hands Leaflet an `Element` (Leaflet appends it, which is not a sink). The heart takes the same route. The engine file is a `.cjs` UMD module loaded through `createRequire`, and it is also what the server-side bird renderer uses, so it must stay dependency-free. + +**Art direction (the writing and the drawing are deliverables, not decoration):** the seed is "an almond husk with a seam and a highlight, warm against a blue-grey map". The heart is its rare counterpart — a small round-shouldered heart in the same warm family but clearly richer than the seed's ochre, with a highlight in the same place so the two read as one set. No gradients, no filters: it renders at 18px on a map and inside a text line on the pet page. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/ramble-bird-svg.test.js`: + +```js +test("drawHeart returns inert SVG children in the seed's own idiom", () => { + const svg = engine.drawHeart(); + assert.equal(typeof svg, "string"); + assert.ok(svg.length > 0); + assert.ok(/ { + const calls = []; + const el = { + setAttribute: (k, v) => calls.push([k, v]), + set innerHTML(v) { calls.push(["innerHTML", v]); }, + }; + engine.mountHeart(el); + assert.deepEqual(calls[0], ["viewBox", "0 0 24 24"], "same box as mountSeed, so the pips match in size"); + assert.equal(calls[1][0], "innerHTML"); + assert.equal(calls[1][1], engine.drawHeart()); +}); +``` + +**Note for the implementer:** the file already imports the engine under some name (`engine`, `Bird`, or similar) and already tests `drawSeed`/`mountSeed` this way. Match whatever is there rather than introducing a second style. + +- [ ] **Step 2: Run and watch it fail** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +node scripts/run-suite.mjs tests/ramble-bird-svg.test.js +``` + +Expected: FAIL — `engine.drawHeart is not a function`. + +- [ ] **Step 3: Draw it** + +In `bundles/ramble/server/bird-svg.cjs`, directly after `mountSeed`: + +```js + /* The rare counterpart to the seed. Same warm family and the same highlight + * placement so the two read as one set, but deeper and richer, because this + * is the thing you go out of your way for. Flat fills only: it renders at + * 18px on a map and inline in a sentence on the pet page. */ + function drawHeart() { + return '' + + ''; + } + function mountHeart(el) { el.setAttribute("viewBox", "0 0 24 24"); el.innerHTML = drawHeart(); } +``` + +and add both to the returned object, beside `drawSeed` and `mountSeed`: + +```js + return { ROSTER: ROSTER, SPECIES: SPECIES, PARTS: PARTS, rollGenome: rollGenome, drawBird: drawBird, drawEgg: drawEgg, drawWalkingEgg: drawWalkingEgg, drawSeed: drawSeed, drawHeart: drawHeart, mountBird: mountBird, mountWalkingEgg: mountWalkingEgg, mountSeed: mountSeed, mountHeart: mountHeart, isValidBird: isValidBird }; +``` + +- [ ] **Step 4: Run the test and watch it pass** + +```bash +node scripts/run-suite.mjs tests/ramble-bird-svg.test.js +node scripts/run-suite.mjs tests/ramble-header-bird.test.js tests/ramble-panel.test.js +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git commit bundles/ramble/server/bird-svg.cjs tests/ramble-bird-svg.test.js -m "ramble: a heart container, drawn in the seed's own hand" +git show --stat HEAD +``` + +--- + +## Task 6: the map — pips to walk to, a counter, and the moment + +**Files:** +- Modify: `bundles/ramble/panel/static/ramble.js` +- Modify: `bundles/ramble/panel/static/ramble.css` +- Modify: `bundles/ramble/panel/ramble.js` (the map bar only) +- Modify: `tests/ramble-panel.test.js` (append served-source assertions) + +**Interfaces:** +- Consumes: `hearts` from `GET /api/ramble/zones?pips=1`; `heart_picked` and `hearts` from `POST /api/ramble/area`; `Bird.mountHeart` from Task 5. +- Produces, for Task 7: `paintHearts(n)` updating `#rb-heart-count`. + +**⚠ `bundles/ramble/panel/static/ramble.js` rules, test-enforced:** ZERO backticks anywhere in the file **including inside comments** — a single one truncates the served script and the whole panel goes dark. Exactly TWO engine markup sinks (they are already spent on `drawEggSeed` and `nestEggHtml`); this task adds none, because Leaflet appending an `Element` is not a sink. `textContent` only. No emoji. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/ramble-panel.test.js`, in the block that already asserts on the served static source: + +```js +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"); + assert.ok(body.includes("function heartIcon()"), "pips carry the engine's heart art"); + assert.ok(body.includes("Bird.mountHeart(svg)"), "drawn by the shared engine, like every other creature part"); + assert.ok(body.includes("rb-heart-dot"), "and a plain dot survives the engine failing to load"); + assert.ok(body.includes("paintHeartPips(out.hearts || [])"), "fed from the server's own list"); + assert.ok(body.includes("out.heart_picked"), "the pickup is consumed from the area response"); + assert.ok(body.includes("function paintHearts("), "the counter is painted from the area response"); + assert.equal(body.split("`").length - 1, 0, "the panel client must contain ZERO backticks"); +}); + +test("heart pips, the fallback dot and the pop all have styles", async () => { + const css = await (await req("/ramble/static/ramble.css")).text(); + assert.ok(css.includes("#ramble .rb-heart-pip {")); + assert.ok(css.includes("#ramble .rb-heart-dot {")); + assert.ok(css.includes("#ramble .rb-hearts {"), "the map-bar counter has a rule"); + assert.ok(css.includes("#ramble .rb-heart-pop {")); + assert.ok(css.includes("@keyframes rb-heart-rise")); + assert.match(css, /prefers-reduced-motion[\s\S]*\.rb-heart-pop \{ animation: none/, + "the pop respects reduced motion, like the seed pop already does"); +}); +``` + +And **inside the existing** `"panel handler renders the world-first shell, its three views and every asset"` test, beside the line that already asserts `id="rb-seed-count"`: + +```js + assert.ok(sent.includes('id="rb-heart-count"'), "the map bar carries the heart counter"); + assert.ok(sent.indexOf('id="rb-heart-count"') > sent.indexOf('id="rb-seed-count"'), + "common currency first, rare currency second"); +``` + +**Note for the implementer:** `req(path)` is the file's own helper and returns a `Response`; the existing `ramble.css` and `ramble-ar.js` tests show the `.text()` idiom exactly. The shell is only rendered inside that one existing test — extend it rather than re-rendering the panel a second time. + +- [ ] **Step 2: Run and watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: FAIL on `paintHeartPips`. + +- [ ] **Step 3: The map bar chip** + +In `bundles/ramble/panel/ramble.js` (this file IS a template literal, so `${}` is expected here), beside the seed counter: + +```js + 0seed + 0hearts +``` + +- [ ] **Step 4: The pip layer** + +In `bundles/ramble/panel/static/ramble.js`, directly after `paintSeedPips`: + +```js + /* A heart container waiting in ground you have already unlocked: the rare + * counterpart to a seed pip, and the reason an existing player has somewhere + * to walk on the day this ships. Not interactive -- you collect it by walking + * there, exactly like seed. + * + * Each pip needs its OWN element: appending an Element MOVES it, so one + * shared node would leave a single heart hopping between cells. */ + function heartIcon() { + if (!Bird || typeof Bird.mountHeart !== "function") return null; + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + try { Bird.mountHeart(svg); } catch (e) { return null; } + var opts = { className: "rb-heart-pip", iconSize: [22, 22], iconAnchor: [11, 11] }; + opts.html = svg; /* an Element: Leaflet appends, so this is no markup sink */ + return L.divIcon(opts); + } + + function paintHeartPips(spots) { + for (var i = 0; i < spots.length; i++) { + var c = spots[i]; + if (!c || !isFinite(c.lat) || !isFinite(c.lon)) continue; + var ll = [c.lat, c.lon]; + var icon = heartIcon(); + if (icon) { + L.marker(ll, { pane: "rb-fog", icon: icon, interactive: false, keyboard: false }).addTo(zoneLayer); + } else { + L.circleMarker(ll, { + pane: "rb-fog", className: "rb-heart-dot", radius: 5, weight: 0, + fillOpacity: 0.95, interactive: false + }).addTo(zoneLayer); + } + } + } +``` + +and in `drawZones`, beside the seed call: + +```js + if (map.getZoom() >= MIN_CELL_DETAIL_ZOOM) { + paintCells(out.frontier || [], "rb-frontier-cell"); + paintSeedPips(out.seed || []); + paintHeartPips(out.hearts || []); + } +``` + +- [ ] **Step 5: The counter and the moment** + +Directly after `paintSeed`: + +```js + /* The heart moment. A heart is rare enough to be worth saying out loud, so + * this does both: the number pops, and the bird speaks. sayMoment is the only + * thing that opens the bubble on its own, and an arrival is exactly what it + * is for. */ + function celebrateHeart() { + var chip = $("rb-heart-count"); + if (chip && chip.parentNode) { + var pop = document.createElement("span"); + pop.className = "rb-heart-pop"; + pop.textContent = "+1"; + chip.parentNode.appendChild(pop); + setTimeout(function () { if (pop.parentNode) pop.parentNode.removeChild(pop); }, 1400); + } + sayMoment("A heart container. Your bird can hold more now."); + } + + function paintHearts(n) { + if (typeof n !== "number") return; + var el = $("rb-heart-count"); + if (el) el.textContent = String(n); + } +``` + +and in the area-response handler, immediately after the two existing seed lines (`if (out && typeof out.seed === "number") paintSeed(out.seed);` and the `out.seed_picked` line): + +```js + if (out && typeof out.hearts === "number") paintHearts(out.hearts); + if (out && out.heart_picked) { celebrateHeart(); if (!out.unlocked) refreshZones(); } +``` + +**Why exactly that form.** Taking a heart removes a pip and the pip list comes from `/zones`, so the map does need a refresh — but marks are untouched by a heart, so do **not** call `refreshMarks` (phase 1 shipped a redundant double `refreshMarks` on the unlock path that three adversarial rounds missed; do not add a third). And when the pickup rode in on a *first unlock*, `celebrateUnlock` has already called `refreshZones`, so the `!out.unlocked` guard is what keeps it from firing twice. This mirrors the seed line directly above it, deliberately. + +- [ ] **Step 6: The styles** + +In `bundles/ramble/panel/static/ramble.css`, beside the seed rules: + +```css +#ramble .rb-heart-pip { pointer-events: none; } +#ramble .rb-heart-dot { fill: #d8556a; } + +#ramble .rb-hearts { + display: inline-flex; align-items: baseline; gap: 0.3rem; + margin-left: 0.5rem; position: relative; + font-size: 0.82rem; color: var(--rb-muted); +} +#ramble .rb-hearts strong { font-size: 0.95rem; color: var(--rb-ink); } + +#ramble .rb-heart-pop { + position: absolute; left: 50%; bottom: 100%; + transform: translateX(-50%); + font-weight: 700; color: #d8556a; pointer-events: none; + animation: rb-heart-rise 1.4s ease-out forwards; +} +@keyframes rb-heart-rise { + from { opacity: 1; transform: translate(-50%, 0); } + to { opacity: 0; transform: translate(-50%, -1.6rem); } +} +@media (prefers-reduced-motion: reduce) { + #ramble .rb-heart-pop { animation: none; opacity: 1; } +} +``` + +**Note for the implementer:** match the existing seed rules' custom-property names (`--rb-muted`, `--rb-ink` or whatever this stylesheet actually uses) and fold the reduced-motion rule into the file's existing `prefers-reduced-motion` block rather than opening a second one, if there already is one. + +- [ ] **Step 7: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +node scripts/run-suite.mjs tests/ramble-ar.test.js tests/ramble-stream.test.js +``` + +Expected: all PASS. If the backtick assertion fails, a comment picked one up — that is the whole reason the assertion exists. + +- [ ] **Step 8: Commit** + +```bash +git commit bundles/ramble/panel/static/ramble.js bundles/ramble/panel/static/ramble.css bundles/ramble/panel/ramble.js tests/ramble-panel.test.js -m "ramble: hearts on the map, and the bird says so when you find one" +git show --stat HEAD +``` + +--- + +## Task 7: the pet page — a bar that grows, and the hearts that grew it + +**Files:** +- Modify: `bundles/ramble/panel/ramble.js` (the pet card) +- Modify: `bundles/ramble/panel/static/ramble.js` (the pet painter) +- Modify: `bundles/ramble/panel/static/ramble.css` +- Modify: `tests/ramble-panel.test.js` (append served-source assertions) + +**Interfaces:** +- Consumes: `energy_max` and `hearts` from `GET /api/ramble/pet`. + +**The bug this task exists to not ship:** the bar is currently `width = energy + "%"`, which silently assumes a maximum of 100. With hearts, an energy of 150 would paint a 150% bar and an energy of 70 out of 150 would look nearly full. **The bar must be drawn against the same `energy_max` the server clamps with** — this is phase 1's lesson on its third surface. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/ramble-panel.test.js`: + +```js +test("the energy bar is drawn against the server's ceiling, not a hardcoded 100", async () => { + const body = await (await req("/ramble/static/ramble.js")).text(); + assert.ok(body.includes("pet.energy_max"), "the painter reads the ceiling the server clamped with"); + assert.ok(!body.includes('Math.min(100, energy)) + "%"'), "the old hardcoded-100 bar is gone"); + assert.ok(body.includes("(energy / max) * 100"), "the bar is a fraction of the real ceiling"); + assert.ok(body.includes("function paintHeartRow("), "the pet page shows the containers themselves"); +}); +``` + +And, again **inside the existing shell test**, beside the pet-card assertions: + +```js + assert.ok(sent.includes('id="rb-energy-max"'), "the bar's ceiling is on the page"); + assert.ok(sent.includes('id="rb-heart-row"'), "and the containers that set it"); +``` + +- [ ] **Step 2: Run and watch it fail** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: FAIL on `pet.energy_max`. + +- [ ] **Step 3: The markup** + +In `bundles/ramble/panel/ramble.js`, replace the energy card's meter block: + +```js +
+
+ Energy + + / 100 +
+

+

Checking on it…

+
+``` + +- [ ] **Step 4: The painter** + +In `bundles/ramble/panel/static/ramble.js`, replace the three energy lines in the pet painter: + +```js + var energy = typeof pet.energy === "number" ? pet.energy : 0; + /* Against the server's OWN ceiling. Drawing a percentage of a hardcoded 100 + * would paint a 150-energy bird at 150% and a 70-of-150 bird as nearly + * full: the bar has to read the same number the server clamps with. */ + var max = typeof pet.energy_max === "number" && pet.energy_max > 0 ? pet.energy_max : 100; + var fill = $("rb-energy-fill"); + if (fill) fill.style.width = Math.max(0, Math.min(100, (energy / max) * 100)) + "%"; + setText($("rb-energy-num"), String(energy)); + setText($("rb-energy-max"), String(max)); + paintHeartRow(typeof pet.hearts === "number" ? pet.hearts : 0); + setText($("rb-mood-line"), MOOD_LINE[pet.mood] || MOOD_LINE.happy); +``` + +and add the heart row, near `paintHearts`: + +```js + /* The containers themselves, above the bar they lengthened -- the number + * alone never explained where the extra bar came from. Capped at a row that + * still fits a phone; past that the sentence carries the count. */ + var HEART_ROW_MAX = 10; + + function paintHeartRow(n) { + var row = $("rb-heart-row"); + if (!row) return; + while (row.firstChild) row.removeChild(row.firstChild); + var shown = Math.max(0, Math.min(HEART_ROW_MAX, n)); + for (var i = 0; i < shown; i++) { + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "rb-heart-one"); + if (Bird && typeof Bird.mountHeart === "function") { + try { Bird.mountHeart(svg); } catch (e) { /* cosmetic */ } + } + row.appendChild(svg); + } + var line = $("rb-heart-line"); + if (!line) return; + if (n <= 0) setText(line, "No heart containers yet. Walk somewhere new."); + else if (n === 1) setText(line, "One heart container."); + else setText(line, n + " heart containers."); + } +``` + +**⚠ `Bird.mountHeart(svg)` writes `innerHTML` on an SVG element the engine owns — this is the SAME mechanism `mountSeed` and `mountBird` already use everywhere in this file and it does not count against the two markup sinks** (the two are `drawEggSeed` and `nestEggHtml`, which assign engine output to `innerHTML` *in this file*). If the sink-count test fails, you wrote the markup here instead of calling the engine — call the engine. + +- [ ] **Step 5: The styles** + +```css +#ramble .rb-meter-of { color: var(--rb-muted); font-size: 0.85rem; } +#ramble .rb-hearts-line { display: flex; align-items: center; gap: 0.45rem; margin: 0.35rem 0 0; flex-wrap: wrap; } +#ramble .rb-heart-row { display: inline-flex; gap: 0.15rem; } +#ramble .rb-heart-one { width: 16px; height: 16px; display: block; } +``` + +- [ ] **Step 6: Run the tests** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +``` + +Expected: PASS, including the zero-backtick and two-sink 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 pet page shows the whole bar, and what lengthened it" +git show --stat HEAD +``` + +--- + +## Task 8: the docs, the version, and the registry + +**Files:** +- Modify: `docs/guide/ramble.md`, `docs/es/guide/ramble.md` +- Modify: `bundles/ramble/manifest.json`, `bundles/ramble/package.json` +- Modify: `registry/add-ons.json` (generated — do not hand-edit) + +**Why the bump is not optional:** the Extensions page installs a bundle by copying it to `~/.crow/bundles/ramble/`, and `repairInstalledBundleAssets` refreshes that copy **only** when the repo manifest version differs from the installed one. Ship `server/hearts.js` without a bump and grackle keeps running 0.9.5 forever while the branch looks deployed. This exact failure already happened once, in Ramble phase 1 of the flock arc. + +- [ ] **Step 1: The English guide** + +`docs/guide/ramble.md` already carries the map paragraph at line 116 (the `## Nests and the egg shelf` section). Extend it with hearts, in the same plain register: + +Add after the bird-seed sentences in that paragraph: + +```markdown +Now and then a new place also holds a **heart container**, which permanently lengthens your bird's energy bar — about one place in three the first time you enter it, and much more rarely in ground you have already cleared. Hearts are the only thing that raises the maximum; they buy your bird a longer stretch between walks before it droops, and nothing else. A heart you have not collected shows on the map wherever it is waiting, so places you cleared before hearts existed are worth walking again. You collect one by walking to it, the same way you collect seed. +``` + +Add to the settings table under `### Nests and shelf`, directly after `seed.per.pickup`: + +```markdown +| `heart.rate` | 3 | About one place in this many holds a heart container the first time you enter it (integer ≥ 1). | +| `heart.wild.days` | 30 | How long before a heart may reappear in ground you have already cleared. | +| `heart.wild.rate` | 40 | About one cleared place in this many holds that reappearing heart (integer ≥ 1). | +| `energy.max.base` | 100 | The energy bar's length with no heart containers. | +| `energy.max.per.heart` | 10 | How much each heart container lengthens it. | +| `energy.max.cap` | 300 | The longest the bar can ever get, however many hearts you find. | +``` + +And extend the replication note near line 237: + +```markdown +The map of places you have unlocked, and your seed and heart balances, replicate to your own linked Crows, and they never go to a contact. +``` + +- [ ] **Step 2: The Spanish guide** + +Make the matching edits in `docs/es/guide/ramble.md` — the same paragraph (under `## Nidos y el estante de huevos`), the same table rows under `### Nidos y estante`, and the same replication sentence. Translate the prose properly; do not leave English in the Spanish file. The settings **keys** stay in English (they are literal setting names); only the Effect column is translated. There is a global i18n parity gate in this repo — an English doc change with no Spanish counterpart fails CI. + +- [ ] **Step 3: The version bump** + +```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH +sed -i 's/"version": "0.9.5"/"version": "0.10.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 +``` + +Expected: both files read `0.10.0`, and `registry/add-ons.json` picks the new version up. + +- [ ] **Step 4: Verify the docs and the registry gates** + +```bash +node scripts/run-suite.mjs tests/ramble-panel.test.js +node scripts/check-port-allocation.js +node scripts/build-registry.js --check +``` + +Expected: all pass. This phase adds no port, so `check-ports` is a formality — run it anyway, since CI does. + +- [ ] **Step 5: 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.10.0: heart containers" +git show --stat HEAD +``` + +--- + +## 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-hearts +npm test 2>&1 | tail -25 +``` + +Expected: **4301 + the new tests**, 0 fail. A drop below 4301 means something was deleted, not fixed. + +- [ ] **Confirm no schema change slipped in** + +```bash +git diff origin/main --stat -- scripts/init-db.js bundles/ramble/server/init-tables.js servers/sharing/instance-sync.js servers/shared/sync-stamp.js servers/sharing/profile-avatar.js +``` + +Expected: **empty**. Hearts ride the existing `ramble_wallet`. If any of these files changed, the design drifted and the phase needs re-reading before it goes near a database. If the diff really is empty, `scripts/schema-migration-dryrun.sh` has nothing to say — note that in the PR rather than skipping it silently. + +- [ ] **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 +``` + +Expected: zero backticks; the `innerHTML` count unchanged from `origin/main` (`git show origin/main:bundles/ramble/panel/static/ramble.js | grep -c innerHTML`). + +- [ ] **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, and every one of them was an interaction between tasks or between the branch and pre-existing production state. Direct the reviewer at exactly that class: + +1. **What did we retire or change that was carrying something else?** (Phase 1 removed a button that was also the only exit from a view.) The energy bar's meaning changed; the pet response shape changed; `petFromRow` grew an argument. Who else reads those? +2. **What does an EXISTING player see at the moment of upgrade?** grackle has ~25 unlocked cells and a live pet row. Walk through the first render after deploy: the bar, the counter, the pips, the numbers. Is anything blank, wrong, or retroactively punishing? +3. **Does anything converge wrongly across the user's own instances?** Every new row is `delta = 1` under a natural key — verify that claim rather than accepting it, and check nothing writes a negative delta or a mutable value. +4. **Do the map and the payout genuinely read the same rule**, on every path, including the AR view and `/around`? +5. **Is any heart, cell or balance now reachable by a contact?** + +Fix everything it finds ON THE BRANCH before opening the PR. + +- [ ] **Open the PR** + +`gh` is not installed. Use the `github` MCP server. Body must state, at minimum: +- Phase 2 of the reward-economy spec; **no shop, no wardrobe, no spend path** (K1). +- **No schema change, no migration, no `SCHEMA_GENERATION` bump** — hearts ride `ramble_wallet`. +- The **two recorded deviations** (`heart.wild.rate`, `energy.max.cap`) with their reasons. +- **K2**: existing players' already-unlocked cells keep their hearts as uncollected pips; nothing is granted silently at upgrade. +- **K4**: D2's sad-portrait-to-contacts is explicitly NOT in this phase. +- That `moodFor` thresholds stay absolute at 60/30, and why. +- 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** check-runs result on a current sha means something is wrong, not that the run is clean — the legacy commit-status API omits Actions entirely and can read green while a check is red. + +- [ ] **Deploy** + +1. **Read `/home/kh0pp/CROW-SCHEDULE.md` first** and register the window before touching anything. +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: back up the database first (phase 1 kept `/home/kh0pp/crow-db-backup-pre-ramble-090.db`; take the equivalent), then restart the gateway. +4. Verify after the restart: + - the installed copy refreshed `0.9.5` -> `0.10.0`, + - `SELECT COUNT(*) FROM ramble_wallet WHERE kind = 'heart'` (expect 0 — nothing is granted at upgrade, by K2), + - `SELECT COUNT(*) FROM ramble_cells` (expect the existing ~25, unchanged), + - `PRAGMA integrity_check` is ok, + - the journal shows `[proxy] addon ramble: connected` with no Ramble errors. +5. Then clear the schedule entry. + +- [ ] **Hand back to Kevin** + +Say plainly: what shipped, what a player with 25 unlocked cells will see (a map with heart pips waiting, a bar still at 100 until the first one is collected), the phone smoke test that is still outstanding from three arcs now, and anything the whole-branch review found. + +--- + +## Out of scope — do not build these here + +- A shop, a wardrobe, an accessory catalogue, prices, or **any spend path** (phase 4, K1). +- Removing the auto-minted successor egg, auto-promote, laying, or the prologue (phase 3). +- D2's sad portrait to contacts, and any change to `servers/sharing/profile-avatar.js` (K4). +- Gating the MCP tool surface. Phase 1 left `ramble_query_world` / `ramble_nests` ungated deliberately; hearts add nothing there and this is not the phase to change it. +- Fixing phase 1's known follow-ups: `unlockedCellsNear`'s full-table read, the seed cooldown's global UTC bucket, the AR view filtering beacons out, spec §2.4's overclaim about surveying a city remotely. All are recorded in the phase 1 handoff and none is made worse by this phase. From 6c86e3b614c07cbcf1d02ab4a36db1d915340510 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 10:33:31 -0500 Subject: [PATCH 02/15] plan: fold the adversarial review's six findings into the phase 2 plan --- .../plans/2026-09-09-ramble-hearts-phase2.md | 496 ++++++++++++++---- 1 file changed, 386 insertions(+), 110 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md index 7c408cf2..5c38bcef 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md +++ b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md @@ -23,15 +23,17 @@ ## Global Constraints -- **Base:** `origin/main` @`e089ad4d`. Worktree `/home/kh0pp/crow-wt-ramble-hearts`, branch `feat/ramble-hearts`, 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. +- **Base:** `origin/main` @`200cf834` (rebased after the plan review; the earlier `e089ad4d` baseline was stale). Worktree `/home/kh0pp/crow-wt-ramble-hearts`, branch `feat/ramble-hearts`, 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`. -- **Suite baseline:** 4301 pass / 0 fail at `e089ad4d`. The suite must not regress. +- **Suite baseline:** **4301 pass / 0 fail at `200cf834`**, measured in this worktree after the rebase. The suite must not regress. - **No schema change, no migration, no `SCHEMA_GENERATION` bump.** Hearts ride `ramble_wallet` (`kind`, `key`, `delta`, `created_at`, `lamport_ts`), which already exists on every host. `bundles/ramble/server/init-tables.js` and `scripts/init-db.js` are **not modified by this plan**. If a task finds itself editing either file, stop — the design has drifted. - **Ledgers, not balances (spec §6.1):** every heart is an append-only row under a natural idempotent key. Never store a heart count, and never store maximum energy — both are derived by reading the ledger. - **A heart row's `delta` is ALWAYS the integer `1`** — a count of containers, never an energy amount. Two reasons, both load-bearing: (a) `applyRambleWallet` in `servers/sharing/instance-sync.js` resolves conflicts with `MAX(delta)`, which is only convergent when the value cannot differ between instances for the same key — phase 1 shipped a bug here precisely because seed's `delta` is the live, mutable `seed.per.pickup`; a constant `1` cannot disagree. (b) Storing energy would freeze `energy.max.per.heart` into history, so retuning that setting would not retune the bar. - **⚠ Never write a NEGATIVE delta in this phase.** `applyRambleWallet`'s doc comment records why: `MAX(delta)` would resolve a `-10`/`-5` disagreement to `-5`, deducting less. There are no spends in phase 2. Phase 4 must key a spend by the purchase. -- **The map and the payout MUST read the same rule.** A heart drawn on the map that a walk does not grant (or the reverse) is the exact defect phase 1 shipped in its seed layer and had to fix. `availableHearts()` (what the map draws) and `recordHeartPickup()` (what a walk grants) both call the single pure `heartCandidate()`, and Task 2 has an executable test that the two agree cell-for-cell. -- **Fail closed on unlock.** A heart is only ever granted in a cell that is already in `ramble_cells`. `recordHeartPickup` checks that itself rather than trusting its caller, so a position fix refused by the `unlock.max.accuracy.m` gate can never pay a heart in ground the user did not enter. +- **The map and the payout MUST read the same rule.** A heart drawn on the map that a walk does not grant (or the reverse) is the hazard 0.9.5 had to close when seed became sparse: before it, seed paid in every cell while the map drew pips in one in four. The gate `recordSeedPickup` carries today (`wallet.js`: "The SAME gate the map draws from. Without this the map would be a liar") is that fix. `availableHearts()` (what the map draws) and `recordHeartPickup()` (what a walk grants) therefore both go through the single pure `heartCandidates()`, and Task 2 has an executable test that the two agree cell-for-cell. + **⚠ But do not over-index on this one.** The three defects phase 1's whole-branch review actually caught were different in kind: a retired UI element that was carrying another affordance, a feature gating on accumulated state with no answer for existing users, and a non-convergent sync apply. Two of them are *an affordance added on one path and not its twin* — which is the failure this plan is most at risk of repeating, not a map/payout split. +- **Fail closed on unlock, on BOTH halves.** A heart is only ever granted in a cell that is already in `ramble_cells` — `recordHeartPickup` checks that itself rather than trusting its caller. **That check alone is not sufficient**, because a cell unlocked months ago passes it no matter how vague today's fix is, and this phase deliberately leaves hearts sitting in exactly such cells. So the route must ALSO pass `recordUnlock`'s own `out.cell`, which `cells.js` sets to `null` when the fix is too vague. Both halves, or a 2 km wifi fix collects a retroactive heart from a cell the user is nowhere near. +- **Every affordance must be added on BOTH paths.** The heart counter is fed by the area response *and* by `GET /api/ramble/pet`, because a player who denies geolocation never posts a fix — `paintPet` already calls `paintSeed(pet.seed)` for exactly this reason. Whenever this plan adds a painter, check whether its seed twin is called from two places. - **Mood thresholds stay ABSOLUTE at 60 and 30** (`moodFor` in `pet.js` is unchanged). This is the spec's own reading: §4.3 says "a larger maximum energy means more slack before dropping below happy". A percentage threshold would give a bigger bar no benefit at all and would make hearts cosmetic. - **Sparse features need explicit fixtures, not lucky hashes.** Phase 1's seed sparsity broke ledger tests written against values that happened to hash right. Every behavioural test in this plan sets `heart.rate = 1` (or `heart.wild.rate = 1`) so **every** cell holds a heart and the fixture is whatever you name. Rate itself is covered separately by one statistical test and one pinned regression vector. - **Replication is EXPLICIT.** `ramble_wallet` is already registered for inbound apply; nothing goes outward unless a writer calls `emit`. `recordHeartPickup` takes `{ now, emit }` and emits after a successful insert, exactly as `recordSeedPickup` does. @@ -49,14 +51,16 @@ Two settings the spec does not list are added here. Both follow the precedent of 1. **`heart.wild.rate` (default 40).** Spec §6.4 gives `heart.wild.days` (30) as "the minimum gap before a heart may reappear in already-unlocked ground" but gives wild hearts no rarity. Without a rate, **every** unlocked cell yields a wild heart every 30 days: a 300-cell map would pay 10 hearts a month, "rarely" would be false, and the map would be carpeted in hearts — the same complaint that forced seed to become sparse in 0.9.5. At 1-in-40 per 30-day window, 25 cells pay about 0.6 hearts a month and 300 cells about 7.5, which keeps §2.3's deliberate concession (grindable by a heavy walker) without making exploration pointless. 2. **`energy.max.cap` (default 300).** The spec caps nothing. Uncapped, a long-lived map pushes maximum energy high enough that the -10-per-6h decay can never reach the 60 threshold, which makes mood permanent, hearts worthless past a point, and — critically — pre-breaks **phase 3**, whose laying floor is gated on "the bird ends the day happy". 300 is base 100 plus 20 hearts, which is exactly Zelda's 20-heart cap; D6 makes that mapping the frame of the whole currency design. -Both are noted in the PR body so a reviewer reads them as decisions, not drift. +Both are noted in the PR body so a reviewer reads them as decisions, not drift. The wild arithmetic above assumes **every** unlocked cell stays eligible for a wild heart, including cells whose permanent heart is long gone — which is true only because `heartCandidates` offers both sources rather than short-circuiting on the first (Task 1). If that ever regresses to an `a || b`, these numbers are overstated by a third as well as the mechanic being broken. + +**A third number left at the spec's value, with the reasoning recorded.** `heart.rate = 3` means roughly one cleared cell in three shows a pip on upgrade day — denser than seed's one in four, which is odd for the rarer currency, and Deviation 1 argues at length that carpeting is a real failure mode. Three things make it acceptable and it is deliberately not changed: pips only draw at `MIN_CELL_DETAIL_ZOOM` (15) and above, so density is bounded by a walkable viewport rather than by lifetime history; unlike seed, a heart is taken **permanently**, so the carpet clears as it is walked and never returns; and the density on upgrade day is precisely the "destinations, not a number that jumped" that K2 chose. For grackle's ~25 cells it is about 8 pips. **Say the density out loud in the PR** so it is a judgement on the record rather than something a player discovers. --- ## File structure **Create** -- `bundles/ramble/server/hearts.js` — the whole heart mechanic: placement (`heartFor`, `wildHeartFor`, `heartCandidate`, `wildWindow`), settings (`readHeartSettings`), the ledger half (`availableHearts`, `recordHeartPickup`, `heartsBalance`), and the derived ceiling (`maxEnergy`). Imports only `nests.js` (for `CELL7_RE`) and `anchors.js` (for `decodeGeohash`) — never `pet.js`, which imports *this* module. +- `bundles/ramble/server/hearts.js` — the whole heart mechanic: placement (`heartFor`, `wildHeartFor`, `heartCandidates`, `wildWindow`), settings (`readHeartSettings`), the ledger half (`availableHearts`, `recordHeartPickup`, `heartsBalance`), and the derived ceiling (`maxEnergy`). Imports only `nests.js` (for `CELL7_RE`) and `anchors.js` (for `decodeGeohash`) — never `pet.js`, which imports *this* module. - `tests/ramble-hearts.test.js` — placement and settings (pure). - `tests/ramble-hearts-ledger.test.js` — pickup, availability, the same-rule agreement test, and multi-instance convergence. @@ -65,10 +69,11 @@ Both are noted in the PR body so a reviewer reads them as decisions, not drift. - `bundles/ramble/server/feed.js` — its `readPet` helper must carry the same `energy_max`. - `bundles/ramble/server/bird-svg.cjs` — `drawHeart()` / `mountHeart()`. - `bundles/ramble/panel/routes.js` — load `hearts.js` into `mods`; grant on a fix in `POST /api/ramble/area`; heart pips in `GET /api/ramble/zones`; `hearts` on `GET /api/ramble/pet`. +- `bundles/ramble/server/server.js` — the MCP tool `ramble_pet_state` (around line 304) calls `petState` too. It will pick up `energy_max` for free from the spread; give it `hearts` as well, or the tool and the HTTP route disagree about the same pet. - `bundles/ramble/panel/static/ramble.js` — the heart-pip layer, the counter, the pickup moment, the scaled energy bar, the heart row. - `bundles/ramble/panel/static/ramble.css` — pip, fallback dot, pop, counter and heart-row rules. - `bundles/ramble/panel/ramble.js` — the heart chip in the map bar; the heart row on the pet card. -- `tests/ramble-pet.test.js`, `tests/ramble-panel.test.js`, `tests/ramble-bird-svg.test.js` — extended in the task that changes the behaviour they cover. +- `tests/ramble-pet.test.js`, `tests/ramble-panel.test.js`, `tests/ramble-bird-svg.test.js`, `tests/ramble-tools.test.js` — extended 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`. @@ -90,7 +95,7 @@ Both are noted in the PR body so a reviewer reads them as decisions, not drift. - `wildWindow(now, days) -> integer` - `heartFor(cell, { rate }) -> { cell, key, source: "first", lat, lon } | null` - `wildHeartFor(cell, window, { wildRate }) -> { cell, key, source: "wild", lat, lon } | null` - - `heartCandidate(cell, window, { rate, wildRate }) -> the same shape | null` + - `heartCandidates(cell, window, { rate, wildRate }) -> [candidate]` — **an array in priority order**, permanent heart first, wild second, either or both possibly absent - `readHeartSettings(db) -> { rate, wildDays, wildRate, energyBase, perHeart, cap }` **Why placement is a hash and not a table:** this copies `nestFor` and `seedFor` deliberately. The answer is identical on every one of the user's devices with nothing stored and nothing to sync, and it cannot be re-rolled by leaving a cell and coming back. @@ -121,7 +126,7 @@ import { HEART_KIND, HEART_SALT, HEART_WILD_SALT, HEART_RATE_DEFAULT, HEART_WILD_DAYS_DEFAULT, HEART_WILD_RATE_DEFAULT, ENERGY_MAX_BASE_DEFAULT, ENERGY_MAX_PER_HEART_DEFAULT, ENERGY_MAX_CAP_DEFAULT, - wildWindow, heartFor, wildHeartFor, heartCandidate, readHeartSettings, + wildWindow, heartFor, wildHeartFor, heartCandidates, readHeartSettings, } from "../bundles/ramble/server/hearts.js"; const CELL = "9vk79ed"; @@ -161,7 +166,11 @@ test("heartFor: deterministic, and junk is refused rather than thrown", () => { assert.equal(heartFor("not-a-cell", ALL), null); assert.equal(heartFor("", ALL), null); assert.equal(heartFor(null, ALL), null); - assert.equal(heartFor(CELL, { rate: 0 }), heartFor(CELL, {}), "a junk rate falls back to the default"); + // deepEqual, NOT equal: at the default rate both sides may be objects, and + // `equal` would then compare identity and fail for a reason that has nothing + // to do with the fallback. This is the file's own lucky-hash warning applied + // to itself. + assert.deepEqual(heartFor(CELL, { rate: 0 }), heartFor(CELL, {}), "a junk rate falls back to the default"); }); test("heartFor at the default rate hits roughly one cell in three", () => { @@ -206,21 +215,27 @@ test("wildWindow buckets by whole days and falls back on junk", () => { assert.equal(wildWindow(60 * day, "x"), wildWindow(60 * day, HEART_WILD_DAYS_DEFAULT)); }); -test("heartCandidate prefers the permanent heart when both would hit", () => { - const both = heartCandidate(CELL, 610, ALL); - assert.equal(both.source, "first", "the once-ever heart outranks the regrowing one"); - assert.deepEqual(both, heartFor(CELL, ALL)); - - // No first heart available: the wild one is the candidate. The huge rate is - // asserted to miss rather than assumed to, so a surprise hit reads as a - // precondition failure instead of a confusing candidate-source failure. +test("heartCandidates lists BOTH sources, permanent first — it never hides the wild one", () => { + // ⚠ This is the shape the plan review forced. An `a || b` candidate would + // short-circuit forever once the permanent heart was taken, so at the default + // rate one cell in three could never grow a wild heart again — while the code + // comment promised the opposite. + const both = heartCandidates(CELL, 610, ALL); + assert.equal(both.length, 2, "both sources hit at rate 1, and both are offered"); + assert.equal(both[0].source, "first", "the once-ever heart is offered first"); + assert.equal(both[1].source, "wild", "but the regrowing one is still there behind it"); + assert.deepEqual(both[0], heartFor(CELL, ALL)); + assert.deepEqual(both[1], wildHeartFor(CELL, 610, ALL)); + + // The huge rates are asserted to miss rather than assumed to, so a surprise + // hit reads as a precondition failure instead of a confusing shape failure. assert.equal(heartFor(CELL, { rate: 999999 }), null, "precondition: no first heart at this rate"); - const onlyWild = heartCandidate(CELL, 610, { rate: 999999, wildRate: 1 }); - assert.equal(onlyWild.source, "wild"); + const onlyWild = heartCandidates(CELL, 610, { rate: 999999, wildRate: 1 }); + assert.deepEqual(onlyWild.map((c) => c.source), ["wild"]); assert.equal(wildHeartFor(CELL, 610, { wildRate: 999999 }), null, "precondition: no wild heart either"); - assert.equal(heartCandidate(CELL, 610, { rate: 999999, wildRate: 999999 }), null); - assert.equal(heartCandidate("bad", 610, ALL), null); + assert.deepEqual(heartCandidates(CELL, 610, { rate: 999999, wildRate: 999999 }), []); + assert.deepEqual(heartCandidates("bad", 610, ALL), []); }); test("readHeartSettings reads all six keys, and refuses junk", async () => { @@ -291,7 +306,7 @@ Expected: FAIL — `Cannot find module .../bundles/ramble/server/hearts.js`. * competitive power, only a longer buffer, and it lets someone who cannot * range far still progress. * - * ⚠ ONE RULE, TWO READERS. `heartCandidate` is the ONLY place that decides + * ⚠ ONE RULE, TWO READERS. `heartCandidates` is the ONLY place that decides * whether a cell holds a heart. The map (availableHearts) and the payout * (recordHeartPickup) both go through it. Phase 1 shipped a seed layer where * the map and the payout disagreed and had to fix it; this module exists in @@ -360,15 +375,27 @@ export function wildHeartFor(cell, window, { wildRate = HEART_WILD_RATE_DEFAULT } /** - * THE ONE RULE. What heart, if any, this cell holds right now. + * THE ONE RULE. Every heart this cell could hold right now, in priority order. + * + * The permanent heart is offered first when both hit: it is the rarer of the + * two and it disappears forever once taken, so handing it over first is + * strictly better for the player. The wild heart comes round again next window. * - * The permanent heart outranks the wild one when both would hit: it is the - * rarer of the two and it disappears forever once taken, so handing it over - * first is strictly better for the player. The wild heart will come round - * again next window. + * ⚠ RETURNS BOTH, and deliberately. The obvious `heartFor(...) || wildHeartFor(...)` + * short-circuits: once a cell's permanent heart is in the ledger, that version + * keeps returning the taken spot and never consults the wild source, so at the + * default rate one cell in three would be sterile for wild hearts FOREVER — + * while this very comment promised it "comes round again". The callers, which + * are the only things that know what has been taken, pick the first candidate + * that is still there. */ -export function heartCandidate(cell, window, { rate = HEART_RATE_DEFAULT, wildRate = HEART_WILD_RATE_DEFAULT } = {}) { - return heartFor(cell, { rate }) || wildHeartFor(cell, window, { wildRate }); +export function heartCandidates(cell, window, { rate = HEART_RATE_DEFAULT, wildRate = HEART_WILD_RATE_DEFAULT } = {}) { + const out = []; + const first = heartFor(cell, { rate }); + if (first) out.push(first); + const wild = wildHeartFor(cell, window, { wildRate }); + if (wild) out.push(wild); + return out; } /** Live settings (spec §6.4 plus the two deviations). Junk or a negative falls back. */ @@ -412,35 +439,65 @@ export async function readHeartSettings(db) { node scripts/run-suite.mjs tests/ramble-hearts.test.js ``` -Expected: PASS, 8 tests. +Expected: PASS, 8 tests. The statistical test was measured at `share = 0.3293` over 3000 cells while this plan was written, comfortably inside the band. - [ ] **Step 5: Pin the salt with a regression vector** -The statistical test proves the *shape* of the distribution; it would not notice the salt changing. Pin the actual values once. Run exactly this, in the worktree: +The statistical test proves the *shape* of the distribution; it would not notice the salt changing. Pin the actual values once. + +**⚠ The pin must contain confirmed HITS, and it must pin positions.** A vector of six cells that all miss reduces every assertion to `false === false`, and a `heartFor` that returned `null` unconditionally would sail through it — this plan's first draft shipped exactly that vector, and the review caught it. A pin that records only hit/miss also cannot see a placement change that keeps the pattern but moves every pip off its spot. + +Run this generator, which searches for real hits on each salt rather than hoping six named cells happen to have them: ```bash +export PATH=/home/kh0pp/.nvm/versions/node/v22.23.1/bin:$PATH node --input-type=module -e ' import { heartFor, wildHeartFor } from "./bundles/ramble/server/hearts.js"; -for (const c of ["9vk79ed", "9v6m2xt", "dr5regw", "gcpvj0d", "u33dc0e", "wecnrmd"]) { - const f = heartFor(c, {}); - const w = wildHeartFor(c, 610, {}); - console.log(c, f ? "FIRST" : "-", w ? "WILD" : "-"); -}' +import { encodeGeohash } from "./bundles/ramble/server/anchors.js"; +const hits = [], misses = [], wildHits = [], wildMisses = []; +for (let i = 0; i < 4000; i++) { + const cell = encodeGeohash(-60 + ((i * 7919) % 12000) / 100, -170 + ((i * 6271) % 34000) / 100, 7); + const f = heartFor(cell, {}), w = wildHeartFor(cell, 610, {}); + if (f && hits.length < 3) hits.push([cell, f.lat, f.lon]); + if (!f && misses.length < 3) misses.push(cell); + if (w && wildHits.length < 2) wildHits.push([cell, w.lat, w.lon]); + if (!w && wildMisses.length < 2) wildMisses.push(cell); +} +console.log(JSON.stringify({ hits, misses, wildHits, wildMisses }, null, 2));' ``` -Paste the observed truth table into a new test in `tests/ramble-hearts.test.js` — whatever it says, unchanged. Do **not** pick cells to make it look tidy; this test's whole job is to fail if the salt or the hash arithmetic ever moves. +Paste the observed values in, to six decimal places, and confirm before committing that `hits` and `wildHits` are **non-empty** — if either is, the generator found nothing and the pin is worthless: ```js -test("pinned: the default-rate placement for six fixed cells never moves", () => { +test("pinned: the default-rate placement never moves", () => { // Generated once from the salts in hearts.js. If this fails, someone changed - // a salt or the hash arithmetic, and every existing player's map moved. - const expected = { - // { first: true|false, wild: true|false } from the snippet above> - }; - for (const [cell, want] of Object.entries(expected)) { - assert.equal(!!heartFor(cell, {}), want.first, cell + " first heart"); - assert.equal(!!wildHeartFor(cell, 610, {}), want.wild, cell + " wild heart"); + // a salt or the hash arithmetic, and every existing player's map moved + // underneath them. Hits AND misses, positions AND presence: a pin of misses + // alone passes against a heartFor() that returns null for everything. + const FIRST_HITS = [ + // + ]; + const FIRST_MISSES = [/* */]; + const WILD_HITS = [ + // + ]; + const WILD_MISSES = [/* */]; + + assert.ok(FIRST_HITS.length > 0 && WILD_HITS.length > 0, "a pin with no hits pins nothing"); + for (const [cell, lat, lon] of FIRST_HITS) { + const spot = heartFor(cell, {}); + assert.ok(spot, cell + " must still hold its permanent heart"); + assert.equal(spot.lat.toFixed(6), lat.toFixed(6), cell + " heart moved in latitude"); + assert.equal(spot.lon.toFixed(6), lon.toFixed(6), cell + " heart moved in longitude"); + } + for (const cell of FIRST_MISSES) assert.equal(heartFor(cell, {}), null, cell + " must still be empty"); + for (const [cell, lat, lon] of WILD_HITS) { + const spot = wildHeartFor(cell, 610, {}); + assert.ok(spot, cell + " must still hold its wild heart in window 610"); + assert.equal(spot.lat.toFixed(6), lat.toFixed(6), cell + " wild heart moved in latitude"); + assert.equal(spot.lon.toFixed(6), lon.toFixed(6), cell + " wild heart moved in longitude"); } + for (const cell of WILD_MISSES) assert.equal(wildHeartFor(cell, 610, {}), null, cell + " must still be empty"); }); ``` @@ -470,7 +527,7 @@ git show --stat HEAD - Create: `tests/ramble-hearts-ledger.test.js` **Interfaces:** -- Consumes: everything Task 1 produced, plus `ramble_wallet` and `ramble_cells` (both already exist on every host), plus `applyRambleWallet` from `servers/sharing/instance-sync.js` for the convergence test. +- Consumes: `heartCandidates`, `wildWindow`, `readHeartSettings`, `HEART_KIND`, `CELL7_RE` from Task 1, plus `ramble_wallet` and `ramble_cells` (both already exist on every host), plus `applyRambleWallet` from `servers/sharing/instance-sync.js` for the convergence test. - Produces, for Tasks 3-4: - `recordHeartPickup(db, cell, { now, emit }) -> { picked: boolean, amount: 0 | 1, source?: "first" | "wild" }` - `availableHearts(db, cells, { now }) -> [{ cell, key, source, lat, lon }]` @@ -479,7 +536,7 @@ git show --stat HEAD **Three rules this task exists to enforce:** -1. **Same rule, two readers.** `availableHearts` and `recordHeartPickup` both go through `heartCandidate`. Step 1's `same rule` test asserts they agree cell-for-cell over a fixture, before and after collection. This is the phase 1 defect made executable. +1. **Same rule, two readers.** `availableHearts` and `recordHeartPickup` both go through `heartCandidates` **and both pick the first candidate that is not already taken**, so a cell whose permanent heart is gone still grows a wild one. Step 1's `same rule` test asserts the two agree cell-for-cell, before and after collection. 2. **Fail closed on unlock.** `recordHeartPickup` verifies the cell is in `ramble_cells` itself. A fix that the `unlock.max.accuracy.m` gate refused must never pay a heart in ground the user did not enter, and the route is not trusted to check. 3. **`delta` is the constant `1`.** Never `perHeart`, never a spend. @@ -507,7 +564,7 @@ import { createClient } from "@libsql/client"; import { initRambleTables } from "../bundles/ramble/server/init-tables.js"; import { applyRambleWallet } from "../servers/sharing/instance-sync.js"; import { - HEART_KIND, heartCandidate, wildWindow, + HEART_KIND, heartCandidates, wildWindow, recordHeartPickup, availableHearts, heartsBalance, maxEnergy, } from "../bundles/ramble/server/hearts.js"; @@ -518,6 +575,8 @@ const NOW = 1_757_000_000_000; // what it looks like — no cell here was chosen for hashing lucky. const CELLS = ["9vk79ed", "9v6m2xt", "dr5regw", "gcpvj0d", "u33dc0e", "wecnrmd"]; +// `per` and `cap` are used by the energy tests below; `rate`/`wildRate` are +// pinned per-test so no test depends on a cell that happens to hash lucky. async function freshDb({ rate = 1, wildRate = 999999, base, per, cap } = {}) { const db = createClient({ url: "file::memory:" }); await initRambleTables(db); @@ -593,7 +652,7 @@ test("a wild heart regrows: once per window, again in the next", async () => { const day = 24 * 3600 * 1000; // Asserted, not assumed: a surprise first heart at this rate would otherwise // fail below as a baffling "source" mismatch. - assert.equal(heartCandidate(CELLS[0], wildWindow(NOW, 30), { rate: 999999, wildRate: 999999 }), null, + assert.deepEqual(heartCandidates(CELLS[0], wildWindow(NOW, 30), { rate: 999999, wildRate: 999999 }), [], "precondition: neither source hits at these rates"); const first = await recordHeartPickup(db, CELLS[0], { now: NOW }); @@ -618,7 +677,7 @@ test("THE SAME RULE: what the map draws is exactly what a walk would grant", asy const drawn = await availableHearts(db, CELLS, { now: NOW }); assert.equal(drawn.length, CELLS.length, "rate 1: every unlocked cell in the fixture"); for (const spot of drawn) { - assert.deepEqual(spot, heartCandidate(spot.cell, wildWindow(NOW, 30), { rate: 1, wildRate: 999999 }), + assert.deepEqual(spot, heartCandidates(spot.cell, wildWindow(NOW, 30), { rate: 1, wildRate: 999999 })[0], "the map draws the candidate itself, not a re-derived guess"); } @@ -677,6 +736,48 @@ test("seed rows are not hearts and hearts are not seed", async () => { assert.equal(await heartsBalance(db), 1, "the seed pile does not inflate the heart count"); }); +test("a cell grows a WILD heart after its permanent one is taken", async () => { + // ⚠ The defect the plan review caught. An `a || b` candidate keeps returning + // the taken permanent heart and never reaches the wild source, so at the + // default rate one cell in three would be sterile forever. Both sources hit + // here (rate 1, wildRate 1), which is the only configuration that can tell + // the two implementations apart — every other test in this file silences one + // source to isolate the other, and that is exactly how this hid. + const db = await freshDb({ rate: 1, wildRate: 1 }); + await unlock(db, [CELLS[0]]); + + const first = await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.deepEqual(first, { picked: true, amount: 1, source: "first" }); + + const wild = await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.deepEqual(wild, { picked: true, amount: 1, source: "wild" }, + "the wild heart in the same window is still there to take"); + + assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: NOW }), { picked: false, amount: 0 }, + "and now the cell really is empty for this window"); + + const day = 24 * 3600 * 1000; + const nextWindow = await recordHeartPickup(db, CELLS[0], { now: NOW + 31 * day }); + assert.deepEqual(nextWindow, { picked: true, amount: 1, source: "wild" }, + "next window, the wild heart comes round again — as the doc comment promises"); + + // And the map agrees at every step, which is the whole point. + assert.deepEqual(await availableHearts(db, [CELLS[0]], { now: NOW }), []); + assert.equal((await availableHearts(db, [CELLS[0]], { now: NOW + 62 * day })).length, 1); +}); + +test("a heart row is worth ONE container even when a heart is worth 25 energy", async () => { + // The assertion that would actually fail if someone later stored energy in + // the row. Asserting that MAX(delta) of two identical 1s is 1 proves nothing. + const db = await freshDb({ rate: 1, per: 25 }); + await unlock(db, [CELLS[0]]); + await recordHeartPickup(db, CELLS[0], { now: NOW }); + const rows = await walletRows(db); + assert.equal(Number(rows[0].delta), 1, "delta is a COUNT; the energy per heart lives in a setting"); + assert.equal(await heartsBalance(db), 1); + assert.equal(await maxEnergy(db), 125, "and the setting is what values it"); +}); + test("two instances converge on the same heart count whatever order rows arrive in", async () => { // Spec §8: anything that replicates needs a multi-instance test, not a // single-database one. delta is a constant 1, so MAX(delta) — which phase 1 @@ -729,6 +830,30 @@ async function takenKeys(db) { return new Set((rows || []).map((r) => String(r.key))); } +/** + * Which of the ASKED cells are unlocked. Bounded by the question, not by the + * player's history: `SELECT cell FROM ramble_cells` would be a second + * unbounded full-table scan on every /zones request, and phase 1 already left + * one of those behind in unlockedCellsNear. The caller has usually filtered to + * unlocked ground already, but this stays fail-closed rather than trusting it. + */ +async function unlockedAmong(db, cells) { + const out = new Set(); + // Chunked: a close-zoom viewport over a walked town can ask about more cells + // than SQLite will bind at once, which is the same limit harvestableCells + // avoids by matching on a key suffix instead. + for (let i = 0; i < cells.length; i += 400) { + const chunk = cells.slice(i, i + 400); + const marks = chunk.map(() => "?").join(","); + // eslint-disable-next-line no-await-in-loop + const { rows } = await db.execute({ + sql: `SELECT cell FROM ramble_cells WHERE cell IN (${marks})`, args: chunk, + }); + for (const r of rows || []) out.add(String(r.cell)); + } + return out; +} + /** * Take the heart in this cell, if there is one and it is still there. * @@ -751,18 +876,24 @@ export async function recordHeartPickup(db, cell, { now = Date.now(), emit } = { // NOT `Number(now) || Date.now()` — that treats `now: 0` as falsy and // silently substitutes the real clock (the phase 1 note on this still holds). const at = Number.isFinite(Number(now)) ? Number(now) : Date.now(); - const spot = heartCandidate(cell, wildWindow(at, wildDays), { rate, wildRate }); - if (!spot) return none; - - const res = await db.execute({ - sql: `INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?) - ON CONFLICT(kind, key) DO NOTHING`, - args: [HEART_KIND, spot.key, at], - }); - if (Number(res.rowsAffected) === 0) return none; - await safeEmit(emit, "ramble_wallet", "insert", - { kind: HEART_KIND, key: spot.key, delta: 1, created_at: at }); - return { picked: true, amount: 1, source: spot.source }; + // EVERY candidate, in priority order, not just the first: a cell whose + // permanent heart was collected long ago must still be able to pay out its + // wild one. The INSERT is the arbiter — whichever key is not yet in the + // ledger is the one that pays. + for (const spot of heartCandidates(cell, wildWindow(at, wildDays), { rate, wildRate })) { + // eslint-disable-next-line no-await-in-loop + const res = await db.execute({ + sql: `INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?) + ON CONFLICT(kind, key) DO NOTHING`, + args: [HEART_KIND, spot.key, at], + }); + if (Number(res.rowsAffected) === 0) continue; + // eslint-disable-next-line no-await-in-loop + await safeEmit(emit, "ramble_wallet", "insert", + { kind: HEART_KIND, key: spot.key, delta: 1, created_at: at }); + return { picked: true, amount: 1, source: spot.source }; + } + return none; } catch (err) { try { console.warn("[ramble] heart pickup failed:", err?.message); } catch {} return none; @@ -772,10 +903,10 @@ export async function recordHeartPickup(db, cell, { now = Date.now(), emit } = { /** * Which of these cells still hold a heart to walk to. * - * ⚠ THE SAME RULE THE PAYOUT USES. This goes through heartCandidate, exactly - * as recordHeartPickup does, and returns the candidate object itself rather - * than a re-derived position — so the map cannot drift from the payout. A - * heart shown but not granted is the defect phase 1 shipped in its seed layer. + * ⚠ THE SAME RULE THE PAYOUT USES. This goes through heartCandidates and takes + * the first untaken one, exactly as recordHeartPickup does, and returns the + * candidate object itself rather than a re-derived position — so the map cannot + * drift from the payout. That drift is the hazard 0.9.5 closed for seed. * * Cells the caller has not unlocked are dropped even when they are asked for: * a heart in fog would be a preview of ground you have not earned (K3). @@ -787,14 +918,15 @@ export async function availableHearts(db, cells, { now = Date.now() } = {}) { const { rate, wildDays, wildRate } = await readHeartSettings(db); const at = Number.isFinite(Number(now)) ? Number(now) : Date.now(); const window = wildWindow(at, wildDays); - const { rows } = await db.execute({ sql: "SELECT cell FROM ramble_cells", args: [] }); - const unlocked = new Set((rows || []).map((r) => String(r.cell))); + const unlocked = await unlockedAmong(db, asked); const taken = await takenKeys(db); const out = []; for (const cell of asked) { if (!unlocked.has(cell)) continue; - const spot = heartCandidate(cell, window, { rate, wildRate }); - if (spot && !taken.has(spot.key)) out.push(spot); + // The FIRST candidate still standing — the same choice recordHeartPickup + // makes when it walks the list and lets the INSERT arbitrate. + const spot = heartCandidates(cell, window, { rate, wildRate }).find((c) => !taken.has(c.key)); + if (spot) out.push(spot); } return out; } catch (err) { @@ -1139,14 +1271,14 @@ git show --stat HEAD **Interfaces:** - Consumes: `recordHeartPickup`, `availableHearts`, `heartsBalance`, `maxEnergy` from `hearts.js`. - Produces, for Tasks 6-7: - - `POST /api/ramble/area` with a fix gains `heart_picked: 1` (only when one was taken) and always-on `hearts` and `energy_max` (only when the post carried `here`). + - `POST /api/ramble/area` with a fix gains `heart_picked: 1` (only when one was taken) plus `hearts`, `energy_max` and `energy_max_cap` (all three only when the post carried `here`). - `GET /api/ramble/zones?pips=1` gains `hearts: [{ cell, key, source, lat, lon }]`. - - `GET /api/ramble/pet` gains `hearts`; `energy_max` already rides in from `petState`. + - `GET /api/ramble/pet` gains `hearts` and `energy_max_cap`; `energy_max` already rides in from `petState`. **Three things to get exactly right:** 1. **A heart is attempted on EVERY fix, not only on a first unlock.** This is what K2 and K3 buy: a first unlock grants on the spot (the cell was fogged, so it is a surprise), and a cell unlocked months ago whose heart was never taken pays when the player walks back. Seed's `if (!out.unlocked)` guard is a seed rule — do not copy it. -2. **Order matters.** `recordUnlock` runs first. `recordHeartPickup` fails closed on an un-unlocked cell, so an inaccurate fix that was refused an unlock is also refused a heart, automatically and without the route knowing the rule. +2. **Pass `out.cell`, NOT `cell`.** This is the finding the plan review caught, and it is the difference between a working phase and a broken one. `recordHeartPickup`'s own fail-closed check (`is this cell in ramble_cells?`) protects *new* ground only — a cell unlocked months ago passes it however vague today's fix is, and this phase deliberately leaves hearts sitting in exactly those cells. `recordUnlock` returns `cell: null` when the fix is worse than `unlock.max.accuracy.m` (`cells.js`), so threading `out.cell` through is what actually refuses the payout. Pass the raw `cell` and a single 2 km wifi fix harvests a retroactive heart from a cell the player is nowhere near. 3. **An area post with no `here` must keep its response byte for byte.** An existing test deep-equals it. Every new field rides inside the `here` branch. - [ ] **Step 1: Write the failing tests** @@ -1187,7 +1319,14 @@ async function withHeartSettings(pairs, fn) { } } -const HEARTS_ON = [["heart.rate", "1"], ["heart.wild.rate", "999999"], ["unlock.max.accuracy.m", "100"]]; +// `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. +const HEARTS_ON = [ + ["heart.rate", "1"], ["heart.wild.rate", "999999"], + ["unlock.max.accuracy.m", "100"], ["warmth.visit_place", "0"], +]; const jsonOf = async (path, opts) => (await req(path, opts)).json(); test("POST /api/ramble/area grants a heart on a first unlock, and reports the new ceiling", async () => { @@ -1222,6 +1361,32 @@ test("a fix too vague to unlock is also too vague to pay a heart", async () => { }); }); +test("a vague fix cannot harvest a heart from ground unlocked LONG AGO", async () => { + // ⚠ The one the plan review caught. The in-ramble_cells check passes for an + // already-unlocked cell no matter how bad today's fix is, so this is the case + // the "fail closed" claim actually has to survive. Unlock the cell sharply + // while it holds no heart, then make it hold one, then arrive vaguely. + const here = { lat: 35.6762, lon: 139.6503 }; // Tokyo: fresh ground for this file + await withHeartSettings( + [["heart.rate", "999999"], ["heart.wild.rate", "999999"], ["unlock.max.accuracy.m", "100"], ["warmth.visit_place", "0"]], + async () => { + const sharp = await jsonOf("/api/ramble/area", { + method: "POST", body: { cells: [], here: { ...here, accuracy_m: 10 } }, + }); + assert.ok(sharp.unlocked, "precondition: the cell is unlocked, and held no heart"); + }, + ); + await withHeartSettings(HEARTS_ON, async () => { + const before = (await jsonOf("/api/ramble/pet")).hearts; + const vague = await jsonOf("/api/ramble/area", { + method: "POST", body: { cells: [], here: { ...here, accuracy_m: 2000 } }, + }); + assert.equal(vague.heart_picked, undefined, + "a 2 km fix must not collect the heart now waiting in already-unlocked ground"); + assert.equal((await jsonOf("/api/ramble/pet")).hearts, before, "nothing was granted"); + }); +}); + test("POST /api/ramble/area WITHOUT `here` keeps its exact historical shape", async () => { const res = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"] } }); assert.deepEqual(res, { cells: ["9v6m2xt"] }, @@ -1229,13 +1394,23 @@ test("POST /api/ramble/area WITHOUT `here` keeps its exact historical shape", as }); test("GET /api/ramble/zones?pips=1 draws hearts only in unlocked ground", async () => { + // ⚠ A FRESH cell, and a length assertion BEFORE the loop. The first draft of + // this test reused Austin, whose only heart the previous test had already + // collected, so `hearts` was always [] and the per-pip loop never ran once — + // an implementation returning [] unconditionally passed it. await withHeartSettings(HEARTS_ON, async () => { - const here = { lat: 30.2672, lon: -97.7431, accuracy_m: 20 }; - await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"], here } }); + const db = createDbClient(); + try { + await db.execute({ + sql: "INSERT INTO ramble_cells (cell, first_unlocked_at) VALUES ('gcpvj0d', 1) ON CONFLICT(cell) DO NOTHING", + args: [], + }); // gcpvj0d = 51.5073/-0.1284, London: untouched by every other test here + } finally { db.close(); } - const bbox = "30.25,-97.76,30.29,-97.72"; + const bbox = "51.49,-0.15,51.52,-0.11"; const withPips = await jsonOf("/api/ramble/zones?bbox=" + bbox + "&pips=1"); assert.ok(Array.isArray(withPips.hearts), "the field is always an array"); + assert.ok(withPips.hearts.length >= 1, "there IS a heart to draw, or this test proves nothing"); for (const h of withPips.hearts) { assert.ok(withPips.unlocked.some((b) => h.lat >= b.south && h.lat <= b.north && h.lon >= b.west && h.lon <= b.east), @@ -1247,7 +1422,7 @@ test("GET /api/ramble/zones?pips=1 draws hearts only in unlocked ground", async }); }); -test("a heart in ground unlocked before this feature existed waits on the map", async () => { +test("a heart in ground unlocked before this feature existed waits on the map, and pays when walked to", async () => { // The K2 case, end to end: a row put straight into ramble_cells (exactly what // phase 1's backfill left behind) still has its heart to walk back to. await withHeartSettings(HEARTS_ON, async () => { @@ -1262,6 +1437,16 @@ test("a heart in ground unlocked before this feature existed waits on the map", const zones = await jsonOf("/api/ramble/zones?bbox=52.50,13.35,52.54,13.46&pips=1"); assert.equal(zones.hearts.filter((h) => h.cell === "u33dc0e").length, 1, "a cell unlocked before this feature shipped still has its heart waiting"); + + // And walking there really does collect the pip the map just drew. + const before = (await jsonOf("/api/ramble/pet")).hearts; + const walked = await jsonOf("/api/ramble/area", { + method: "POST", body: { cells: [], here: { lat: 52.5181, lon: 13.4081, accuracy_m: 15 } }, + }); + assert.equal(walked.heart_picked, 1, "the pip the map drew is the heart the walk grants"); + assert.equal(walked.hearts, before + 1); + const after = await jsonOf("/api/ramble/zones?bbox=52.50,13.35,52.54,13.46&pips=1"); + assert.equal(after.hearts.filter((h) => h.cell === "u33dc0e").length, 0, "and the pip is gone"); }); }); @@ -1326,11 +1511,17 @@ Inside the existing `if (here) { ... }` block, after the seed branch: // unlock. A first unlock grants on the spot (the cell was fogged, so it // is a surprise); a cell unlocked long ago whose heart was never taken // pays when the player walks back to it, which is what makes the pips on - // their existing map real destinations. recordHeartPickup fails closed on - // a cell that is not in ramble_cells, so a fix too vague to unlock is - // refused a heart on exactly the same grounds, without this route - // restating the rule. - heartPicked = (await mods.heartsMod.recordHeartPickup(db, cell, { now: Date.now(), emit })).amount; + // their existing map real destinations. + // + // ⚠ `out.cell`, NOT `cell`. recordUnlock nulls its cell when the fix is + // vaguer than unlock.max.accuracy.m, and that is the ONLY thing standing + // between a 2 km wifi fix and the heart sitting in a cell unlocked months + // ago — recordHeartPickup's own in-ramble_cells check passes happily for + // ground that is already unlocked, which is most of the ground that still + // holds a heart. + if (out.cell) { + heartPicked = (await mods.heartsMod.recordHeartPickup(db, out.cell, { now: Date.now(), emit })).amount; + } } ``` @@ -1346,6 +1537,9 @@ and the response, keeping every new field inside the `here` branch: ...(here ? { hearts: await mods.heartsMod.heartsBalance(db), energy_max: await mods.heartsMod.maxEnergy(db), + // The ceiling's ceiling, so the panel can tell "the bar grew" from + // "the bar is as long as it goes" and say the right thing (Task 6). + energy_max_cap: (await mods.heartsMod.readHeartSettings(db)).cap, } : {}), }); ``` @@ -1360,7 +1554,7 @@ In the `pips === "1"` branch, beside the existing seed read: if (req.query?.pips === "1") { seed = await mods.walletMod.harvestableCells(db, out.unlocked.map((b) => b.cell), { now: Date.now() }); // The SAME rule the payout uses (hearts.js: availableHearts and - // recordHeartPickup both go through heartCandidate). A heart drawn here + // recordHeartPickup both go through heartCandidates). A heart drawn here // that a walk would not grant is the phase 1 seed defect all over again. hearts = await mods.heartsMod.availableHearts(db, out.unlocked.map((b) => b.cell), { now: Date.now() }); } @@ -1377,11 +1571,28 @@ and add `hearts` to the response object beside `seed`. egg: { percent: egg.egg.percent }, seed: await mods.walletMod.seedBalance(db), hearts: await mods.heartsMod.heartsBalance(db), + energy_max_cap: (await mods.heartsMod.readHeartSettings(db)).cap, }); ``` `energy_max` needs nothing here — `petState` already returns it and the spread carries it. +- [ ] **Step 6b: Keep the MCP tool agreeing with the route** + +`bundles/ramble/server/server.js` (around line 304) has a second `petState` caller: the `ramble_pet_state` tool. It picks up `energy_max` for free from its spread, but not `hearts`, so the tool and the HTTP route would describe the same pet differently. Import `heartsBalance` alongside the other server-side helpers and add it: + +```js + 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 } })); +``` + +and assert it in `tests/ramble-tools.test.js` beside the existing `ramble_pet_state` coverage: + +```js + assert.equal(typeof state.hearts, "number", "the tool reports the same wallet the panel does"); + assert.equal(typeof state.energy_max, "number"); +``` + - [ ] **Step 7: Run the route tests, then the map slice** ```bash @@ -1394,7 +1605,7 @@ Expected: all PASS. - [ ] **Step 8: Commit** ```bash -git commit bundles/ramble/panel/routes.js tests/ramble-panel.test.js -m "ramble: a walk takes the heart, and the map says where the next one is" +git commit bundles/ramble/panel/routes.js bundles/ramble/server/server.js tests/ramble-panel.test.js tests/ramble-tools.test.js -m "ramble: a walk takes the heart, and the map says where the next one is" git show --stat HEAD ``` @@ -1419,12 +1630,16 @@ Append to `tests/ramble-bird-svg.test.js`: ```js test("drawHeart returns inert SVG children in the seed's own idiom", () => { - const svg = engine.drawHeart(); + const svg = Bird.drawHeart(); assert.equal(typeof svg, "string"); assert.ok(svg.length > 0); assert.ok(/ { @@ -1433,14 +1648,14 @@ test("mountHeart sets the same 24-unit viewBox the seed pip uses", () => { setAttribute: (k, v) => calls.push([k, v]), set innerHTML(v) { calls.push(["innerHTML", v]); }, }; - engine.mountHeart(el); + Bird.mountHeart(el); assert.deepEqual(calls[0], ["viewBox", "0 0 24 24"], "same box as mountSeed, so the pips match in size"); assert.equal(calls[1][0], "innerHTML"); - assert.equal(calls[1][1], engine.drawHeart()); + assert.equal(calls[1][1], Bird.drawHeart()); }); ``` -**Note for the implementer:** the file already imports the engine under some name (`engine`, `Bird`, or similar) and already tests `drawSeed`/`mountSeed` this way. Match whatever is there rather than introducing a second style. +**Note for the implementer:** `tests/ramble-bird-svg.test.js` binds the engine as `Bird` — the names above are already correct. Mirror the file's existing `drawSeed`/`mountSeed` cases rather than introducing a second style. - [ ] **Step 2: Run and watch it fail** @@ -1533,8 +1748,12 @@ test("heart pips, the fallback dot and the pop all have styles", async () => { assert.ok(css.includes("#ramble .rb-hearts {"), "the map-bar counter has a rule"); assert.ok(css.includes("#ramble .rb-heart-pop {")); assert.ok(css.includes("@keyframes rb-heart-rise")); - assert.match(css, /prefers-reduced-motion[\s\S]*\.rb-heart-pop \{ animation: none/, + // The heart pop joins the EXISTING comma-separated reduced-motion list, so + // match it as a member of that list rather than as its own rule. + assert.match(css, /prefers-reduced-motion[\s\S]*#ramble \.rb-heart-pop,[\s\S]*animation: none/, "the pop respects reduced motion, like the seed pop already does"); + assert.ok(css.includes("#ramble .rb-heart-pip > svg {"), + "the pip's svg is SIZED — without this it renders at the CSS default 300x150"); }); ``` @@ -1623,7 +1842,7 @@ Directly after `paintSeed`: * this does both: the number pops, and the bird speaks. sayMoment is the only * thing that opens the bubble on its own, and an arrival is exactly what it * is for. */ - function celebrateHeart() { + function celebrateHeart(alsoUnlocked, atCap) { var chip = $("rb-heart-count"); if (chip && chip.parentNode) { var pop = document.createElement("span"); @@ -1632,7 +1851,14 @@ Directly after `paintSeed`: chip.parentNode.appendChild(pop); setTimeout(function () { if (pop.parentNode) pop.parentNode.removeChild(pop); }, 1400); } - sayMoment("A heart container. Your bird can hold more now."); + /* ONE line, not two. celebrateUnlock has already said "New ground." on a + * first unlock, and sayMoment holds for 4200ms -- a second call overwrites + * the first, so the unlock moment would be erased every time a new cell + * also paid a heart, which is one arrival in three. When both happen, say + * the thing that covers both. */ + if (atCap) sayMoment("Another heart container. Your bird is as strong as it gets."); + else if (alsoUnlocked) sayMoment("New ground, and a heart container in it."); + else sayMoment("A heart container. Your bird can hold more now."); } function paintHearts(n) { @@ -1646,25 +1872,37 @@ and in the area-response handler, immediately after the two existing seed lines ```js if (out && typeof out.hearts === "number") paintHearts(out.hearts); - if (out && out.heart_picked) { celebrateHeart(); if (!out.unlocked) refreshZones(); } + if (out && out.heart_picked) { + celebrateHeart(!!out.unlocked, out.energy_max === out.energy_max_cap); + if (!out.unlocked) refreshZones(); + } ``` -**Why exactly that form.** Taking a heart removes a pip and the pip list comes from `/zones`, so the map does need a refresh — but marks are untouched by a heart, so do **not** call `refreshMarks` (phase 1 shipped a redundant double `refreshMarks` on the unlock path that three adversarial rounds missed; do not add a third). And when the pickup rode in on a *first unlock*, `celebrateUnlock` has already called `refreshZones`, so the `!out.unlocked` guard is what keeps it from firing twice. This mirrors the seed line directly above it, deliberately. +**Why exactly that form.** Taking a heart removes a pip and the pip list comes from `/zones`, so the map does need a refresh — but marks are untouched by a heart, so do **not** call `refreshMarks` (phase 1 shipped a redundant double `refreshMarks` on the unlock path that three adversarial rounds missed; do not add a third). When the pickup rode in on a *first unlock*, `celebrateUnlock` has already called `refreshZones`, so the `!out.unlocked` guard keeps it from firing twice — and it has already spoken, which is why `celebrateHeart` takes the flag and says one combined line rather than silently overwriting "New ground." + +**This needs one more field from the server.** `energy_max_cap` is the ceiling's ceiling, so the panel can tell "the bar grew" from "the bar is as long as it goes". Add it beside `energy_max` in the `here` branch of `POST /api/ramble/area` and in `GET /api/ramble/pet` (Task 4), reading `(await mods.heartsMod.readHeartSettings(db)).cap`. - [ ] **Step 6: The styles** In `bundles/ramble/panel/static/ramble.css`, beside the seed rules: +**⚠ The pip needs TWO rules, not one.** `mountHeart` sets a `viewBox` but no `width`/`height`, and `L.divIcon`'s `iconSize` sizes the wrapper, not the child — an unsized inline `` falls back to the CSS default **300x150 px**. The seed pip is two rules for exactly this reason (`ramble.css:883-884`). Copy that shape: + ```css -#ramble .rb-heart-pip { pointer-events: none; } +#ramble .rb-heart-pip { display: grid; place-items: center; } +#ramble .rb-heart-pip > svg { width: 20px; height: 20px; filter: drop-shadow(1px 2px 0 var(--rb-shadow-col)); } #ramble .rb-heart-dot { fill: #d8556a; } +``` + +`#d8556a` is a literal rather than a token deliberately: the fallback dot exists to stand in for the engine's heart when the engine did not load, so it has to be the engine's own colour. (`--rb-accent-2`, which the seed dot uses, is the seed's ochre.) +The map-bar counter is a **pill**, matching its neighbour — `.rb-seed` at `ramble.css:915` is `position: relative` (that is what anchors the `+N` pop), `display: inline-flex`, with a border and the display font. Read that rule and mirror it, changing only the accent: + +```css #ramble .rb-hearts { - display: inline-flex; align-items: baseline; gap: 0.3rem; - margin-left: 0.5rem; position: relative; - font-size: 0.82rem; color: var(--rb-muted); + position: relative; /* anchors the +1 pop */ + /* ... copy .rb-seed's display/padding/border/font shorthand verbatim ... */ } -#ramble .rb-hearts strong { font-size: 0.95rem; color: var(--rb-ink); } #ramble .rb-heart-pop { position: absolute; left: 50%; bottom: 100%; @@ -1676,12 +1914,11 @@ In `bundles/ramble/panel/static/ramble.css`, beside the seed rules: from { opacity: 1; transform: translate(-50%, 0); } to { opacity: 0; transform: translate(-50%, -1.6rem); } } -@media (prefers-reduced-motion: reduce) { - #ramble .rb-heart-pop { animation: none; opacity: 1; } -} ``` -**Note for the implementer:** match the existing seed rules' custom-property names (`--rb-muted`, `--rb-ink` or whatever this stylesheet actually uses) and fold the reduced-motion rule into the file's existing `prefers-reduced-motion` block rather than opening a second one, if there already is one. +**There is no `--rb-ink` in this stylesheet** — the text token is `--rb-text`. Use the tokens `.rb-seed` actually uses. + +**Reduced motion goes in the EXISTING block**, not a new one. `ramble.css:585-596` is one comma-separated selector list ending `#ramble .rb-seed-pop { animation: none; }`; add `#ramble .rb-heart-pop,` to that list. Task 6 Step 1's regex is written to match that form — do not open a second `@media` block to satisfy it. - [ ] **Step 7: Run the tests** @@ -1712,7 +1949,10 @@ git show --stat HEAD **Interfaces:** - Consumes: `energy_max` and `hearts` from `GET /api/ramble/pet`. -**The bug this task exists to not ship:** the bar is currently `width = energy + "%"`, which silently assumes a maximum of 100. With hearts, an energy of 150 would paint a 150% bar and an energy of 70 out of 150 would look nearly full. **The bar must be drawn against the same `energy_max` the server clamps with** — this is phase 1's lesson on its third surface. +**Two bugs this task exists to not ship:** + +1. The bar is currently `width = energy + "%"`, which silently assumes a maximum of 100. With hearts, an energy of 150 would paint a 150% bar and 70 out of 150 would look nearly full. **The bar must be drawn against the same `energy_max` the server clamps with.** +2. **The map-bar heart counter must be painted from HERE too.** `paintPet` already calls `paintSeed(pet.seed)` — that is what keeps the seed counter honest at boot and for a player who denies geolocation, since the area response only carries a wallet when it carried a fix. Add `paintHearts` beside it or a player with three hearts sees the pet page say "3 heart containers" while the map bar reads `0 hearts`. This is phase 1's actual defect #1 in miniature: an affordance added on one path and not its twin. - [ ] **Step 1: Write the failing test** @@ -1725,6 +1965,8 @@ test("the energy bar is drawn against the server's ceiling, not a hardcoded 100" assert.ok(!body.includes('Math.min(100, energy)) + "%"'), "the old hardcoded-100 bar is gone"); assert.ok(body.includes("(energy / max) * 100"), "the bar is a fraction of the real ceiling"); assert.ok(body.includes("function paintHeartRow("), "the pet page shows the containers themselves"); + assert.ok(body.includes("paintHearts(hearts)"), + "the map-bar counter is painted from the pet read too, not only from a position fix"); }); ``` @@ -1773,7 +2015,13 @@ In `bundles/ramble/panel/static/ramble.js`, replace the three energy lines in th if (fill) fill.style.width = Math.max(0, Math.min(100, (energy / max) * 100)) + "%"; setText($("rb-energy-num"), String(energy)); setText($("rb-energy-max"), String(max)); - paintHeartRow(typeof pet.hearts === "number" ? pet.hearts : 0); + var hearts = typeof pet.hearts === "number" ? pet.hearts : 0; + paintHeartRow(hearts, max, pet.energy_max_cap); + /* The map bar too, not only this page: the area response carries a wallet + * ONLY when it carried a position fix, so a player who denies geolocation + * would otherwise read 0 hearts on the map forever. paintSeed is called + * from here for exactly this reason. */ + paintHearts(hearts); setText($("rb-mood-line"), MOOD_LINE[pet.mood] || MOOD_LINE.happy); ``` @@ -1785,7 +2033,7 @@ and add the heart row, near `paintHearts`: * still fits a phone; past that the sentence carries the count. */ var HEART_ROW_MAX = 10; - function paintHeartRow(n) { + function paintHeartRow(n, max, cap) { var row = $("rb-heart-row"); if (!row) return; while (row.firstChild) row.removeChild(row.firstChild); @@ -1800,7 +2048,11 @@ and add the heart row, near `paintHearts`: } var line = $("rb-heart-line"); if (!line) return; - if (n <= 0) setText(line, "No heart containers yet. Walk somewhere new."); + /* At the cap the bar cannot grow again, and saying nothing about that would + * leave the player collecting pips that change no number they can see. */ + if (typeof cap === "number" && typeof max === "number" && n > 0 && max >= cap) { + setText(line, n + " heart containers. The bar is as long as it goes."); + } else if (n <= 0) setText(line, "No heart containers yet. Walk somewhere new."); else if (n === 1) setText(line, "One heart container."); else setText(line, n + " heart containers."); } @@ -1915,7 +2167,7 @@ cd /home/kh0pp/crow-wt-ramble-hearts npm test 2>&1 | tail -25 ``` -Expected: **4301 + the new tests**, 0 fail. A drop below 4301 means something was deleted, not fixed. +Expected: **4301 + the new tests**, 0 fail (4301 is the measured baseline at `200cf834`). A drop below 4301 means something was deleted, not fixed. - [ ] **Confirm no schema change slipped in** @@ -1992,3 +2244,27 @@ Say plainly: what shipped, what a player with 25 unlocked cells will see (a map - D2's sad portrait to contacts, and any change to `servers/sharing/profile-avatar.js` (K4). - Gating the MCP tool surface. Phase 1 left `ramble_query_world` / `ramble_nests` ungated deliberately; hearts add nothing there and this is not the phase to change it. - Fixing phase 1's known follow-ups: `unlockedCellsNear`'s full-table read, the seed cooldown's global UTC bucket, the AR view filtering beacons out, spec §2.4's overclaim about surveying a city remotely. All are recorded in the phase 1 handoff and 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 — six critical issues, all fixed in this document before execution. The reviewer independently confirmed the architecture is sound (`applyRambleWallet` at `servers/sharing/instance-sync.js:600-617` resolves with `MAX(delta)`, all five sync sites registered, `shouldSyncRow` has no `kind` allowlist, so `kind='heart'` with a constant `delta = 1` is safe), verified the scope discipline holds, and measured the statistical test at `share = 0.3293`. + +| # | Issue | Resolution | +|---|---|---| +| 1 | The route passed `cell`, not `out.cell`, so a 2 km wifi fix would collect a retroactive heart from any already-unlocked cell — `recordHeartPickup`'s in-`ramble_cells` check protects new ground only. The plan asserted the opposite twice. | Task 4 passes `out.cell` and explains why the fail-closed check alone is insufficient. New test unlocks sharply with no heart present, then makes the cell hold one, then arrives vaguely. | +| 2 | `heartCandidate` was `heartFor(...) || wildHeartFor(...)`, which short-circuits forever once the permanent heart is taken — at the default rate, one cell in three could never grow a wild heart again, while the doc comment promised it would. | Replaced with `heartCandidates()` returning both sources in priority order; both readers take the first untaken one. New test at `rate: 1, wildRate: 1` — the only configuration that can tell the two implementations apart. | +| 3 | `paintPet` was not calling `paintHearts`, so a player who denies geolocation would see the pet page and the map bar disagree. (Phase 1's real defect #1: an affordance added on one path only.) | Task 7 paints the counter from the pet read as well, mirroring the existing `paintSeed(pet.seed)`, with a source assertion. | +| 4 | The pip CSS was one rule; `mountHeart` sets no width/height and `L.divIcon`'s `iconSize` sizes the wrapper, so the heart would render at the CSS default 300x150. | Two rules, copying `.rb-seed-pip` at `ramble.css:883-884`, plus a test that asserts the `> svg` rule exists. `--rb-ink` (which does not exist) corrected to the tokens `.rb-seed` uses. | +| 5 | The `/zones` heart test reused a cell whose heart the previous test had collected, so `hearts` was always `[]`, the per-pip loop never ran, and an implementation returning `[]` unconditionally passed. | Uses a fresh cell (London, `gcpvj0d`) and asserts `hearts.length >= 1` before iterating; also walks there and asserts the pip is collected and disappears. | +| 6 | The "pinned regression vector" was all misses — the reviewer ran the generator and got `- -` for all six cells — so every assertion reduced to `false === false`. | Generator rewritten to search for confirmed hits on each salt; the pin now records positions to six decimals as well as presence, and asserts the hit lists are non-empty. | + +**Suggestions adopted:** `Bird` (not `engine`) in the bird-svg test plus its two dropped invariants; the reduced-motion contradiction resolved in favour of the existing comma-separated block, with the test regex rewritten to match; `deepEqual` for the junk-rate fallback (the lucky-hash trap, in the file that warns about it); `availableHearts` no longer re-scans `ramble_cells` and instead asks only about the cells it was given, chunked against the bind limit; the convergence test replaced with one that would actually fail (`energy.max.per.heart = 25`, assert `delta` is still `1`); `energy.max.cap` given a designed UX rather than only a number; the double `sayMoment` on a first unlock that also pays a heart collapsed into one line; `bundles/ramble/server/server.js`'s `ramble_pet_state` added to the file list so the MCP tool and the HTTP route agree; `warmth.visit_place` suppression carried into the new panel tests; the branch rebased onto `200cf834` and the 4301 baseline re-measured there. + +**Correction accepted:** the plan had repeatedly attributed a shipped map/payout defect to phase 1. Phase 1's three blocking defects were a retired UI element carrying another affordance, fog blanking existing users, and a non-convergent sync apply; the map/payout gate was added during 0.9.5's seed-sparsity work, not after a shipped bug. The constraint still stands, but the plan now names the right lesson — and issues 1 and 3 above were both instances of phase 1's *actual* defect pattern, which is why the correction mattered. + +**Open questions answered:** a cell does grow a wild heart after its permanent one is taken (issue 2); the cap now has designed copy on both the moment and the pet page (issue 2 in the reviewer's list of questions); `heart.rate` stays at the spec's 3, with the density reasoning recorded above and flagged for the PR. From 7810daa3a2d5912d1fdfff3de49d2baf6e8cd9e9 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 10:47:33 -0500 Subject: [PATCH 03/15] plan: second review, four more findings folded in --- .../plans/2026-09-09-ramble-hearts-phase2.md | 146 +++++++++++++----- 1 file changed, 111 insertions(+), 35 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md index 5c38bcef..f46fb608 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md +++ b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md @@ -422,7 +422,10 @@ export async function readHeartSettings(db) { if (r.key === "heart.wild.days" && n >= 1) out.wildDays = n; if (r.key === "heart.wild.rate" && n >= 1) out.wildRate = n; if (r.key === "energy.max.base" && n >= 1) out.energyBase = n; - if (r.key === "energy.max.per.heart" && n >= 0) out.perHeart = n; + // >= 1, not >= 0: a zero would make every heart inert while the pet page + // still counted them, and (with the cap normalized up to the base below) + // would announce "the bar is as long as it goes" from the very first one. + if (r.key === "energy.max.per.heart" && n >= 1) out.perHeart = n; if (r.key === "energy.max.cap" && n >= 1) out.cap = n; } } catch { /* defaults */ } @@ -540,6 +543,8 @@ git show --stat HEAD 2. **Fail closed on unlock.** `recordHeartPickup` verifies the cell is in `ramble_cells` itself. A fix that the `unlock.max.accuracy.m` gate refused must never pay a heart in ground the user did not enter, and the route is not trusted to check. 3. **`delta` is the constant `1`.** Never `perHeart`, never a spend. +**Ruling: a cell holding BOTH a permanent and a wild heart pays them on two successive posts, and that is allowed.** `recordHeartPickup` grants one candidate per call and the panel posts an area every ~75 m of movement, so a player standing in such a cell collects two hearts seconds apart. It needs both sources to hit at once — roughly one cell in 120 at the defaults — and suppressing it would mean per-visit state this design does not have. It reads as a windfall, not a glitch. Do not add a "one heart per visit" guard. + **Why reading every heart row is fine here** (and why `harvestableCells` had to be cleverer for seed): a heart row exists only per heart actually *taken*. Firsts are capped by the player's unlocked-cell count at 1-in-3, and wilds are 1-in-40 per 30-day window. The table's heart slice is the player's lifetime collection — dozens of rows, not the roughly-one-per-cell-per-day the seed ledger accrues. - [ ] **Step 1: Write the failing test** @@ -641,6 +646,8 @@ test("the permanent heart is gone for good — a second visit pays nothing, ever assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: NOW }), { picked: false, amount: 0 }); const muchLater = NOW + 400 * 24 * 3600 * 1000; + assert.deepEqual(heartCandidates(CELLS[0], wildWindow(muchLater, 30), { rate: 1, wildRate: 999999 }) + .map((c) => c.source), ["first"], "precondition: the wild source is silent, even 400 days out"); assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: muchLater }), { picked: false, amount: 0 }); assert.equal((await walletRows(db)).length, 1); }); @@ -1083,9 +1090,19 @@ test("lowering a setting clamps a bird that is already over the new ceiling, on }); const pet = await petState(db, { now: 1000 }); assert.equal(pet.energy_max, 110); - assert.equal(pet.energy, 110, "an over-ceiling bird is brought down to the new bar"); + assert.equal(pet.energy, 110, "an over-ceiling bird READS as the new bar"); + // ⚠ and is NOT written down. Sync applies ramble_pet before ramble_wallet, so + // a synced-in 150 would otherwise be truncated to 100 in the window before + // this instance's heart rows land — an unrecoverable loss. The stored value + // waits for the ledger; only decay writes. const row = (await db.execute({ sql: "SELECT energy FROM ramble_pet WHERE owner = 'self'", args: [] })).rows[0]; - assert.equal(Number(row.energy), 110, "and it is persisted, so the next read does not redo it"); + assert.equal(Number(row.energy), 150, "the stored value survives a ceiling that dropped underneath it"); + + // Put the setting back and the energy is still there, not lost. + await db.execute({ + sql: "UPDATE ramble_settings SET value = '10' WHERE key = 'energy.max.per.heart'", args: [], + }); + assert.equal((await petState(db, { now: 1000 })).energy, 150, "nothing was destroyed on the way"); }); test("every pet shape carries the same energy_max — feed, chore, and the no-op chore", async () => { @@ -1151,7 +1168,9 @@ and add `energy_max: max` to the object `feed()` returns: return { owner: "self", mood, energy, energy_max: max, places_week, unlocks_week, crows_week, week_start, last_fed_at }; ``` -4. In `petState()`, clamp against the ceiling — including when the ceiling has *moved down* under a bird that is already over it, which a settings change can do: +4. In `petState()`, clamp against the ceiling — including when the ceiling has *moved down* under a bird that is already over it, which a settings change can do. + +**⚠ Clamp the REPORTED value; persist only what decay changed.** `SYNCED_TABLES` in `servers/sharing/instance-sync.js` applies `ramble_settings` and `ramble_pet` **before** `ramble_wallet`. On a pairing or backfill, a synced pet row at energy 150 lands while this instance still has no heart rows, so `maxEnergy` reads 100 — and if the clamp were persisted, the panel's next poll would write 150 down to 100 permanently. The heart rows arrive moments later and restore the ceiling, but the energy is gone and nothing can bring it back. Persisting only the decay write keeps the stored value intact until the ledger catches up, and the read is clamped either way, so the player never sees an over-long bar: ```js export async function petState(db, { now = Date.now() } = {}) { @@ -1159,9 +1178,8 @@ export async function petState(db, { now = Date.now() } = {}) { const max = await maxEnergy(db); let energy = row.energy; - let mood = row.mood; let last_fed_at = row.last_fed_at; - let dirty = false; + let decayed = false; if (last_fed_at != null) { const elapsed = now - last_fed_at; @@ -1169,22 +1187,24 @@ export async function petState(db, { now = Date.now() } = {}) { const intervals = Math.floor(elapsed / DECAY_INTERVAL_MS); energy = energy - intervals * DECAY_PER_INTERVAL; last_fed_at = now; - dirty = true; + decayed = true; } } - // Also catches a bird sitting ABOVE a ceiling that just moved down, which - // `energy.max.per.heart` or `energy.max.cap` can do at any time. - const clamped = clampEnergy(energy, max); - if (clamped !== row.energy) dirty = true; - energy = clamped; - mood = moodFor(energy); - - if (dirty) { + // Clamped for the CALLER, including a bird sitting above a ceiling that just + // moved down. Deliberately NOT persisted on its own: sync applies ramble_pet + // before ramble_wallet, so a synced-in 150 would be written down to 100 in + // the window before this instance's heart rows arrive, and that loss is + // permanent. Only decay writes. + const energyOut = clampEnergy(energy, max); + const mood = moodFor(energyOut); + + if (decayed) { await db.execute({ sql: "UPDATE ramble_pet SET energy = ?, mood = ?, last_fed_at = ? WHERE owner = 'self'", - args: [energy, mood, last_fed_at], + args: [energyOut, mood, last_fed_at], }); } + energy = energyOut; return { mood, @@ -1271,7 +1291,7 @@ git show --stat HEAD **Interfaces:** - Consumes: `recordHeartPickup`, `availableHearts`, `heartsBalance`, `maxEnergy` from `hearts.js`. - Produces, for Tasks 6-7: - - `POST /api/ramble/area` with a fix gains `heart_picked: 1` (only when one was taken) plus `hearts`, `energy_max` and `energy_max_cap` (all three only when the post carried `here`). + - `POST /api/ramble/area` with a fix gains `heart_picked: 1` and `heart_source: "first" | "wild"` (only when one was taken) plus `hearts`, `energy_max` and `energy_max_cap` (all three only when the post carried `here`). - `GET /api/ramble/zones?pips=1` gains `hearts: [{ cell, key, source, lat, lon }]`. - `GET /api/ramble/pet` gains `hearts` and `energy_max_cap`; `energy_max` already rides in from `petState`. @@ -1340,6 +1360,8 @@ test("POST /api/ramble/area grants a heart on a first unlock, and reports the ne assert.equal(first.energy_max, before.energy_max + 10, "the bar grew by energy.max.per.heart"); const again = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"], here } }); + // Asserted, not assumed, exactly like the ledger tests: this only holds + // because the wild source is silenced at rate 999999. assert.equal(again.heart_picked, undefined, "a permanent heart is taken once, ever"); assert.equal(again.hearts, first.hearts, "the count still rides on every fix"); assert.equal(again.energy_max, first.energy_max); @@ -1370,8 +1392,12 @@ test("a vague fix cannot harvest a heart from ground unlocked LONG AGO", async ( await withHeartSettings( [["heart.rate", "999999"], ["heart.wild.rate", "999999"], ["unlock.max.accuracy.m", "100"], ["warmth.visit_place", "0"]], async () => { + // ⚠ NO `cells: []` — routes.js rejects an empty array with a 400, and a + // 400 would make the assertions below pass vacuously against the buggy + // implementation. Omitting `cells` entirely is the supported form: the + // route falls back to lat/lon, exactly as walkTo() does. const sharp = await jsonOf("/api/ramble/area", { - method: "POST", body: { cells: [], here: { ...here, accuracy_m: 10 } }, + method: "POST", body: { ...here, here: { ...here, accuracy_m: 10 } }, }); assert.ok(sharp.unlocked, "precondition: the cell is unlocked, and held no heart"); }, @@ -1379,7 +1405,7 @@ test("a vague fix cannot harvest a heart from ground unlocked LONG AGO", async ( await withHeartSettings(HEARTS_ON, async () => { const before = (await jsonOf("/api/ramble/pet")).hearts; const vague = await jsonOf("/api/ramble/area", { - method: "POST", body: { cells: [], here: { ...here, accuracy_m: 2000 } }, + method: "POST", body: { ...here, here: { ...here, accuracy_m: 2000 } }, }); assert.equal(vague.heart_picked, undefined, "a 2 km fix must not collect the heart now waiting in already-unlocked ground"); @@ -1394,23 +1420,30 @@ test("POST /api/ramble/area WITHOUT `here` keeps its exact historical shape", as }); test("GET /api/ramble/zones?pips=1 draws hearts only in unlocked ground", async () => { - // ⚠ A FRESH cell, and a length assertion BEFORE the loop. The first draft of - // this test reused Austin, whose only heart the previous test had already - // collected, so `hearts` was always [] and the per-pip loop never ran once — - // an implementation returning [] unconditionally passed it. + // ⚠ A GENUINELY FRESH cell, and a length assertion BEFORE the loop. Two + // earlier drafts got this wrong: the first reused Austin, whose heart the + // previous test had already collected, so `hearts` was always [] and the loop + // never ran; the second reused London, which is HERE_LAT/HERE_LON's own cell + // (`gcpvj0d`) and is walked twice by the visit_place test — that draft passed + // only because heartFor("gcpvj0d", {rate: 3}) happens to miss, which is the + // lucky-hash dependency this plan bans. + // + // wecnrmd = 22.2233/114.2283, Hong Kong. No test in this file uses a latitude + // anywhere near it (they use 10.5, 30.46, 48.8584 and 51.5074). await withHeartSettings(HEARTS_ON, async () => { const db = createDbClient(); try { await db.execute({ - sql: "INSERT INTO ramble_cells (cell, first_unlocked_at) VALUES ('gcpvj0d', 1) ON CONFLICT(cell) DO NOTHING", + sql: "INSERT INTO ramble_cells (cell, first_unlocked_at) VALUES ('wecnrmd', 1) ON CONFLICT(cell) DO NOTHING", args: [], - }); // gcpvj0d = 51.5073/-0.1284, London: untouched by every other test here + }); } finally { db.close(); } - const bbox = "51.49,-0.15,51.52,-0.11"; + const bbox = "22.21,114.21,22.24,114.25"; const withPips = await jsonOf("/api/ramble/zones?bbox=" + bbox + "&pips=1"); assert.ok(Array.isArray(withPips.hearts), "the field is always an array"); - assert.ok(withPips.hearts.length >= 1, "there IS a heart to draw, or this test proves nothing"); + assert.ok(withPips.hearts.some((h) => h.cell === "wecnrmd"), + "there IS a heart to draw, or this test proves nothing"); for (const h of withPips.hearts) { assert.ok(withPips.unlocked.some((b) => h.lat >= b.south && h.lat <= b.north && h.lon >= b.west && h.lon <= b.east), @@ -1441,7 +1474,7 @@ test("a heart in ground unlocked before this feature existed waits on the map, a // And walking there really does collect the pip the map just drew. const before = (await jsonOf("/api/ramble/pet")).hearts; const walked = await jsonOf("/api/ramble/area", { - method: "POST", body: { cells: [], here: { lat: 52.5181, lon: 13.4081, accuracy_m: 15 } }, + method: "POST", body: { lat: 52.5181, lon: 13.4081, here: { lat: 52.5181, lon: 13.4081, accuracy_m: 15 } }, }); assert.equal(walked.heart_picked, 1, "the pip the map drew is the heart the walk grants"); assert.equal(walked.hearts, before + 1); @@ -1499,6 +1532,7 @@ Inside the existing `if (here) { ... }` block, after the seed branch: let unlockedNow = null; let seedPicked = 0; let heartPicked = 0; + let heartSource = null; if (here) { const cell = mods.anchorsMod.encodeGeohash(here.lat, here.lon, 7); await feedActivity({ type: "visit_place", cell }); @@ -1520,7 +1554,11 @@ Inside the existing `if (here) { ... }` block, after the seed branch: // ground that is already unlocked, which is most of the ground that still // holds a heart. if (out.cell) { - heartPicked = (await mods.heartsMod.recordHeartPickup(db, out.cell, { now: Date.now(), emit })).amount; + const got = await mods.heartsMod.recordHeartPickup(db, out.cell, { now: Date.now(), emit }); + heartPicked = got.amount; + // The panel says a different line for a once-ever heart and one that + // regrew, so the source has to survive the trip. + heartSource = got.source || null; } } ``` @@ -1532,7 +1570,7 @@ and the response, keeping every new field inside the `here` branch: cells, ...(unlockedNow ? { unlocked: unlockedNow } : {}), ...(seedPicked ? { seed_picked: seedPicked } : {}), - ...(heartPicked ? { heart_picked: heartPicked } : {}), + ...(heartPicked ? { heart_picked: heartPicked, heart_source: heartSource } : {}), ...(here ? { seed: await mods.walletMod.seedBalance(db) } : {}), ...(here ? { hearts: await mods.heartsMod.heartsBalance(db), @@ -1842,7 +1880,7 @@ Directly after `paintSeed`: * this does both: the number pops, and the bird speaks. sayMoment is the only * thing that opens the bubble on its own, and an arrival is exactly what it * is for. */ - function celebrateHeart(alsoUnlocked, atCap) { + function celebrateHeart(alsoUnlocked, source, atCap) { var chip = $("rb-heart-count"); if (chip && chip.parentNode) { var pop = document.createElement("span"); @@ -1858,6 +1896,7 @@ Directly after `paintSeed`: * the thing that covers both. */ if (atCap) sayMoment("Another heart container. Your bird is as strong as it gets."); else if (alsoUnlocked) sayMoment("New ground, and a heart container in it."); + else if (source === "wild") sayMoment("A heart container, grown here since you last came by."); else sayMoment("A heart container. Your bird can hold more now."); } @@ -1868,16 +1907,34 @@ Directly after `paintSeed`: } ``` -and in the area-response handler, immediately after the two existing seed lines (`if (out && typeof out.seed === "number") paintSeed(out.seed);` and the `out.seed_picked` line): +and in the area-response handler. The existing lines are `static/ramble.js:419` (`paintSeed`), `:420` (`celebrateUnlock`) and `:423` (`seed_picked`) — **insert after line 423**, and rewrite that line as shown: ```js if (out && typeof out.hearts === "number") paintHearts(out.hearts); if (out && out.heart_picked) { - celebrateHeart(!!out.unlocked, out.energy_max === out.energy_max_cap); - if (!out.unlocked) refreshZones(); + celebrateHeart(!!out.unlocked, out.heart_source, out.energy_max === out.energy_max_cap); } ``` +and **restructure the seed line rather than adding a second guarded refresh**. The existing line at `static/ramble.js:423` is: + +```js + if (out && out.seed_picked) { celebrateSeed(out.seed_picked); if (!out.unlocked) refreshZones(); } +``` + +Walking back into an already-unlocked cell that holds both a regrown seed and a retroactive heart — the K2 case, on upgrade day — would fire `/zones` twice if the heart branch carried its own copy. One refresh, after both: + +```js + if (out && out.seed_picked) celebrateSeed(out.seed_picked); + if (out && typeof out.hearts === "number") paintHearts(out.hearts); + if (out && out.heart_picked) { + celebrateHeart(!!out.unlocked, out.heart_source, out.energy_max === out.energy_max_cap); + } + /* One /zones fetch however many pips were just consumed. celebrateUnlock + * already refreshed on a first unlock, which is what the guard is for. */ + if (out && (out.seed_picked || out.heart_picked) && !out.unlocked) refreshZones(); +``` + **Why exactly that form.** Taking a heart removes a pip and the pip list comes from `/zones`, so the map does need a refresh — but marks are untouched by a heart, so do **not** call `refreshMarks` (phase 1 shipped a redundant double `refreshMarks` on the unlock path that three adversarial rounds missed; do not add a third). When the pickup rode in on a *first unlock*, `celebrateUnlock` has already called `refreshZones`, so the `!out.unlocked` guard keeps it from firing twice — and it has already spoken, which is why `celebrateHeart` takes the flag and says one combined line rather than silently overwriting "New ground." **This needs one more field from the server.** `energy_max_cap` is the ceiling's ceiling, so the panel can tell "the bar grew" from "the bar is as long as it goes". Add it beside `energy_max` in the `here` branch of `POST /api/ramble/area` and in `GET /api/ramble/pet` (Task 4), reading `(await mods.heartsMod.readHeartSettings(db)).cap`. @@ -2143,7 +2200,7 @@ Expected: both files read `0.10.0`, and `registry/add-ons.json` picks the new ve ```bash node scripts/run-suite.mjs tests/ramble-panel.test.js node scripts/check-port-allocation.js -node scripts/build-registry.js --check +node scripts/build-registry.mjs --check ``` Expected: all pass. This phase adds no port, so `check-ports` is a formality — run it anyway, since CI does. @@ -2268,3 +2325,22 @@ Say plainly: what shipped, what a player with 25 unlocked cells will see (a map **Correction accepted:** the plan had repeatedly attributed a shipped map/payout defect to phase 1. Phase 1's three blocking defects were a retired UI element carrying another affordance, fog blanking existing users, and a non-convergent sync apply; the map/payout gate was added during 0.9.5's seed-sparsity work, not after a shipped bug. The constraint still stands, but the plan now names the right lesson — and issues 1 and 3 above were both instances of phase 1's *actual* defect pattern, which is why the correction mattered. **Open questions answered:** a cell does grow a wild heart after its permanent one is taken (issue 2); the cap now has designed copy on both the moment and the pet page (issue 2 in the reviewer's list of questions); `heart.rate` stays at the spec's 3, with the density reasoning recorded above and flagged for the PR. + +--- + +### Second review (2026-09-09) + +Re-run after the revision, as the process requires. **Verdict: REVISE** — five of the six fixes confirmed real and complete; one was only half-fixed, and the rewrite introduced three defects of its own. All are now addressed. + +| # | Issue | Resolution | +|---|---|---| +| 7 | `cells: []` is a **400** (`routes.js`: `b.cells.length === 0` fails validation), so three of the rewritten panel tests could never pass — and the vague-fix test, the one that proves issue 1's fix, would have passed *vacuously against a 400* if someone deleted its precondition. | All three posts drop `cells` entirely and pass `lat`/`lon`, the supported fallback form `walkTo()` itself uses. | +| 8 | The "fresh" cell chosen for the `/zones` test, `gcpvj0d`, is `HERE_LAT`/`HERE_LON`'s own cell — the visit_place test walks it twice — so the `INSERT` was a no-op and the assertion survived only because `heartFor("gcpvj0d", { rate: 3 })` happens to miss. The lucky-hash dependency this plan bans, reintroduced by the fix for issue 5. | Moved to `wecnrmd` (Hong Kong, 22.2233/114.2283); no test in the file uses a latitude near it. The assertion now names the cell rather than counting. | +| 9 | `petState` persisting a downward clamp loses energy permanently on a sync backfill: `SYNCED_TABLES` applies `ramble_settings` and `ramble_pet` **before** `ramble_wallet`, so a synced-in pet at energy 150 gets written down to 100 in the window before this instance's heart rows arrive. | The clamp is applied to the **reported** value only; the stored value waits for the ledger, and only decay writes. The Task 3 test now asserts the stored 150 survives, and that restoring the setting restores the bar. | +| 10 | `scripts/build-registry.js --check` does not exist — the script is `.mjs`. | Corrected. | + +**Suggestions adopted:** the seed and heart branches now share a single `refreshZones()` (walking into a cell holding both a regrown seed and a retroactive heart is the K2 upgrade-day case and would otherwise fetch `/zones` twice); `heart_source` threaded through the response so a regrown heart gets its own line instead of the once-ever heart's; `energy.max.per.heart` tightened to `>= 1`, since a zero makes every heart inert while the page still counts them; the two unasserted `wildRate: 999999` preconditions added, for consistency with the file's own rule; Task 6's insertion point given as a line number (`static/ramble.js:423`, with `celebrateUnlock` at `:420` sitting between the two seed lines). + +**Ruling recorded:** a cell holding both a permanent and a wild heart pays both on successive posts. It needs both sources to hit at once (~1 cell in 120 at the defaults), and suppressing it would need per-visit state this design does not have. + +**Confirmed by the reviewer, needing no change:** `servers/gateway/dashboard/shared/notifications.js` derives mood with hardcoded 60/30, which is consistent with `moodFor` staying absolute; `opts.html = svg` does not match the markup-sink detector, so `heartIcon` spends neither of the two; `CELL7_LAT_STEP === 2 * latErr` exactly, so a placed heart always falls inside its own `cellBox`; no heart, cell or balance reaches a contact-facing payload; the 400-cell chunk in `unlockedAmong` is well inside SQLite's 32766 bind limit. From 021d37f6e2a0ffce44b055c5fb4af29fb5944726 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 10:55:30 -0500 Subject: [PATCH 04/15] plan: third review, and the energy ceiling becomes asymmetric --- .../plans/2026-09-09-ramble-hearts-phase2.md | 130 +++++++++++------- 1 file changed, 82 insertions(+), 48 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md index f46fb608..461f016a 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md +++ b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md @@ -423,8 +423,7 @@ export async function readHeartSettings(db) { if (r.key === "heart.wild.rate" && n >= 1) out.wildRate = n; if (r.key === "energy.max.base" && n >= 1) out.energyBase = n; // >= 1, not >= 0: a zero would make every heart inert while the pet page - // still counted them, and (with the cap normalized up to the base below) - // would announce "the bar is as long as it goes" from the very first one. + // went on counting them. if (r.key === "energy.max.per.heart" && n >= 1) out.perHeart = n; if (r.key === "energy.max.cap" && n >= 1) out.cap = n; } @@ -1078,31 +1077,47 @@ test("decay still bottoms out at 0 whatever the ceiling is", async () => { assert.equal(pet.mood, "alarmed"); }); -test("lowering a setting clamps a bird that is already over the new ceiling, on read", async () => { +test("a ceiling that drops underneath a bird never destroys its energy — on ANY path", async () => { + // ⚠ This is the sync-window hazard, and it has to be tested on the paths that + // actually run in that window. sync applies ramble_pet BEFORE ramble_wallet, + // so an instance can see a 150-energy bird while it still computes a ceiling + // of 100 — and `POST /api/ramble/area` feeds the pet (visit_place) BEFORE the + // heart pickup runs. A test that only reads petState at a frozen `now` proves + // nothing: no decay interval elapses, so nothing is written at all. const db = await freshDb(); await giveHearts(db, 5); for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); assert.equal((await petState(db, { now: 1000 })).energy, 150); + const stored = async () => Number( + (await db.execute({ sql: "SELECT energy FROM ramble_pet WHERE owner = 'self'", args: [] })).rows[0].energy); + + // The ceiling collapses underneath it — exactly what an un-synced wallet looks like. await db.execute({ sql: "INSERT INTO ramble_settings (key, value) VALUES ('energy.max.per.heart', '2') ON CONFLICT(key) DO UPDATE SET value = excluded.value", args: [], }); - const pet = await petState(db, { now: 1000 }); - assert.equal(pet.energy_max, 110); - assert.equal(pet.energy, 110, "an over-ceiling bird READS as the new bar"); - // ⚠ and is NOT written down. Sync applies ramble_pet before ramble_wallet, so - // a synced-in 150 would otherwise be truncated to 100 in the window before - // this instance's heart rows land — an unrecoverable loss. The stored value - // waits for the ledger; only decay writes. - const row = (await db.execute({ sql: "SELECT energy FROM ramble_pet WHERE owner = 'self'", args: [] })).rows[0]; - assert.equal(Number(row.energy), 150, "the stored value survives a ceiling that dropped underneath it"); - - // Put the setting back and the energy is still there, not lost. - await db.execute({ - sql: "UPDATE ramble_settings SET value = '10' WHERE key = 'energy.max.per.heart'", args: [], - }); - assert.equal((await petState(db, { now: 1000 })).energy, 150, "nothing was destroyed on the way"); + assert.equal(await maxEnergy(db), 110); + + // Path 1: a plain read writes nothing and reports the truth. + assert.equal((await petState(db, { now: 1000 })).energy, 150); + assert.equal(await stored(), 150, "a read never truncates"); + + // Path 2: FEEDING, which is what an area post does before the hearts arrive. + const fed = await feed(db, { type: "visit_place" }, { now: 1000 }); + assert.equal(fed.energy, 150, "an addition stops at the ceiling but never pulls the bird DOWN to it"); + assert.equal(await stored(), 150); + + // Path 3: DECAY, which is the other write. It reduces by the elapsed + // intervals, not by snapping to the ceiling. + const six = 6 * 60 * 60 * 1000; + assert.equal((await petState(db, { now: 1000 + 2 * six })).energy, 130, "two intervals, 20 energy"); + assert.equal(await stored(), 130); + + // And when the ledger catches up, the bar is long again with nothing lost. + await db.execute({ sql: "UPDATE ramble_settings SET value = '10' WHERE key = 'energy.max.per.heart'", args: [] }); + assert.equal(await maxEnergy(db), 150); + assert.equal((await petState(db, { now: 1000 + 2 * six })).energy, 130, "nothing was destroyed on the way"); }); test("every pet shape carries the same energy_max — feed, chore, and the no-op chore", async () => { @@ -1139,17 +1154,31 @@ Four edits, all inside `bundles/ramble/server/pet.js`: import { maxEnergy, ENERGY_MAX_BASE_DEFAULT } from "./hearts.js"; ``` -2. Replace `clampEnergy`: +2. Replace `clampEnergy`. **The ceiling stops additions; it never pulls down a value already above it.** + +That asymmetry is the whole fix, and it is not a nicety. `SYNCED_TABLES` in `servers/sharing/instance-sync.js` applies `ramble_settings` and `ramble_pet` **before** `ramble_wallet`, so during a pairing or backfill this instance can briefly compute a ceiling of 100 for a bird that legitimately sits at 150 on the instance that owns the heart rows. A symmetric clamp writes that 150 down to 100 — on the very next `/api/ramble/area` post, because `feedActivity({ type: "visit_place" })` runs before the heart pickup — and last-writer-wins then carries the loss *back* to the instance it came from. Nothing can restore it. Spec §7: nothing in this design destroys player progress. + +A guard on `petState` alone is not enough and was rejected for that reason: `feed`, `doChore` and `eggs.js`'s hatch path all persist energy too, and the area post is a far hotter path than a `/pet` poll. Putting the asymmetry in the one function every writer already goes through closes all of them at once. ```js /** * The ceiling is DERIVED from the heart ledger (spec §3, D6), so it is passed - * in rather than read here — every caller has already fetched it once and a + * in rather than read here — every caller has already fetched it once, and a * second read would risk clamping against a different number than the one the * panel is about to draw. + * + * ⚠ ASYMMETRIC, DELIBERATELY. An addition stops at the ceiling, but the + * ceiling NEVER reduces a value that is already above it. Sync applies + * ramble_pet before ramble_wallet, so a bird that is legitimately at 150 can + * be seen by an instance that has not yet received the heart rows and computes + * a ceiling of 100. A symmetric clamp would write the 150 down, last-writer- + * wins would propagate the loss back, and no later arrival could undo it. + * Decay still brings an over-ceiling bird down normally — it just is not the + * ceiling that does it. */ -function clampEnergy(v, max) { - return Math.max(0, Math.min(Number.isFinite(max) ? max : ENERGY_MAX_BASE_DEFAULT, v)); +function clampEnergy(next, previous, max) { + const ceiling = Math.max(Number.isFinite(max) ? max : ENERGY_MAX_BASE_DEFAULT, previous || 0); + return Math.max(0, Math.min(ceiling, next)); } ``` @@ -1158,7 +1187,7 @@ function clampEnergy(v, max) { ```js const delta = FEED_DELTAS[type]; const max = await maxEnergy(db); - const energy = clampEnergy(row.energy + delta, max); + const energy = clampEnergy(row.energy + delta, row.energy, max); const mood = moodFor(energy); ``` @@ -1168,9 +1197,7 @@ and add `energy_max: max` to the object `feed()` returns: return { owner: "self", mood, energy, energy_max: max, places_week, unlocks_week, crows_week, week_start, last_fed_at }; ``` -4. In `petState()`, clamp against the ceiling — including when the ceiling has *moved down* under a bird that is already over it, which a settings change can do. - -**⚠ Clamp the REPORTED value; persist only what decay changed.** `SYNCED_TABLES` in `servers/sharing/instance-sync.js` applies `ramble_settings` and `ramble_pet` **before** `ramble_wallet`. On a pairing or backfill, a synced pet row at energy 150 lands while this instance still has no heart rows, so `maxEnergy` reads 100 — and if the clamp were persisted, the panel's next poll would write 150 down to 100 permanently. The heart rows arrive moments later and restore the ceiling, but the energy is gone and nothing can bring it back. Persisting only the decay write keeps the stored value intact until the ledger catches up, and the read is clamped either way, so the player never sees an over-long bar: +4. In `petState()`, apply decay and report the ceiling. With the asymmetry living in `clampEnergy`, this needs no special case of its own — decay reduces, which is always allowed, and the ceiling can no longer take anything away: ```js export async function petState(db, { now = Date.now() } = {}) { @@ -1185,26 +1212,21 @@ export async function petState(db, { now = Date.now() } = {}) { const elapsed = now - last_fed_at; if (elapsed >= DECAY_INTERVAL_MS) { const intervals = Math.floor(elapsed / DECAY_INTERVAL_MS); - energy = energy - intervals * DECAY_PER_INTERVAL; + // `row.energy` as the floor argument, so an over-ceiling bird decays by + // exactly the intervals elapsed rather than snapping to the ceiling. + energy = clampEnergy(energy - intervals * DECAY_PER_INTERVAL, row.energy, max); last_fed_at = now; decayed = true; } } - // Clamped for the CALLER, including a bird sitting above a ceiling that just - // moved down. Deliberately NOT persisted on its own: sync applies ramble_pet - // before ramble_wallet, so a synced-in 150 would be written down to 100 in - // the window before this instance's heart rows arrive, and that loss is - // permanent. Only decay writes. - const energyOut = clampEnergy(energy, max); - const mood = moodFor(energyOut); + const mood = moodFor(energy); if (decayed) { await db.execute({ sql: "UPDATE ramble_pet SET energy = ?, mood = ?, last_fed_at = ? WHERE owner = 'self'", - args: [energyOut, mood, last_fed_at], + args: [energy, mood, last_fed_at], }); } - energy = energyOut; return { mood, @@ -1220,7 +1242,7 @@ export async function petState(db, { now = Date.now() } = {}) { } ``` -5. `petFromRow` takes the ceiling as a second argument: +5. `petFromRow` takes the ceiling as a second argument. It reports `energy` as stored, unclamped, for the same reason — it is a read: ```js export function petFromRow(row, energyMax) { @@ -1360,9 +1382,10 @@ test("POST /api/ramble/area grants a heart on a first unlock, and reports the ne assert.equal(first.energy_max, before.energy_max + 10, "the bar grew by energy.max.per.heart"); const again = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"], here } }); - // Asserted, not assumed, exactly like the ledger tests: this only holds - // because the wild source is silenced at rate 999999. + // Holds only because HEARTS_ON silences the wild source at rate 999999 — + // otherwise the same cell could pay a second, regrown heart. assert.equal(again.heart_picked, undefined, "a permanent heart is taken once, ever"); + assert.equal(first.heart_source, "first", "and the source rides along, so the panel can say the right line"); assert.equal(again.hearts, first.hearts, "the count still rides on every fix"); assert.equal(again.energy_max, first.energy_max); }); @@ -1775,6 +1798,8 @@ test("the map draws heart pips, counts them, and says something when one is take assert.ok(body.includes("rb-heart-dot"), "and a plain dot survives the engine failing to load"); assert.ok(body.includes("paintHeartPips(out.hearts || [])"), "fed from the server's own list"); assert.ok(body.includes("out.heart_picked"), "the pickup is consumed from the area response"); + assert.ok(body.includes("out.heart_source"), "and a regrown heart gets its own line, not the once-ever one's"); + assert.ok(body.includes("grown here since you last came by"), "the wild heart's copy is actually there"); assert.ok(body.includes("function paintHearts("), "the counter is painted from the area response"); assert.equal(body.split("`").length - 1, 0, "the panel client must contain ZERO backticks"); }); @@ -1909,14 +1934,7 @@ Directly after `paintSeed`: and in the area-response handler. The existing lines are `static/ramble.js:419` (`paintSeed`), `:420` (`celebrateUnlock`) and `:423` (`seed_picked`) — **insert after line 423**, and rewrite that line as shown: -```js - if (out && typeof out.hearts === "number") paintHearts(out.hearts); - if (out && out.heart_picked) { - celebrateHeart(!!out.unlocked, out.heart_source, out.energy_max === out.energy_max_cap); - } -``` - -and **restructure the seed line rather than adding a second guarded refresh**. The existing line at `static/ramble.js:423` is: +**Restructure the seed line rather than adding a second guarded refresh.** The existing line at `static/ramble.js:423` is: ```js if (out && out.seed_picked) { celebrateSeed(out.seed_picked); if (!out.unlocked) refreshZones(); } @@ -2339,8 +2357,24 @@ Re-run after the revision, as the process requires. **Verdict: REVISE** — five | 9 | `petState` persisting a downward clamp loses energy permanently on a sync backfill: `SYNCED_TABLES` applies `ramble_settings` and `ramble_pet` **before** `ramble_wallet`, so a synced-in pet at energy 150 gets written down to 100 in the window before this instance's heart rows arrive. | The clamp is applied to the **reported** value only; the stored value waits for the ledger, and only decay writes. The Task 3 test now asserts the stored 150 survives, and that restoring the setting restores the bar. | | 10 | `scripts/build-registry.js --check` does not exist — the script is `.mjs`. | Corrected. | -**Suggestions adopted:** the seed and heart branches now share a single `refreshZones()` (walking into a cell holding both a regrown seed and a retroactive heart is the K2 upgrade-day case and would otherwise fetch `/zones` twice); `heart_source` threaded through the response so a regrown heart gets its own line instead of the once-ever heart's; `energy.max.per.heart` tightened to `>= 1`, since a zero makes every heart inert while the page still counts them; the two unasserted `wildRate: 999999` preconditions added, for consistency with the file's own rule; Task 6's insertion point given as a line number (`static/ramble.js:423`, with `celebrateUnlock` at `:420` sitting between the two seed lines). +**Suggestions adopted:** the seed and heart branches now share a single `refreshZones()` (walking into a cell holding both a regrown seed and a retroactive heart is the K2 upgrade-day case and would otherwise fetch `/zones` twice); `heart_source` threaded through the response so a regrown heart gets its own line instead of the once-ever heart's; `energy.max.per.heart` tightened to `>= 1`, since a zero makes every heart inert while the page still counts them; the unasserted `wildRate: 999999` precondition added at the one site that lacked a real assertion, for consistency with the file's own rule; Task 6's insertion point given as a line number (`static/ramble.js:423`, with `celebrateUnlock` at `:420` sitting between the two seed lines). **Ruling recorded:** a cell holding both a permanent and a wild heart pays both on successive posts. It needs both sources to hit at once (~1 cell in 120 at the defaults), and suppressing it would need per-visit state this design does not have. **Confirmed by the reviewer, needing no change:** `servers/gateway/dashboard/shared/notifications.js` derives mood with hardcoded 60/30, which is consistent with `moodFor` staying absolute; `opts.html = svg` does not match the markup-sink detector, so `heartIcon` spends neither of the two; `CELL7_LAT_STEP === 2 * latErr` exactly, so a placed heart always falls inside its own `cellBox`; no heart, cell or balance reaches a contact-facing payload; the 400-cell chunk in `unlockedAmong` is well inside SQLite's 32766 bind limit. + +--- + +### Third review (2026-09-09, scoped to the second round's fixes) + +Five of the eight round-3 changes verified clean against the code — the `cells`-less POST bodies (`routes.js` falls back to `encodeGeohash(requireLat(b.lat), requireLon(b.lon), defaultPrecision())`, which is what `walkTo` itself posts), `wecnrmd` genuinely untouched with a guaranteed hit at `rate: 1` rather than a lucky one, the shared `refreshZones()` (no path consumes a pip without refreshing, none refreshes twice, and `celebrateSeed` does not call `sayMoment` so there is no bubble collision), `celebrateHeart`'s argument order at both call sites, and `build-registry.mjs`. + +**One critical, and it invalidated the round-2 fix rather than refining it.** Guarding `petState` alone was incoherent: `feed`, `doChore` and `eggs.js`'s hatch path all persist energy too, and `POST /api/ramble/area` calls `feedActivity({ type: "visit_place" })` **before** the heart pickup — a far hotter path than a `/pet` poll. The reviewer also found the round-2 version still wrote the ceiling-clamped value on the decay branch, so it did not implement the invariant its own comment claimed. + +**Resolution — the asymmetry moved into `clampEnergy`, which every writer already goes through.** An addition stops at the ceiling; the ceiling never reduces a value already above it. Decay reduces normally, because decay is not the ceiling. One function, no new state, and the whole class of loss is closed on every path at once rather than one caller at a time. + +The round-2 test could not have detected either hole: it froze `now`, so no decay interval elapsed and nothing was written at all, and it never fed after lowering the ceiling. It now exercises all three paths — read, feed, decay — and asserts the stored value at each, then restores the setting and confirms nothing was destroyed on the way. + +**Also fixed:** a stale "two preconditions" claim in the second review's own record (only one real assertion was added; the other site got a comment attached to a pre-existing assertion); a false second clause in the `energy.max.per.heart >= 1` comment (at the defaults `atCap` is false when `perHeart` is 0, so the "as long as it goes" consequence needs `cap <= base` too); a superseded snippet left standing immediately above its own replacement, which an implementer could have applied and stopped; and `heart_source`, which was threaded but asserted nowhere — now covered in both the route test and the panel-source test, including the wild heart's copy. + +**⚠ For the Task 3 reviewer and the whole-branch review:** `clampEnergy`'s asymmetry is a redesign made after the last full plan review, not a patch that one vetted. Check it directly — every caller, and what each one persists. From bbe586bf226afb18b95dfa63826ea706f388642e Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 10:58:35 -0500 Subject: [PATCH 05/15] ramble: where a heart container is, decided by the cell --- bundles/ramble/server/hearts.js | 147 +++++++++++++++++++++++ tests/ramble-hearts.test.js | 201 ++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 bundles/ramble/server/hearts.js create mode 100644 tests/ramble-hearts.test.js diff --git a/bundles/ramble/server/hearts.js b/bundles/ramble/server/hearts.js new file mode 100644 index 00000000..b547a4c0 --- /dev/null +++ b/bundles/ramble/server/hearts.js @@ -0,0 +1,147 @@ +/** + * Heart containers (spec 2026-09-08 §2.3, §3, D6). + * + * The rare currency. A heart raises the bird's MAXIMUM energy and does nothing + * else — the Zelda mapping D6 names: seed buys gear, hearts only extend the + * bar. There is no spend path in this phase. + * + * WHERE A HEART IS is a hash of the cell, copying nestFor and seedFor: the + * answer is identical on every one of the user's devices with nothing stored + * and nothing to sync, and it cannot be re-rolled by leaving and coming back. + * Two independent sources, two independent salts: + * + * - the FIRST heart, keyed by the bare cell, once ever, roughly 1 in + * `heart.rate` cells. This is the one a new unlock can surprise you with. + * - a WILD heart, keyed `cell:window`, in ground already unlocked, roughly + * 1 in `heart.wild.rate` cells per `heart.wild.days` window. §2.3 accepts + * that this makes maximum energy grindable by a heavy walker: it is not + * competitive power, only a longer buffer, and it lets someone who cannot + * range far still progress. + * + * ⚠ ONE RULE, TWO READERS. `heartCandidates` is the ONLY place that decides + * whether a cell holds a heart. The map (availableHearts) and the payout + * (recordHeartPickup) both go through it. Phase 1 shipped a seed layer where + * the map and the payout disagreed and had to fix it; this module exists in + * this shape so that cannot happen again. + * + * ⚠ A HEART ROW'S `delta` IS ALWAYS 1 — a count of containers, never an + * energy amount. applyRambleWallet resolves conflicts with MAX(delta), which + * is only convergent when the value cannot differ between instances for the + * same key; a constant cannot disagree. Storing energy would also freeze + * `energy.max.per.heart` into history and make retuning it a no-op. + */ +import { createHash } from "node:crypto"; +import { CELL7_RE } from "./nests.js"; +import { decodeGeohash } from "./anchors.js"; + +export const HEART_KIND = "heart"; +export const HEART_SALT = "ramble-heart-v1:"; +export const HEART_WILD_SALT = "ramble-heart-wild-v1:"; + +export const HEART_RATE_DEFAULT = 3; +export const HEART_WILD_DAYS_DEFAULT = 30; +export const HEART_WILD_RATE_DEFAULT = 40; +export const ENERGY_MAX_BASE_DEFAULT = 100; +export const ENERGY_MAX_PER_HEART_DEFAULT = 10; +export const ENERGY_MAX_CAP_DEFAULT = 300; + +/** Which regrowth window `now` falls in. Same cell, same window = one wild heart. */ +export function wildWindow(now, days) { + const d = Number.isFinite(Number(days)) && Number(days) >= 1 ? Number(days) : HEART_WILD_DAYS_DEFAULT; + return Math.floor(Number(now) / (d * 24 * 3600 * 1000)); +} + +/** + * The shared body: hash `salt + material`, keep 1 in `rate`, and place the + * result at a hash-derived point INSIDE the cell rather than at its centre, so + * a street's worth of hearts does not line up like a pegboard. + */ +function place(salt, material, rate, fallbackRate, cell, key, source) { + if (typeof cell !== "string" || !CELL7_RE.test(cell)) return null; + const r = Number.isInteger(rate) && rate >= 1 ? rate : fallbackRate; + const h = createHash("sha256").update(salt + material).digest(); + if (h.readUInt32BE(0) % r !== 0) return null; + let c; + try { c = decodeGeohash(cell); } catch { return null; } + if (!c || !Number.isFinite(c.lat) || !Number.isFinite(c.lon)) return null; + const fy = h.readUInt32BE(4) / 0x100000000; + const fx = h.readUInt32BE(8) / 0x100000000; + return { + cell, key, source, + lat: c.lat - c.latErr + fy * 2 * c.latErr, + lon: c.lon - c.lonErr + fx * 2 * c.lonErr, + }; +} + +/** The once-ever heart in this cell, or null. Keyed by the bare cell. */ +export function heartFor(cell, { rate = HEART_RATE_DEFAULT } = {}) { + return place(HEART_SALT, String(cell), rate, HEART_RATE_DEFAULT, cell, String(cell), "first"); +} + +/** The regrowing heart in this cell in this window, or null. Keyed `cell:window`. */ +export function wildHeartFor(cell, window, { wildRate = HEART_WILD_RATE_DEFAULT } = {}) { + if (!Number.isFinite(Number(window))) return null; + const w = Number(window); + return place(HEART_WILD_SALT, String(cell) + ":" + String(w), wildRate, HEART_WILD_RATE_DEFAULT, + cell, String(cell) + ":" + String(w), "wild"); +} + +/** + * THE ONE RULE. Every heart this cell could hold right now, in priority order. + * + * The permanent heart is offered first when both hit: it is the rarer of the + * two and it disappears forever once taken, so handing it over first is + * strictly better for the player. The wild heart comes round again next window. + * + * ⚠ RETURNS BOTH, and deliberately. The obvious `heartFor(...) || wildHeartFor(...)` + * short-circuits: once a cell's permanent heart is in the ledger, that version + * keeps returning the taken spot and never consults the wild source, so at the + * default rate one cell in three would be sterile for wild hearts FOREVER — + * while this very comment promised it "comes round again". The callers, which + * are the only things that know what has been taken, pick the first candidate + * that is still there. + */ +export function heartCandidates(cell, window, { rate = HEART_RATE_DEFAULT, wildRate = HEART_WILD_RATE_DEFAULT } = {}) { + const out = []; + const first = heartFor(cell, { rate }); + if (first) out.push(first); + const wild = wildHeartFor(cell, window, { wildRate }); + if (wild) out.push(wild); + return out; +} + +/** Live settings (spec §6.4 plus the two deviations). Junk or a negative falls back. */ +export async function readHeartSettings(db) { + const out = { + rate: HEART_RATE_DEFAULT, + wildDays: HEART_WILD_DAYS_DEFAULT, + wildRate: HEART_WILD_RATE_DEFAULT, + energyBase: ENERGY_MAX_BASE_DEFAULT, + perHeart: ENERGY_MAX_PER_HEART_DEFAULT, + cap: ENERGY_MAX_CAP_DEFAULT, + }; + try { + const { rows } = await db.execute({ + sql: `SELECT key, value FROM ramble_settings WHERE key IN + ('heart.rate', 'heart.wild.days', 'heart.wild.rate', + 'energy.max.base', 'energy.max.per.heart', 'energy.max.cap')`, + args: [], + }); + for (const r of rows || []) { + const n = parseInt(r.value, 10); + if (!Number.isInteger(n)) continue; + if (r.key === "heart.rate" && n >= 1) out.rate = n; + if (r.key === "heart.wild.days" && n >= 1) out.wildDays = n; + if (r.key === "heart.wild.rate" && n >= 1) out.wildRate = n; + if (r.key === "energy.max.base" && n >= 1) out.energyBase = n; + // >= 1, not >= 0: a zero would make every heart inert while the pet page + // went on counting them. + if (r.key === "energy.max.per.heart" && n >= 1) out.perHeart = n; + if (r.key === "energy.max.cap" && n >= 1) out.cap = n; + } + } catch { /* defaults */ } + // A cap below the base would clamp a heartless bird's energy DOWN, which is + // a punishment no setting in this design is allowed to hand out. + if (out.cap < out.energyBase) out.cap = out.energyBase; + return out; +} diff --git a/tests/ramble-hearts.test.js b/tests/ramble-hearts.test.js new file mode 100644 index 00000000..9852fe9b --- /dev/null +++ b/tests/ramble-hearts.test.js @@ -0,0 +1,201 @@ +/** + * Spec 2026-09-08 §2.3 and §6.4 — heart container placement. + * + * Placement is a hash of the cell, like nestFor and seedFor: identical on + * every device, nothing stored, and not re-rollable by walking out and back. + * + * ⚠ FIXTURES, NOT LUCKY HASHES. Phase 1's sparse seed broke tests that were + * written against cells which happened to hash right. Every behavioural test + * below sets rate 1 so EVERY cell holds a heart and the fixture is whatever we + * name. The rate itself is covered by one statistical test and one pinned + * vector, which are the only two places a specific hash value matters. + */ +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 { decodeGeohash, encodeGeohash } from "../bundles/ramble/server/anchors.js"; +import { + HEART_KIND, HEART_SALT, HEART_WILD_SALT, + HEART_RATE_DEFAULT, HEART_WILD_DAYS_DEFAULT, HEART_WILD_RATE_DEFAULT, + ENERGY_MAX_BASE_DEFAULT, ENERGY_MAX_PER_HEART_DEFAULT, ENERGY_MAX_CAP_DEFAULT, + wildWindow, heartFor, wildHeartFor, heartCandidates, readHeartSettings, +} from "../bundles/ramble/server/hearts.js"; + +const CELL = "9vk79ed"; +const ALL = { rate: 1, wildRate: 1 }; // rate 1: every cell holds one + +function inside(cell, spot) { + const c = decodeGeohash(cell); + return spot.lat >= c.lat - c.latErr && spot.lat <= c.lat + c.latErr + && spot.lon >= c.lon - c.lonErr && spot.lon <= c.lon + c.lonErr; +} + +test("the defaults are the spec's numbers, plus the two recorded deviations", () => { + assert.equal(HEART_KIND, "heart"); + assert.equal(HEART_RATE_DEFAULT, 3); + assert.equal(HEART_WILD_DAYS_DEFAULT, 30); + assert.equal(HEART_WILD_RATE_DEFAULT, 40); + assert.equal(ENERGY_MAX_BASE_DEFAULT, 100); + assert.equal(ENERGY_MAX_PER_HEART_DEFAULT, 10); + assert.equal(ENERGY_MAX_CAP_DEFAULT, 300); + assert.notEqual(HEART_SALT, HEART_WILD_SALT, "the two sources must not share a salt"); +}); + +test("heartFor: at rate 1 every valid cell holds one, at a point INSIDE the cell", () => { + const spot = heartFor(CELL, ALL); + assert.ok(spot, "rate 1 always hits"); + assert.equal(spot.cell, CELL); + assert.equal(spot.source, "first"); + assert.equal(spot.key, CELL, "a first heart is keyed by the bare cell"); + assert.ok(inside(CELL, spot), "the pip sits inside its own cell"); + // Not the centre: a row of hearts along a street must not line up. + const c = decodeGeohash(CELL); + assert.ok(spot.lat !== c.lat || spot.lon !== c.lon, "hash-placed, not centred"); +}); + +test("heartFor: deterministic, and junk is refused rather than thrown", () => { + assert.deepEqual(heartFor(CELL, ALL), heartFor(CELL, ALL)); + assert.equal(heartFor("not-a-cell", ALL), null); + assert.equal(heartFor("", ALL), null); + assert.equal(heartFor(null, ALL), null); + // deepEqual, NOT equal: at the default rate both sides may be objects, and + // `equal` would then compare identity and fail for a reason that has nothing + // to do with the fallback. This is the file's own lucky-hash warning applied + // to itself. + assert.deepEqual(heartFor(CELL, { rate: 0 }), heartFor(CELL, {}), "a junk rate falls back to the default"); +}); + +test("heartFor at the default rate hits roughly one cell in three", () => { + let hits = 0; + const total = 3000; + let n = 0; + for (let i = 0; i < total; i++) { + // A spread of real coordinates, not sequential strings: geohash prefixes + // are not uniform over arbitrary text. + const lat = -60 + ((i * 7919) % 12000) / 100; + const lon = -170 + ((i * 6271) % 34000) / 100; + const cell = encodeGeohash(lat, lon, 7); + n += 1; + if (heartFor(cell, { rate: HEART_RATE_DEFAULT })) hits += 1; + } + const share = hits / n; + assert.ok(share > 0.28 && share < 0.39, `expected ~1/3, got ${share}`); +}); + +test("wildHeartFor: window-scoped, and a DIFFERENT place from the first heart", () => { + const w = 610; + const wild = wildHeartFor(CELL, w, ALL); + assert.ok(wild); + assert.equal(wild.source, "wild"); + assert.equal(wild.key, CELL + ":" + w, "a wild heart is keyed by cell AND window"); + assert.ok(inside(CELL, wild)); + const first = heartFor(CELL, ALL); + assert.ok(wild.lat !== first.lat || wild.lon !== first.lon, + "independent salts: the two sources must not land on the same spot"); + assert.notDeepEqual(wildHeartFor(CELL, w + 1, ALL), wild, "a new window is a new roll"); + assert.equal(wildHeartFor(CELL, "nope", ALL), null); + assert.equal(wildHeartFor("bad", w, ALL), null); +}); + +test("wildWindow buckets by whole days and falls back on junk", () => { + const day = 24 * 3600 * 1000; + assert.equal(wildWindow(0, 30), 0); + assert.equal(wildWindow(30 * day - 1, 30), 0); + assert.equal(wildWindow(30 * day, 30), 1); + assert.equal(wildWindow(60 * day, 30), 2); + assert.equal(wildWindow(60 * day, 0), wildWindow(60 * day, HEART_WILD_DAYS_DEFAULT)); + assert.equal(wildWindow(60 * day, "x"), wildWindow(60 * day, HEART_WILD_DAYS_DEFAULT)); +}); + +test("heartCandidates lists BOTH sources, permanent first — it never hides the wild one", () => { + // ⚠ This is the shape the plan review forced. An `a || b` candidate would + // short-circuit forever once the permanent heart was taken, so at the default + // rate one cell in three could never grow a wild heart again — while the code + // comment promised the opposite. + const both = heartCandidates(CELL, 610, ALL); + assert.equal(both.length, 2, "both sources hit at rate 1, and both are offered"); + assert.equal(both[0].source, "first", "the once-ever heart is offered first"); + assert.equal(both[1].source, "wild", "but the regrowing one is still there behind it"); + assert.deepEqual(both[0], heartFor(CELL, ALL)); + assert.deepEqual(both[1], wildHeartFor(CELL, 610, ALL)); + + // The huge rates are asserted to miss rather than assumed to, so a surprise + // hit reads as a precondition failure instead of a confusing shape failure. + assert.equal(heartFor(CELL, { rate: 999999 }), null, "precondition: no first heart at this rate"); + const onlyWild = heartCandidates(CELL, 610, { rate: 999999, wildRate: 1 }); + assert.deepEqual(onlyWild.map((c) => c.source), ["wild"]); + + assert.equal(wildHeartFor(CELL, 610, { wildRate: 999999 }), null, "precondition: no wild heart either"); + assert.deepEqual(heartCandidates(CELL, 610, { rate: 999999, wildRate: 999999 }), []); + assert.deepEqual(heartCandidates("bad", 610, ALL), []); +}); + +test("readHeartSettings reads all six keys, and refuses junk", async () => { + const db = createClient({ url: "file::memory:" }); + await initRambleTables(db); + const put = (k, v) => db.execute({ + sql: "INSERT INTO ramble_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + args: [k, v], + }); + + assert.deepEqual(await readHeartSettings(db), { + rate: 3, wildDays: 30, wildRate: 40, energyBase: 100, perHeart: 10, cap: 300, + }, "an untouched db reads the defaults"); + + await put("heart.rate", "1"); + await put("heart.wild.days", "7"); + await put("heart.wild.rate", "2"); + await put("energy.max.base", "80"); + await put("energy.max.per.heart", "25"); + await put("energy.max.cap", "500"); + assert.deepEqual(await readHeartSettings(db), { + rate: 1, wildDays: 7, wildRate: 2, energyBase: 80, perHeart: 25, cap: 500, + }); + + for (const k of ["heart.rate", "heart.wild.days", "heart.wild.rate", "energy.max.base", "energy.max.per.heart", "energy.max.cap"]) { + await put(k, "banana"); + } + assert.deepEqual(await readHeartSettings(db), { + rate: 3, wildDays: 30, wildRate: 40, energyBase: 100, perHeart: 10, cap: 300, + }, "junk everywhere falls all the way back"); + + // A cap below the base would clamp a heartless bird's energy DOWN. Refuse it. + await put("energy.max.base", "100"); + await put("energy.max.cap", "40"); + assert.equal((await readHeartSettings(db)).cap, 100, "the cap is never below the base"); +}); + +test("pinned: the default-rate placement never moves", () => { + // Generated once from the salts in hearts.js. If this fails, someone changed + // a salt or the hash arithmetic, and every existing player's map moved + // underneath them. Hits AND misses, positions AND presence: a pin of misses + // alone passes against a heartFor() that returns null for everything. + const FIRST_HITS = [ + ["0jr4et3", -60.000382, -169.999899], + ["9e7tnt3", 19.189336, -107.290802], + ["7h0m181", -21.620466, -44.580818], + ]; + const FIRST_MISSES = ["u6hzs83", "r5wp4tc", "7g616em"]; + const WILD_HITS = [ + ["26y5dc1", -28.910171, -160.188970], + ["u3s7bzb", 54.139269, 17.259736], + ]; + const WILD_MISSES = ["0jr4et3", "9e7tnt3"]; + + assert.ok(FIRST_HITS.length > 0 && WILD_HITS.length > 0, "a pin with no hits pins nothing"); + for (const [cell, lat, lon] of FIRST_HITS) { + const spot = heartFor(cell, {}); + assert.ok(spot, cell + " must still hold its permanent heart"); + assert.equal(spot.lat.toFixed(6), lat.toFixed(6), cell + " heart moved in latitude"); + assert.equal(spot.lon.toFixed(6), lon.toFixed(6), cell + " heart moved in longitude"); + } + for (const cell of FIRST_MISSES) assert.equal(heartFor(cell, {}), null, cell + " must still be empty"); + for (const [cell, lat, lon] of WILD_HITS) { + const spot = wildHeartFor(cell, 610, {}); + assert.ok(spot, cell + " must still hold its wild heart in window 610"); + assert.equal(spot.lat.toFixed(6), lat.toFixed(6), cell + " wild heart moved in latitude"); + assert.equal(spot.lon.toFixed(6), lon.toFixed(6), cell + " wild heart moved in longitude"); + } + for (const cell of WILD_MISSES) assert.equal(wildHeartFor(cell, 610, {}), null, cell + " must still be empty"); +}); From f22674e5b559d32e56d0e4bca2a69d3fb797d69a Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:03:12 -0500 Subject: [PATCH 06/15] ramble: taking a heart, and the one rule the map and the payout share --- bundles/ramble/server/hearts.js | 147 +++++++++++++++++ tests/ramble-hearts-ledger.test.js | 256 +++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 tests/ramble-hearts-ledger.test.js diff --git a/bundles/ramble/server/hearts.js b/bundles/ramble/server/hearts.js index b547a4c0..cb1cbe3a 100644 --- a/bundles/ramble/server/hearts.js +++ b/bundles/ramble/server/hearts.js @@ -145,3 +145,150 @@ export async function readHeartSettings(db) { if (out.cap < out.energyBase) out.cap = out.energyBase; return out; } + +/** Mirrors eggs.js's helper: an emit must never be able to fail the write. */ +async function safeEmit(emit, table, op, row) { + if (typeof emit !== "function") return; + try { await emit(table, op, row); } + catch (err) { try { console.warn(`[ramble] emit ${table} failed:`, err?.message); } catch {} } +} + +/** Every heart key already taken. Bounded by the player's lifetime collection. */ +async function takenKeys(db) { + const { rows } = await db.execute({ + sql: "SELECT key FROM ramble_wallet WHERE kind = ?", args: [HEART_KIND], + }); + return new Set((rows || []).map((r) => String(r.key))); +} + +/** + * Which of the ASKED cells are unlocked. Bounded by the question, not by the + * player's history: `SELECT cell FROM ramble_cells` would be a second + * unbounded full-table scan on every /zones request, and phase 1 already left + * one of those behind in unlockedCellsNear. The caller has usually filtered to + * unlocked ground already, but this stays fail-closed rather than trusting it. + */ +async function unlockedAmong(db, cells) { + const out = new Set(); + // Chunked: a close-zoom viewport over a walked town can ask about more cells + // than SQLite will bind at once, which is the same limit harvestableCells + // avoids by matching on a key suffix instead. + for (let i = 0; i < cells.length; i += 400) { + const chunk = cells.slice(i, i + 400); + const marks = chunk.map(() => "?").join(","); + // eslint-disable-next-line no-await-in-loop + const { rows } = await db.execute({ + sql: `SELECT cell FROM ramble_cells WHERE cell IN (${marks})`, args: chunk, + }); + for (const r of rows || []) out.add(String(r.cell)); + } + return out; +} + +/** + * Take the heart in this cell, if there is one and it is still there. + * + * ⚠ FAIL CLOSED. The cell must already be in ramble_cells. A position fix + * vaguer than `unlock.max.accuracy.m` is refused an unlock, and it must be + * refused a heart on exactly the same grounds — otherwise a 2 km wifi fix pays + * out in ground the user never entered. Checked HERE rather than trusted to + * the caller, because this function is the payout. + */ +export async function recordHeartPickup(db, cell, { now = Date.now(), emit } = {}) { + const none = { picked: false, amount: 0 }; + if (!db || typeof cell !== "string" || !CELL7_RE.test(cell)) return none; + try { + const { rows } = await db.execute({ + sql: "SELECT 1 AS ok FROM ramble_cells WHERE cell = ?", args: [cell], + }); + if (!rows || rows.length === 0) return none; + + const { rate, wildDays, wildRate } = await readHeartSettings(db); + // NOT `Number(now) || Date.now()` — that treats `now: 0` as falsy and + // silently substitutes the real clock (the phase 1 note on this still holds). + const at = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + // EVERY candidate, in priority order, not just the first: a cell whose + // permanent heart was collected long ago must still be able to pay out its + // wild one. The INSERT is the arbiter — whichever key is not yet in the + // ledger is the one that pays. + for (const spot of heartCandidates(cell, wildWindow(at, wildDays), { rate, wildRate })) { + // eslint-disable-next-line no-await-in-loop + const res = await db.execute({ + sql: `INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, 1, ?) + ON CONFLICT(kind, key) DO NOTHING`, + args: [HEART_KIND, spot.key, at], + }); + if (Number(res.rowsAffected) === 0) continue; + // eslint-disable-next-line no-await-in-loop + await safeEmit(emit, "ramble_wallet", "insert", + { kind: HEART_KIND, key: spot.key, delta: 1, created_at: at }); + return { picked: true, amount: 1, source: spot.source }; + } + return none; + } catch (err) { + try { console.warn("[ramble] heart pickup failed:", err?.message); } catch {} + return none; + } +} + +/** + * Which of these cells still hold a heart to walk to. + * + * ⚠ THE SAME RULE THE PAYOUT USES. This goes through heartCandidates and takes + * the first untaken one, exactly as recordHeartPickup does, and returns the + * candidate object itself rather than a re-derived position — so the map cannot + * drift from the payout. That drift is the hazard 0.9.5 closed for seed. + * + * Cells the caller has not unlocked are dropped even when they are asked for: + * a heart in fog would be a preview of ground you have not earned (K3). + */ +export async function availableHearts(db, cells, { now = Date.now() } = {}) { + const asked = (Array.from(cells || [])).filter((c) => typeof c === "string" && CELL7_RE.test(c)); + if (!db || asked.length === 0) return []; + try { + const { rate, wildDays, wildRate } = await readHeartSettings(db); + const at = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + const window = wildWindow(at, wildDays); + const unlocked = await unlockedAmong(db, asked); + const taken = await takenKeys(db); + const out = []; + for (const cell of asked) { + if (!unlocked.has(cell)) continue; + // The FIRST candidate still standing — the same choice recordHeartPickup + // makes when it walks the list and lets the INSERT arbitrate. + const spot = heartCandidates(cell, window, { rate, wildRate }).find((c) => !taken.has(c.key)); + if (spot) out.push(spot); + } + return out; + } catch (err) { + // A map that cannot say where a heart is should still draw. Never throw. + try { console.warn("[ramble] availableHearts failed:", err?.message); } catch {} + return []; + } +} + +/** How many containers the player holds. Every row is worth exactly one. */ +export async function heartsBalance(db) { + try { + const { rows } = await db.execute({ + sql: "SELECT COALESCE(SUM(delta), 0) AS total FROM ramble_wallet WHERE kind = ?", + args: [HEART_KIND], + }); + return Number(rows?.[0]?.total) || 0; + } catch { return 0; } +} + +/** + * The bird's ceiling: the base plus one step per container, capped. + * + * DERIVED, never stored. A stored maximum would be a balance, and §6.1's whole + * point is that a balance loses increments to last-writer-wins. It also means + * retuning `energy.max.per.heart` retunes every existing player's bar, which is + * what makes these numbers settings rather than a redesign. + */ +export async function maxEnergy(db) { + try { + const { energyBase, perHeart, cap } = await readHeartSettings(db); + return Math.min(cap, energyBase + (await heartsBalance(db)) * perHeart); + } catch { return ENERGY_MAX_BASE_DEFAULT; } +} diff --git a/tests/ramble-hearts-ledger.test.js b/tests/ramble-hearts-ledger.test.js new file mode 100644 index 00000000..2fa2ee15 --- /dev/null +++ b/tests/ramble-hearts-ledger.test.js @@ -0,0 +1,256 @@ +/** + * Spec 2026-09-08 §6.1, §8 — the heart ledger. + * + * Hearts are append-only rows in ramble_wallet under kind 'heart'. There is no + * heart TABLE and no stored balance: the count and the maximum energy derived + * from it are both read out of the ledger, which is what makes them converge + * across the user's own instances for free. + * + * ⚠ rate 1 throughout, so every cell in a fixture holds a heart and no test + * depends on a cell that happens to hash lucky (the phase 1 lesson). + */ +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 { applyRambleWallet } from "../servers/sharing/instance-sync.js"; +import { + HEART_KIND, heartCandidates, wildWindow, + recordHeartPickup, availableHearts, heartsBalance, maxEnergy, +} from "../bundles/ramble/server/hearts.js"; + +const NOW = 1_757_000_000_000; +// Six real, distinct geohash-7 cells, checked with decodeGeohash while this +// was written: Houston, Texas hill country, New York, London, Berlin, Hong +// Kong. At rate 1 every one of them holds a heart, so this fixture is exactly +// what it looks like — no cell here was chosen for hashing lucky. +const CELLS = ["9vk79ed", "9v6m2xt", "dr5regw", "gcpvj0d", "u33dc0e", "wecnrmd"]; + +// `per` and `cap` are used by the energy tests below; `rate`/`wildRate` are +// pinned per-test so no test depends on a cell that happens to hash lucky. +async function freshDb({ rate = 1, wildRate = 999999, base, per, cap } = {}) { + const db = createClient({ url: "file::memory:" }); + await initRambleTables(db); + const put = (k, v) => db.execute({ + sql: "INSERT INTO ramble_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + args: [k, String(v)], + }); + await put("heart.rate", rate); + await put("heart.wild.rate", wildRate); + if (base != null) await put("energy.max.base", base); + if (per != null) await put("energy.max.per.heart", per); + if (cap != null) await put("energy.max.cap", cap); + return db; +} + +async function unlock(db, cells, at = NOW) { + for (const c of cells) { + await db.execute({ + sql: "INSERT INTO ramble_cells (cell, first_unlocked_at) VALUES (?, ?) ON CONFLICT(cell) DO NOTHING", + args: [c, at], + }); + } +} + +const walletRows = async (db) => + (await db.execute({ sql: "SELECT * FROM ramble_wallet WHERE kind = ? ORDER BY key", args: [HEART_KIND] })).rows; + +test("a heart is NEVER granted in a cell that is not unlocked — fail closed", async () => { + const db = await freshDb(); + const out = await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.deepEqual(out, { picked: false, amount: 0 }); + assert.equal((await walletRows(db)).length, 0, "no row, so no heart"); + assert.equal(await heartsBalance(db), 0); +}); + +test("the first pickup writes exactly one row with delta 1, and emits it", async () => { + const db = await freshDb(); + await unlock(db, [CELLS[0]]); + const seen = []; + const emit = (table, op, row) => { seen.push({ table, op, row }); }; + + const out = await recordHeartPickup(db, CELLS[0], { now: NOW, emit }); + assert.deepEqual(out, { picked: true, amount: 1, source: "first" }); + + const rows = await walletRows(db); + assert.equal(rows.length, 1); + assert.equal(rows[0].key, CELLS[0], "a first heart is keyed by the bare cell"); + assert.equal(Number(rows[0].delta), 1, "delta is a COUNT of containers, never an energy amount"); + assert.equal(Number(rows[0].created_at), NOW); + + assert.equal(seen.length, 1, "the outbound half: without this the ledger syncs one way only"); + assert.equal(seen[0].table, "ramble_wallet"); + assert.equal(seen[0].op, "insert"); + assert.equal(seen[0].row.kind, HEART_KIND); + assert.equal(seen[0].row.delta, 1); +}); + +test("the permanent heart is gone for good — a second visit pays nothing, ever", async () => { + const db = await freshDb(); + await unlock(db, [CELLS[0]]); + await recordHeartPickup(db, CELLS[0], { now: NOW }); + + assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: NOW }), { picked: false, amount: 0 }); + const muchLater = NOW + 400 * 24 * 3600 * 1000; + assert.deepEqual(heartCandidates(CELLS[0], wildWindow(muchLater, 30), { rate: 1, wildRate: 999999 }) + .map((c) => c.source), ["first"], "precondition: the wild source is silent, even 400 days out"); + assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: muchLater }), { picked: false, amount: 0 }); + assert.equal((await walletRows(db)).length, 1); +}); + +test("a wild heart regrows: once per window, again in the next", async () => { + // No first hearts at all, so every hit here is unambiguously a wild one. + const db = await freshDb({ rate: 999999, wildRate: 1 }); + await unlock(db, [CELLS[0]]); + const day = 24 * 3600 * 1000; + // Asserted, not assumed: a surprise first heart at this rate would otherwise + // fail below as a baffling "source" mismatch. + assert.deepEqual(heartCandidates(CELLS[0], wildWindow(NOW, 30), { rate: 999999, wildRate: 999999 }), [], + "precondition: neither source hits at these rates"); + + const first = await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.deepEqual(first, { picked: true, amount: 1, source: "wild" }); + assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: NOW + day }), { picked: false, amount: 0 }, + "still the same 30-day window"); + + const next = await recordHeartPickup(db, CELLS[0], { now: NOW + 31 * day }); + assert.deepEqual(next, { picked: true, amount: 1, source: "wild" }); + + const rows = await walletRows(db); + assert.equal(rows.length, 2); + for (const r of rows) assert.match(String(r.key), /^[0-9b-hjkmnp-z]{7}:\d+$/, "wild keys carry their window"); +}); + +test("THE SAME RULE: what the map draws is exactly what a walk would grant", async () => { + // The phase 1 defect, made executable. A heart shown but not granted (or + // granted but never shown) is the bug that shipped in the seed layer. + const db = await freshDb(); + await unlock(db, CELLS); + + const drawn = await availableHearts(db, CELLS, { now: NOW }); + assert.equal(drawn.length, CELLS.length, "rate 1: every unlocked cell in the fixture"); + for (const spot of drawn) { + assert.deepEqual(spot, heartCandidates(spot.cell, wildWindow(NOW, 30), { rate: 1, wildRate: 999999 })[0], + "the map draws the candidate itself, not a re-derived guess"); + } + + // Take three of them, then assert the two readers STILL agree. + const taken = CELLS.slice(0, 3); + for (const c of taken) { + assert.equal((await recordHeartPickup(db, c, { now: NOW })).picked, true); + } + const after = await availableHearts(db, CELLS, { now: NOW }); + assert.deepEqual(after.map((s) => s.cell).sort(), CELLS.slice(3).sort(), + "a collected heart leaves the map"); + for (const c of taken) { + assert.equal((await recordHeartPickup(db, c, { now: NOW })).picked, false, + "and a cell the map no longer draws grants nothing"); + } + for (const c of CELLS.slice(3)) { + assert.equal((await recordHeartPickup(db, c, { now: NOW })).picked, true, + "while every cell the map still draws does grant"); + } +}); + +test("availableHearts never leaves unlocked ground, and never throws on junk", async () => { + const db = await freshDb(); + await unlock(db, [CELLS[0]]); + const asked = [CELLS[0], CELLS[1], "not-a-cell", "", null, 7]; + const drawn = await availableHearts(db, asked, { now: NOW }); + assert.deepEqual(drawn.map((s) => s.cell), [CELLS[0]], + "a cell the caller has not unlocked is not drawn even when it is asked for"); + assert.deepEqual(await availableHearts(db, [], { now: NOW }), []); + assert.deepEqual(await availableHearts(db, null, { now: NOW }), []); +}); + +test("heartsBalance counts containers; maxEnergy derives the bar and honours the cap", async () => { + const db = await freshDb({ rate: 1, base: 100, per: 10, cap: 130 }); + assert.equal(await heartsBalance(db), 0); + assert.equal(await maxEnergy(db), 100, "no hearts: the base"); + + await unlock(db, CELLS); + for (const c of CELLS.slice(0, 2)) await recordHeartPickup(db, c, { now: NOW }); + assert.equal(await heartsBalance(db), 2); + assert.equal(await maxEnergy(db), 120, "base + hearts x per-heart"); + + for (const c of CELLS.slice(2)) await recordHeartPickup(db, c, { now: NOW }); + assert.equal(await heartsBalance(db), 6); + assert.equal(await maxEnergy(db), 130, "the cap holds"); +}); + +test("seed rows are not hearts and hearts are not seed", async () => { + const db = await freshDb(); + await unlock(db, [CELLS[0]]); + await db.execute({ + sql: "INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES ('seed', ?, 5, ?)", + args: [CELLS[0] + ":1", NOW], + }); + await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.equal(await heartsBalance(db), 1, "the seed pile does not inflate the heart count"); +}); + +test("a cell grows a WILD heart after its permanent one is taken", async () => { + // ⚠ The defect the plan review caught. An `a || b` candidate keeps returning + // the taken permanent heart and never reaches the wild source, so at the + // default rate one cell in three would be sterile forever. Both sources hit + // here (rate 1, wildRate 1), which is the only configuration that can tell + // the two implementations apart — every other test in this file silences one + // source to isolate the other, and that is exactly how this hid. + const db = await freshDb({ rate: 1, wildRate: 1 }); + await unlock(db, [CELLS[0]]); + + const first = await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.deepEqual(first, { picked: true, amount: 1, source: "first" }); + + const wild = await recordHeartPickup(db, CELLS[0], { now: NOW }); + assert.deepEqual(wild, { picked: true, amount: 1, source: "wild" }, + "the wild heart in the same window is still there to take"); + + assert.deepEqual(await recordHeartPickup(db, CELLS[0], { now: NOW }), { picked: false, amount: 0 }, + "and now the cell really is empty for this window"); + + const day = 24 * 3600 * 1000; + const nextWindow = await recordHeartPickup(db, CELLS[0], { now: NOW + 31 * day }); + assert.deepEqual(nextWindow, { picked: true, amount: 1, source: "wild" }, + "next window, the wild heart comes round again — as the doc comment promises"); + + // And the map agrees at every step, which is the whole point. + assert.deepEqual(await availableHearts(db, [CELLS[0]], { now: NOW }), []); + assert.equal((await availableHearts(db, [CELLS[0]], { now: NOW + 62 * day })).length, 1); +}); + +test("a heart row is worth ONE container even when a heart is worth 25 energy", async () => { + // The assertion that would actually fail if someone later stored energy in + // the row. Asserting that MAX(delta) of two identical 1s is 1 proves nothing. + const db = await freshDb({ rate: 1, per: 25 }); + await unlock(db, [CELLS[0]]); + await recordHeartPickup(db, CELLS[0], { now: NOW }); + const rows = await walletRows(db); + assert.equal(Number(rows[0].delta), 1, "delta is a COUNT; the energy per heart lives in a setting"); + assert.equal(await heartsBalance(db), 1); + assert.equal(await maxEnergy(db), 125, "and the setting is what values it"); +}); + +test("two instances converge on the same heart count whatever order rows arrive in", async () => { + // Spec §8: anything that replicates needs a multi-instance test, not a + // single-database one. delta is a constant 1, so MAX(delta) — which phase 1 + // had to fix for seed — is safe here BY CONSTRUCTION. This test is what says + // so out loud. + const rows = [ + { kind: HEART_KIND, key: CELLS[0], delta: 1, created_at: NOW }, + { kind: HEART_KIND, key: CELLS[1], delta: 1, created_at: NOW + 10 }, + { kind: HEART_KIND, key: CELLS[2] + ":610", delta: 1, created_at: NOW + 20 }, + ]; + const a = await freshDb(); + const b = await freshDb(); + for (let i = 0; i < rows.length; i++) await applyRambleWallet(a, "insert", rows[i], 10 + i); + for (let i = rows.length - 1; i >= 0; i--) await applyRambleWallet(b, "insert", rows[i], 10 + i); + // And a duplicate arriving late on both. + await applyRambleWallet(a, "insert", rows[0], 99); + await applyRambleWallet(b, "insert", rows[0], 99); + + assert.equal(await heartsBalance(a), 3); + assert.equal(await heartsBalance(b), 3); + assert.equal(await maxEnergy(a), await maxEnergy(b)); + for (const r of await walletRows(a)) assert.equal(Number(r.delta), 1, "no row ever grew"); +}); From a002f593e30724b07b4869ed6d43e3912889074a Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:08:18 -0500 Subject: [PATCH 07/15] ramble: the energy bar ends where the heart containers say it does --- bundles/ramble/server/feed.js | 3 +- bundles/ramble/server/pet.js | 53 ++++++++++++---- tests/ramble-pet.test.js | 113 ++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 14 deletions(-) diff --git a/bundles/ramble/server/feed.js b/bundles/ramble/server/feed.js index fd99fdc4..ebe07257 100644 --- a/bundles/ramble/server/feed.js +++ b/bundles/ramble/server/feed.js @@ -9,6 +9,7 @@ import { creditWarmth } from "./eggs.js"; import { feed as petFeed, petFromRow } from "./pet.js"; +import { maxEnergy } from "./hearts.js"; const KEYED_TYPES = new Set(["visit_place", "meet_crow", "checkin"]); const ACCEPTED_TYPES = new Set(["visit_place", "mark_left", "unlock_mark", "meet_crow", "checkin", "quiet_tick"]); @@ -20,7 +21,7 @@ const ACCEPTED_TYPES = new Set(["visit_place", "mark_left", "unlock_mark", "meet */ async function readPet(db) { const { rows } = await db.execute({ sql: "SELECT * FROM ramble_pet WHERE owner = 'self'", args: [] }); - return petFromRow(rows[0] ?? null); + return petFromRow(rows[0] ?? null, await maxEnergy(db)); } export async function feedAll(db, event, { now = Date.now(), emit, onHatch } = {}) { diff --git a/bundles/ramble/server/pet.js b/bundles/ramble/server/pet.js index 0ad28dd5..5aab46ea 100644 --- a/bundles/ramble/server/pet.js +++ b/bundles/ramble/server/pet.js @@ -21,6 +21,7 @@ */ import { localDay } from "./eggs.js"; +import { maxEnergy, ENERGY_MAX_BASE_DEFAULT } from "./hearts.js"; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const DECAY_INTERVAL_MS = 6 * 60 * 60 * 1000; @@ -76,8 +77,24 @@ export function moodFor(energy) { return "alarmed"; } -function clampEnergy(v) { - return Math.max(0, Math.min(100, v)); +/** + * The ceiling is DERIVED from the heart ledger (spec §3, D6), so it is passed + * in rather than read here — every caller has already fetched it once, and a + * second read would risk clamping against a different number than the one the + * panel is about to draw. + * + * ⚠ ASYMMETRIC, DELIBERATELY. An addition stops at the ceiling, but the + * ceiling NEVER reduces a value that is already above it. Sync applies + * ramble_pet before ramble_wallet, so a bird that is legitimately at 150 can + * be seen by an instance that has not yet received the heart rows and computes + * a ceiling of 100. A symmetric clamp would write the 150 down, last-writer- + * wins would propagate the loss back, and no later arrival could undo it. + * Decay still brings an over-ceiling bird down normally — it just is not the + * ceiling that does it. + */ +function clampEnergy(next, previous, max) { + const ceiling = Math.max(Number.isFinite(max) ? max : ENERGY_MAX_BASE_DEFAULT, previous || 0); + return Math.max(0, Math.min(ceiling, next)); } /** @@ -119,7 +136,8 @@ export async function feed(db, event, { now = Date.now(), emit } = {}) { } const delta = FEED_DELTAS[type]; - const energy = clampEnergy(row.energy + delta); + const max = await maxEnergy(db); + const energy = clampEnergy(row.energy + delta, row.energy, max); const mood = moodFor(energy); const counterCol = COUNTER_COLUMN[type]; @@ -138,7 +156,7 @@ export async function feed(db, event, { now = Date.now(), emit } = {}) { const updated = await ensureRow(db); await safeEmit(emit, "ramble_pet", "update", updated); - return { owner: "self", mood, energy, places_week, unlocks_week, crows_week, week_start, last_fed_at }; + return { owner: "self", mood, energy, energy_max: max, places_week, unlocks_week, crows_week, week_start, last_fed_at }; } /** @@ -149,12 +167,13 @@ export async function feed(db, event, { now = Date.now(), emit } = {}) { * the raw db row instead leaks `lamport_ts`/`chores_json` into a response * that otherwise never carries them. Returns null for a null row. */ -export function petFromRow(row) { +export function petFromRow(row, energyMax) { if (!row) return null; return { owner: "self", mood: row.mood, energy: row.energy, + energy_max: Number.isFinite(energyMax) ? energyMax : ENERGY_MAX_BASE_DEFAULT, places_week: row.places_week, unlocks_week: row.unlocks_week, crows_week: row.crows_week, @@ -179,7 +198,7 @@ export async function doChore(db, kind, { now = Date.now(), emit } = {}) { const chores = readChores(row, now); if (chores[kind] === true) { - return { done: false, chores, pet: petFromRow(row) }; + return { done: false, chores, pet: petFromRow(row, await maxEnergy(db)) }; } chores[kind] = true; @@ -200,28 +219,36 @@ export async function doChore(db, kind, { now = Date.now(), emit } = {}) { */ export async function petState(db, { now = Date.now() } = {}) { const row = await ensureRow(db); + const max = await maxEnergy(db); let energy = row.energy; - let mood = row.mood; let last_fed_at = row.last_fed_at; + let decayed = false; if (last_fed_at != null) { const elapsed = now - last_fed_at; if (elapsed >= DECAY_INTERVAL_MS) { const intervals = Math.floor(elapsed / DECAY_INTERVAL_MS); - energy = clampEnergy(energy - intervals * DECAY_PER_INTERVAL); - mood = moodFor(energy); + // `row.energy` as the floor argument, so an over-ceiling bird decays by + // exactly the intervals elapsed rather than snapping to the ceiling. + energy = clampEnergy(energy - intervals * DECAY_PER_INTERVAL, row.energy, max); last_fed_at = now; - await db.execute({ - sql: "UPDATE ramble_pet SET energy = ?, mood = ?, last_fed_at = ? WHERE owner = 'self'", - args: [energy, mood, last_fed_at], - }); + decayed = true; } } + const mood = moodFor(energy); + + if (decayed) { + await db.execute({ + sql: "UPDATE ramble_pet SET energy = ?, mood = ?, last_fed_at = ? WHERE owner = 'self'", + args: [energy, mood, last_fed_at], + }); + } return { mood, energy, + energy_max: max, places_week: row.places_week, unlocks_week: row.unlocks_week, crows_week: row.crows_week, diff --git a/tests/ramble-pet.test.js b/tests/ramble-pet.test.js index 4842de78..02a0dcd7 100644 --- a/tests/ramble-pet.test.js +++ b/tests/ramble-pet.test.js @@ -229,3 +229,116 @@ test("ensureRow is atomic: concurrent feeds on a fresh db never throw UNIQUE", a const { rows } = await fresh.execute("SELECT count(*) AS n FROM ramble_pet"); assert.equal(rows[0].n, 1); }); + +/* --- Phase 2: heart containers raise the ceiling (spec §3, §4.3, D6). --- */ + +import { maxEnergy } from "../bundles/ramble/server/hearts.js"; + +const HEART_CELLS = ["9vk79ed", "9v6m2xt", "dr5regw", "gcpvj0d", "u33dc0e", "wecnrmd"]; + +async function giveHearts(db, n) { + // Ledger rows directly: this file tests the PET, not the pickup path. + for (let i = 0; i < n; i++) { + await db.execute({ + sql: "INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES ('heart', ?, 1, 0)", + args: [HEART_CELLS[i % HEART_CELLS.length] + ":pet" + i], + }); + } +} + +test("with no hearts the ceiling is still 100, exactly as before", async () => { + const db = await freshDb(); + assert.equal(await maxEnergy(db), 100); + for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); + const pet = await petState(db, { now: 1000 }); + assert.equal(pet.energy, 100, "the old ceiling holds for a player with no hearts"); + assert.equal(pet.energy_max, 100); +}); + +test("five hearts raise the ceiling to 150, and feed() fills to it", async () => { + const db = await freshDb(); + await giveHearts(db, 5); + assert.equal(await maxEnergy(db), 150); + for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); + const pet = await petState(db, { now: 1000 }); + assert.equal(pet.energy, 150, "the bird fills the longer bar"); + assert.equal(pet.energy_max, 150, "and the number drawn is the number clamped"); +}); + +test("mood thresholds stay ABSOLUTE, so a longer bar buys real slack", async () => { + // Spec §4.3: hearts buy resilience. At max 150, energy 70 is still happy — + // a percentage threshold would have made it tired and hearts pointless. + const db = await freshDb(); + await giveHearts(db, 5); + for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); + const six = 6 * 60 * 60 * 1000; + // 150 -> 70 is eight decay intervals; a 100-max bird would be at 20 by now. + const pet = await petState(db, { now: 1000 + 8 * six }); + assert.equal(pet.energy, 70); + assert.equal(pet.mood, "happy", "still happy at 70 because 60 is an absolute threshold"); +}); + +test("decay still bottoms out at 0 whatever the ceiling is", async () => { + const db = await freshDb(); + await giveHearts(db, 20); + await feed(db, { type: "meet_crow" }, { now: 1000 }); + const year = 365 * 24 * 60 * 60 * 1000; + const pet = await petState(db, { now: 1000 + year }); + assert.equal(pet.energy, 0); + assert.equal(pet.mood, "alarmed"); +}); + +test("a ceiling that drops underneath a bird never destroys its energy — on ANY path", async () => { + // ⚠ This is the sync-window hazard, and it has to be tested on the paths that + // actually run in that window. sync applies ramble_pet BEFORE ramble_wallet, + // so an instance can see a 150-energy bird while it still computes a ceiling + // of 100 — and `POST /api/ramble/area` feeds the pet (visit_place) BEFORE the + // heart pickup runs. A test that only reads petState at a frozen `now` proves + // nothing: no decay interval elapses, so nothing is written at all. + const db = await freshDb(); + await giveHearts(db, 5); + for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); + assert.equal((await petState(db, { now: 1000 })).energy, 150); + + const stored = async () => Number( + (await db.execute({ sql: "SELECT energy FROM ramble_pet WHERE owner = 'self'", args: [] })).rows[0].energy); + + // The ceiling collapses underneath it — exactly what an un-synced wallet looks like. + await db.execute({ + sql: "INSERT INTO ramble_settings (key, value) VALUES ('energy.max.per.heart', '2') ON CONFLICT(key) DO UPDATE SET value = excluded.value", + args: [], + }); + assert.equal(await maxEnergy(db), 110); + + // Path 1: a plain read writes nothing and reports the truth. + assert.equal((await petState(db, { now: 1000 })).energy, 150); + assert.equal(await stored(), 150, "a read never truncates"); + + // Path 2: FEEDING, which is what an area post does before the hearts arrive. + const fed = await feed(db, { type: "visit_place" }, { now: 1000 }); + assert.equal(fed.energy, 150, "an addition stops at the ceiling but never pulls the bird DOWN to it"); + assert.equal(await stored(), 150); + + // Path 3: DECAY, which is the other write. It reduces by the elapsed + // intervals, not by snapping to the ceiling. + const six = 6 * 60 * 60 * 1000; + assert.equal((await petState(db, { now: 1000 + 2 * six })).energy, 130, "two intervals, 20 energy"); + assert.equal(await stored(), 130); + + // And when the ledger catches up, the bar is long again with nothing lost. + await db.execute({ sql: "UPDATE ramble_settings SET value = '10' WHERE key = 'energy.max.per.heart'", args: [] }); + assert.equal(await maxEnergy(db), 150); + assert.equal((await petState(db, { now: 1000 + 2 * six })).energy, 130, "nothing was destroyed on the way"); +}); + +test("every pet shape carries the same energy_max — feed, chore, and the no-op chore", async () => { + const db = await freshDb(); + await giveHearts(db, 3); + const fed = await feed(db, { type: "checkin" }, { now: 1000 }); + assert.equal(fed.energy_max, 130); + const chore = await doChore(db, "feed", { now: 1000 }); + assert.equal(chore.pet.energy_max, 130); + const repeat = await doChore(db, "feed", { now: 1000 }); + assert.equal(repeat.done, false); + assert.equal(repeat.pet.energy_max, 130, "the no-op branch must not report a different bar"); +}); From f0f0f80a55996341a559e0b86c71ca9874e0b62e Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:12:54 -0500 Subject: [PATCH 08/15] ramble: fix the clampEnergy sync-hazard comment's ordering claim --- bundles/ramble/server/pet.js | 17 ++++++++++------- tests/ramble-pet.test.js | 12 +++++++----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/bundles/ramble/server/pet.js b/bundles/ramble/server/pet.js index 5aab46ea..e5f106f2 100644 --- a/bundles/ramble/server/pet.js +++ b/bundles/ramble/server/pet.js @@ -84,13 +84,16 @@ export function moodFor(energy) { * panel is about to draw. * * ⚠ ASYMMETRIC, DELIBERATELY. An addition stops at the ceiling, but the - * ceiling NEVER reduces a value that is already above it. Sync applies - * ramble_pet before ramble_wallet, so a bird that is legitimately at 150 can - * be seen by an instance that has not yet received the heart rows and computes - * a ceiling of 100. A symmetric clamp would write the 150 down, last-writer- - * wins would propagate the loss back, and no later arrival could undo it. - * Decay still brings an over-ceiling bird down normally — it just is not the - * ceiling that does it. + * ceiling NEVER reduces a value that is already above it. instance-sync + * applies each incoming entry one at a time, in the order it arrived, with no + * guarantee that a pet row and the wallet rows that justify its energy land + * together or in any particular relative order — so an instance can apply a + * synced pet row at energy 150 while it still holds none of the heart rows + * and computes a ceiling of 100. A symmetric clamp would write the 150 down + * right there, and last-writer-wins would then carry that loss back to the + * instance it came from, with no later arrival able to undo it. Decay still + * brings an over-ceiling bird down normally — it just is not the ceiling that + * does it. */ function clampEnergy(next, previous, max) { const ceiling = Math.max(Number.isFinite(max) ? max : ENERGY_MAX_BASE_DEFAULT, previous || 0); diff --git a/tests/ramble-pet.test.js b/tests/ramble-pet.test.js index 02a0dcd7..3efb1a57 100644 --- a/tests/ramble-pet.test.js +++ b/tests/ramble-pet.test.js @@ -290,11 +290,13 @@ test("decay still bottoms out at 0 whatever the ceiling is", async () => { test("a ceiling that drops underneath a bird never destroys its energy — on ANY path", async () => { // ⚠ This is the sync-window hazard, and it has to be tested on the paths that - // actually run in that window. sync applies ramble_pet BEFORE ramble_wallet, - // so an instance can see a 150-energy bird while it still computes a ceiling - // of 100 — and `POST /api/ramble/area` feeds the pet (visit_place) BEFORE the - // heart pickup runs. A test that only reads petState at a frozen `now` proves - // nothing: no decay interval elapses, so nothing is written at all. + // actually run in that window. instance-sync applies each incoming entry + // one at a time with no ordering guarantee between a pet row and the wallet + // rows that justify its energy, so an instance can apply a synced 150-energy + // bird while it still computes a ceiling of 100 from wallet rows it hasn't + // received yet — and `POST /api/ramble/area` feeds the pet (visit_place) + // BEFORE the heart pickup runs. A test that only reads petState at a frozen + // `now` proves nothing: no decay interval elapses, so nothing is written at all. const db = await freshDb(); await giveHearts(db, 5); for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); From 8f6c6476b7c8970bc253cef98af82754da457984 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:13:39 -0500 Subject: [PATCH 09/15] plan: correct the sync justification for the asymmetric energy ceiling --- .../plans/2026-09-09-ramble-hearts-phase2.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md index 461f016a..45a1b9d8 100644 --- a/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md +++ b/docs/superpowers/plans/2026-09-09-ramble-hearts-phase2.md @@ -1156,7 +1156,7 @@ import { maxEnergy, ENERGY_MAX_BASE_DEFAULT } from "./hearts.js"; 2. Replace `clampEnergy`. **The ceiling stops additions; it never pulls down a value already above it.** -That asymmetry is the whole fix, and it is not a nicety. `SYNCED_TABLES` in `servers/sharing/instance-sync.js` applies `ramble_settings` and `ramble_pet` **before** `ramble_wallet`, so during a pairing or backfill this instance can briefly compute a ceiling of 100 for a bird that legitimately sits at 150 on the instance that owns the heart rows. A symmetric clamp writes that 150 down to 100 — on the very next `/api/ramble/area` post, because `feedActivity({ type: "visit_place" })` runs before the heart pickup — and last-writer-wins then carries the loss *back* to the instance it came from. Nothing can restore it. Spec §7: nothing in this design destroys player progress. +That asymmetry is the whole fix, and it is not a nicety. Sync gives **no ordering guarantee at all** between a pet row and the wallet rows that justify its energy: `_applyEntry` in `servers/sharing/instance-sync.js` applies one entry at a time in feed-arrival order, and `SYNCED_TABLES` is only ever an allowlist (`.includes(table)`), never an iteration order. So during a pairing or backfill this instance can perfectly well hold a synced pet row at energy 150 while it still has no heart rows and computes a ceiling of 100. A symmetric clamp writes that 150 down to 100 — on the very next `/api/ramble/area` post, because `feedActivity({ type: "visit_place" })` runs before the heart pickup — and last-writer-wins then carries the loss *back* to the instance it came from. Nothing can restore it. Spec §7: nothing in this design destroys player progress. A guard on `petState` alone is not enough and was rejected for that reason: `feed`, `doChore` and `eggs.js`'s hatch path all persist energy too, and the area post is a far hotter path than a `/pet` poll. Putting the asymmetry in the one function every writer already goes through closes all of them at once. @@ -2354,7 +2354,7 @@ Re-run after the revision, as the process requires. **Verdict: REVISE** — five |---|---|---| | 7 | `cells: []` is a **400** (`routes.js`: `b.cells.length === 0` fails validation), so three of the rewritten panel tests could never pass — and the vague-fix test, the one that proves issue 1's fix, would have passed *vacuously against a 400* if someone deleted its precondition. | All three posts drop `cells` entirely and pass `lat`/`lon`, the supported fallback form `walkTo()` itself uses. | | 8 | The "fresh" cell chosen for the `/zones` test, `gcpvj0d`, is `HERE_LAT`/`HERE_LON`'s own cell — the visit_place test walks it twice — so the `INSERT` was a no-op and the assertion survived only because `heartFor("gcpvj0d", { rate: 3 })` happens to miss. The lucky-hash dependency this plan bans, reintroduced by the fix for issue 5. | Moved to `wecnrmd` (Hong Kong, 22.2233/114.2283); no test in the file uses a latitude near it. The assertion now names the cell rather than counting. | -| 9 | `petState` persisting a downward clamp loses energy permanently on a sync backfill: `SYNCED_TABLES` applies `ramble_settings` and `ramble_pet` **before** `ramble_wallet`, so a synced-in pet at energy 150 gets written down to 100 in the window before this instance's heart rows arrive. | The clamp is applied to the **reported** value only; the stored value waits for the ledger, and only decay writes. The Task 3 test now asserts the stored 150 survives, and that restoring the setting restores the bar. | +| 9 | `petState` persisting a downward clamp loses energy permanently on a sync backfill: a synced-in pet at energy 150 gets written down to 100 in the window before this instance's heart rows arrive. (⚠ The reviewer and this plan both justified it by an apply ORDER in `SYNCED_TABLES`; see the correction below — the hazard is real, the stated mechanism was not.) | The clamp is applied to the **reported** value only; the stored value waits for the ledger, and only decay writes. The Task 3 test now asserts the stored 150 survives, and that restoring the setting restores the bar. | | 10 | `scripts/build-registry.js --check` does not exist — the script is `.mjs`. | Corrected. | **Suggestions adopted:** the seed and heart branches now share a single `refreshZones()` (walking into a cell holding both a regrown seed and a retroactive heart is the K2 upgrade-day case and would otherwise fetch `/zones` twice); `heart_source` threaded through the response so a regrown heart gets its own line instead of the once-ever heart's; `energy.max.per.heart` tightened to `>= 1`, since a zero makes every heart inert while the page still counts them; the unasserted `wildRate: 999999` precondition added at the one site that lacked a real assertion, for consistency with the file's own rule; Task 6's insertion point given as a line number (`static/ramble.js:423`, with `celebrateUnlock` at `:420` sitting between the two seed lines). @@ -2378,3 +2378,15 @@ The round-2 test could not have detected either hole: it froze `now`, so no deca **Also fixed:** a stale "two preconditions" claim in the second review's own record (only one real assertion was added; the other site got a comment attached to a pre-existing assertion); a false second clause in the `energy.max.per.heart >= 1` comment (at the defaults `atCap` is false when `perHeart` is 0, so the "as long as it goes" consequence needs `cap <= base` too); a superseded snippet left standing immediately above its own replacement, which an implementer could have applied and stopped; and `heart_source`, which was threaded but asserted nowhere — now covered in both the route test and the panel-source test, including the wild heart's copy. **⚠ For the Task 3 reviewer and the whole-branch review:** `clampEnergy`'s asymmetry is a redesign made after the last full plan review, not a patch that one vetted. Check it directly — every caller, and what each one persists. + +--- + +### Correction, made during execution (2026-09-09, Task 3) + +Both the second and third reviews, and this plan, justified `clampEnergy`'s asymmetry with an ordering claim: that sync applies `ramble_settings` and `ramble_pet` **before** `ramble_wallet`, citing their positions in `SYNCED_TABLES`. **That claim is false, and it had shipped in a code comment.** + +`SYNCED_TABLES` is referenced in exactly two places in `servers/sharing/instance-sync.js`, both `SYNCED_TABLES.includes(table)` membership checks — in `emitChange` and in `_applyEntry`. It is never iterated. `_applyEntry` applies one entry at a time in feed-arrival order, so the array position of `ramble_pet` relative to `ramble_wallet` has no bearing on anything. + +**The design stands unchanged.** The hazard is real; it is simply not an ordering hazard. There is no cross-table ordering guarantee whatsoever, so a pet row can be applied before the wallet rows that justify its energy — which is a *stronger* argument for the asymmetry than a fixed ordering would have been, not a weaker one. Fixed in `bundles/ramble/server/pet.js` as comments only, no logic and no test changes. + +**Why it was worth chasing rather than letting stand:** the claim was specific enough to be checkable. A future reader checks it, finds it false, concludes the hazard was imagined, and "simplifies" the clamp back to symmetric — which permanently destroys energy on any pairing or backfill, with last-writer-wins carrying the loss to every other instance. A wrong-but-checkable justification for a correct design is more dangerous than no justification at all. From 9623350ae6e950b4ec8f37aa90111958f1def9e3 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:17:01 -0500 Subject: [PATCH 10/15] ramble: a walk takes the heart, and the map says where the next one is --- bundles/ramble/panel/routes.js | 59 ++++++++-- bundles/ramble/server/server.js | 3 +- tests/ramble-panel.test.js | 187 ++++++++++++++++++++++++++++++++ tests/ramble-tools.test.js | 2 + 4 files changed, 243 insertions(+), 8 deletions(-) diff --git a/bundles/ramble/panel/routes.js b/bundles/ramble/panel/routes.js index 7d5a2163..8b094200 100644 --- a/bundles/ramble/panel/routes.js +++ b/bundles/ramble/panel/routes.js @@ -194,7 +194,7 @@ export default function rambleRouter(dashboardAuth, options = {}) { async function ensureLoaded(res) { if (!mods) { - const [dbMod, initMod, marksMod, gridMod, personaMod, anchorsMod, appRootMod, petMod, eggsMod, feedMod, flockMod, nestsMod, deliveryMod, tradesMod, aroundMod, zonesMod, cellsMod, walletMod] = await Promise.all([ + const [dbMod, initMod, marksMod, gridMod, personaMod, anchorsMod, appRootMod, petMod, eggsMod, feedMod, flockMod, nestsMod, deliveryMod, tradesMod, aroundMod, zonesMod, cellsMod, walletMod, heartsMod] = await Promise.all([ bundleImport("server/db.js"), bundleImport("server/init-tables.js"), bundleImport("server/marks.js"), @@ -213,16 +213,18 @@ export default function rambleRouter(dashboardAuth, options = {}) { bundleImport("server/zones.js"), bundleImport("server/cells.js"), bundleImport("server/wallet.js"), + bundleImport("server/hearts.js"), ]).catch((err) => { console.warn(`[ramble routes] bundle modules unavailable: ${err.message}`); return []; }); if (!dbMod || !initMod || !marksMod || !gridMod || !personaMod || !anchorsMod || !appRootMod || !petMod || - !eggsMod || !feedMod || !flockMod || !nestsMod || !deliveryMod || !tradesMod || !aroundMod || !zonesMod || !cellsMod || !walletMod) { + !eggsMod || !feedMod || !flockMod || !nestsMod || !deliveryMod || !tradesMod || !aroundMod || !zonesMod || + !cellsMod || !walletMod || !heartsMod) { res.status(500).json({ error: "ramble bundle modules not available" }); return false; } - mods = { dbMod, initMod, marksMod, gridMod, personaMod, anchorsMod, petMod, eggsMod, feedMod, flockMod, nestsMod, deliveryMod, tradesMod, aroundMod, zonesMod, cellsMod, walletMod, appImport: appRootMod.appImport }; + mods = { dbMod, initMod, marksMod, gridMod, personaMod, anchorsMod, petMod, eggsMod, feedMod, flockMod, nestsMod, deliveryMod, tradesMod, aroundMod, zonesMod, cellsMod, walletMod, heartsMod, appImport: appRootMod.appImport }; } if (!db) { db = mods.dbMod.createDbClient(); @@ -713,6 +715,8 @@ export default function rambleRouter(dashboardAuth, options = {}) { let unlockedNow = null; let seedPicked = 0; + let heartPicked = 0; + let heartSource = null; if (here) { // Geohash-7 (spec §2.1) — the credit key's period is the ISO week, so // the same real place only ever counts once a week no matter how many @@ -733,17 +737,45 @@ export default function rambleRouter(dashboardAuth, options = {}) { if (!out.unlocked && out.cell) { seedPicked = (await mods.walletMod.recordSeedPickup(db, cell, { now: Date.now(), emit })).amount; } + // 2026-09-08 §2.3: hearts are tried on EVERY fix, not only a first + // unlock. A first unlock grants on the spot (the cell was fogged, so it + // is a surprise); a cell unlocked long ago whose heart was never taken + // pays when the player walks back to it, which is what makes the pips on + // their existing map real destinations. + // + // ⚠ `out.cell`, NOT `cell`. recordUnlock nulls its cell when the fix is + // vaguer than unlock.max.accuracy.m, and that is the ONLY thing standing + // between a 2 km wifi fix and the heart sitting in a cell unlocked months + // ago — recordHeartPickup's own in-ramble_cells check passes happily for + // ground that is already unlocked, which is most of the ground that still + // holds a heart. + if (out.cell) { + const got = await mods.heartsMod.recordHeartPickup(db, out.cell, { now: Date.now(), emit }); + heartPicked = got.amount; + // The panel says a different line for a once-ever heart and one that + // regrew, so the source has to survive the trip. + heartSource = got.source || null; + } } poke("ramble:area"); - // `seed` rides ONLY on a post that carried a fix. An area post without - // `here` keeps its historical response shape byte for byte, which is what - // the existing "writes local.active_area" test asserts with a deepEqual. + // `seed` and the heart fields ride ONLY on a post that carried a fix. An + // area post without `here` keeps its historical response shape byte for + // byte, which is what the existing "writes local.active_area" test + // asserts with a deepEqual. res.json({ cells, ...(unlockedNow ? { unlocked: unlockedNow } : {}), ...(seedPicked ? { seed_picked: seedPicked } : {}), + ...(heartPicked ? { heart_picked: heartPicked, heart_source: heartSource } : {}), ...(here ? { seed: await mods.walletMod.seedBalance(db) } : {}), + ...(here ? { + hearts: await mods.heartsMod.heartsBalance(db), + energy_max: await mods.heartsMod.maxEnergy(db), + // The ceiling's ceiling, so the panel can tell "the bar grew" from + // "the bar is as long as it goes" and say the right thing (Task 6). + energy_max_cap: (await mods.heartsMod.readHeartSettings(db)).cap, + } : {}), }); })); @@ -767,7 +799,14 @@ 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 }); - res.json({ ...pet, bird, egg: { percent: egg.egg.percent }, seed: await mods.walletMod.seedBalance(db) }); + res.json({ + ...pet, + bird, + egg: { percent: egg.egg.percent }, + seed: await mods.walletMod.seedBalance(db), + hearts: await mods.heartsMod.heartsBalance(db), + energy_max_cap: (await mods.heartsMod.readHeartSettings(db)).cap, + }); })); router.post("/api/ramble/pet/chore", handle(async (req, res) => { @@ -883,11 +922,16 @@ export default function rambleRouter(dashboardAuth, options = {}) { // long history would be sent thousands of pip footprints to throw away — // and we would run the ledger query to build them. let seed = []; + let hearts = []; if (req.query?.pips === "1") { // Read the cell ids BEFORE coalescing: a merged run is not one cell. // Points now, not cells: seed sits at a hash-derived spot inside its // cell, so a row of it along a street does not look like a pegboard. seed = await mods.walletMod.harvestableCells(db, out.unlocked.map((b) => b.cell), { now: Date.now() }); + // The SAME rule the payout uses (hearts.js: availableHearts and + // recordHeartPickup both go through heartCandidates). A heart drawn here + // that a walk would not grant is the phase 1 seed defect all over again. + hearts = await mods.heartsMod.availableHearts(db, out.unlocked.map((b) => b.cell), { now: Date.now() }); } // Coalesce the AREA geometry. The mask only needs the shape, and a walked // town collapses from thousands of boxes to a few dozen — see coalesceBoxes. @@ -895,6 +939,7 @@ export default function rambleRouter(dashboardAuth, options = {}) { unlocked: mods.zonesMod.coalesceBoxes(out.unlocked), frontier: mods.zonesMod.coalesceBoxes(out.frontier), seed, + hearts, depth, }); })); diff --git a/bundles/ramble/server/server.js b/bundles/ramble/server/server.js index 3333d72c..acbf30f8 100644 --- a/bundles/ramble/server/server.js +++ b/bundles/ramble/server/server.js @@ -26,6 +26,7 @@ import { createMark, listMarks, unlockMark, blockPersona, unblockPersona } from 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 { feedAll } from "./feed.js"; import { flockState, listNests, claimNest } from "./flock.js"; @@ -302,7 +303,7 @@ 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, egg: { percent: egg.egg.percent } })); + return text(JSON.stringify({ ...state, bird, hearts: await heartsBalance(db), egg: { percent: egg.egg.percent } })); } catch (err) { return errorText(err.message); } diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index 6d285618..4500e1c0 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -1615,3 +1615,190 @@ test("GET /api/ramble/zones answers a world-sized bbox instead of refusing it", const out = await res.json(); assert.ok(Array.isArray(out.unlocked) && Array.isArray(out.frontier)); }); + + +/* --- Phase 2: heart containers (spec §2.3, §3). --- */ + +/** + * ⚠ THIS FILE SHARES ONE SCRATCH DATABASE ACROSS EVERY TEST, so heart counts + * accumulate as tests run and an unlock is permanent for every test after it. + * Assert DELTAS, never absolute totals — an absolute assertion here passes + * alone and fails in the suite, which is exactly the flake shape this repo has + * hunted before. + * + * rate 1 so every cell in these tests holds a heart: no test may depend on a + * cell that happens to hash lucky (the phase 1 lesson). + */ +async function withHeartSettings(pairs, fn) { + const db = createDbClient(); + try { + for (const [k, v] of pairs) { + // eslint-disable-next-line no-await-in-loop + await db.execute({ + sql: `INSERT INTO ramble_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + args: [k, v], + }); + } + return await fn(); + } finally { + for (const [k] of pairs) { + // eslint-disable-next-line no-await-in-loop + await db.execute({ sql: "DELETE FROM ramble_settings WHERE key = ?", args: [k] }); + } + db.close(); + } +} + +// `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. +const HEARTS_ON = [ + ["heart.rate", "1"], ["heart.wild.rate", "999999"], + ["unlock.max.accuracy.m", "100"], ["warmth.visit_place", "0"], +]; +const jsonOf = async (path, opts) => (await req(path, opts)).json(); + +test("POST /api/ramble/area grants a heart on a first unlock, and reports the new ceiling", async () => { + await withHeartSettings(HEARTS_ON, async () => { + const here = { lat: 30.2672, lon: -97.7431, accuracy_m: 20 }; + const before = await jsonOf("/api/ramble/pet"); + + const first = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"], here } }); + assert.equal(first.heart_picked, 1, "the fogged cell had a heart in it"); + assert.equal(first.hearts, before.hearts + 1); + assert.equal(first.energy_max, before.energy_max + 10, "the bar grew by energy.max.per.heart"); + + const again = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"], here } }); + // Holds only because HEARTS_ON silences the wild source at rate 999999 — + // otherwise the same cell could pay a second, regrown heart. + assert.equal(again.heart_picked, undefined, "a permanent heart is taken once, ever"); + assert.equal(first.heart_source, "first", "and the source rides along, so the panel can say the right line"); + assert.equal(again.hearts, first.hearts, "the count still rides on every fix"); + assert.equal(again.energy_max, first.energy_max); + }); +}); + +test("a fix too vague to unlock is also too vague to pay a heart", async () => { + await withHeartSettings(HEARTS_ON, async () => { + const before = (await jsonOf("/api/ramble/pet")).hearts; + const res = await jsonOf("/api/ramble/area", { + method: "POST", + // `cells` is only the active-area list; the cell that matters is derived + // from `here` (Chicago -> dp3wjzt), which is fresh ground for this file. + body: { cells: ["dp3wjzt"], here: { lat: 41.8781, lon: -87.6298, accuracy_m: 2000 } }, + }); + assert.equal(res.unlocked, undefined, "no unlock"); + assert.equal(res.heart_picked, undefined, "and therefore no heart"); + assert.equal((await jsonOf("/api/ramble/pet")).hearts, before, "nothing was granted"); + }); +}); + +test("a vague fix cannot harvest a heart from ground unlocked LONG AGO", async () => { + // ⚠ The one the plan review caught. The in-ramble_cells check passes for an + // already-unlocked cell no matter how bad today's fix is, so this is the case + // the "fail closed" claim actually has to survive. Unlock the cell sharply + // while it holds no heart, then make it hold one, then arrive vaguely. + const here = { lat: 35.6762, lon: 139.6503 }; // Tokyo: fresh ground for this file + await withHeartSettings( + [["heart.rate", "999999"], ["heart.wild.rate", "999999"], ["unlock.max.accuracy.m", "100"], ["warmth.visit_place", "0"]], + async () => { + // ⚠ NO `cells: []` — routes.js rejects an empty array with a 400, and a + // 400 would make the assertions below pass vacuously against the buggy + // implementation. Omitting `cells` entirely is the supported form: the + // route falls back to lat/lon, exactly as walkTo() does. + const sharp = await jsonOf("/api/ramble/area", { + method: "POST", body: { ...here, here: { ...here, accuracy_m: 10 } }, + }); + assert.ok(sharp.unlocked, "precondition: the cell is unlocked, and held no heart"); + }, + ); + await withHeartSettings(HEARTS_ON, async () => { + const before = (await jsonOf("/api/ramble/pet")).hearts; + const vague = await jsonOf("/api/ramble/area", { + method: "POST", body: { ...here, here: { ...here, accuracy_m: 2000 } }, + }); + assert.equal(vague.heart_picked, undefined, + "a 2 km fix must not collect the heart now waiting in already-unlocked ground"); + assert.equal((await jsonOf("/api/ramble/pet")).hearts, before, "nothing was granted"); + }); +}); + +test("POST /api/ramble/area WITHOUT `here` keeps its exact historical shape", async () => { + const res = await jsonOf("/api/ramble/area", { method: "POST", body: { cells: ["9v6m2xt"] } }); + assert.deepEqual(res, { cells: ["9v6m2xt"] }, + "no fix, no currency: panning the map must not report a wallet"); +}); + +test("GET /api/ramble/zones?pips=1 draws hearts only in unlocked ground", async () => { + // ⚠ A GENUINELY FRESH cell, and a length assertion BEFORE the loop. Two + // earlier drafts got this wrong: the first reused Austin, whose heart the + // previous test had already collected, so `hearts` was always [] and the loop + // never ran; the second reused London, which is HERE_LAT/HERE_LON's own cell + // (`gcpvj0d`) and is walked twice by the visit_place test — that draft passed + // only because heartFor("gcpvj0d", {rate: 3}) happens to miss, which is the + // lucky-hash dependency this plan bans. + // + // wecnrmd = 22.2233/114.2283, Hong Kong. No test in this file uses a latitude + // anywhere near it (they use 10.5, 30.46, 48.8584 and 51.5074). + await withHeartSettings(HEARTS_ON, async () => { + const db = createDbClient(); + try { + await db.execute({ + sql: "INSERT INTO ramble_cells (cell, first_unlocked_at) VALUES ('wecnrmd', 1) ON CONFLICT(cell) DO NOTHING", + args: [], + }); + } finally { db.close(); } + + const bbox = "22.21,114.21,22.24,114.25"; + const withPips = await jsonOf("/api/ramble/zones?bbox=" + bbox + "&pips=1"); + assert.ok(Array.isArray(withPips.hearts), "the field is always an array"); + assert.ok(withPips.hearts.some((h) => h.cell === "wecnrmd"), + "there IS a heart to draw, or this test proves nothing"); + for (const h of withPips.hearts) { + assert.ok(withPips.unlocked.some((b) => + h.lat >= b.south && h.lat <= b.north && h.lon >= b.west && h.lon <= b.east), + "a heart pip only ever sits in unlocked ground"); + } + + const noPips = await jsonOf("/api/ramble/zones?bbox=" + bbox); + assert.deepEqual(noPips.hearts, [], "pips are a close-zoom detail; the client asks for them"); + }); +}); + +test("a heart in ground unlocked before this feature existed waits on the map, and pays when walked to", async () => { + // The K2 case, end to end: a row put straight into ramble_cells (exactly what + // phase 1's backfill left behind) still has its heart to walk back to. + await withHeartSettings(HEARTS_ON, async () => { + const db = createDbClient(); + try { + await db.execute({ + sql: "INSERT INTO ramble_cells (cell, first_unlocked_at) VALUES ('u33dc0e', 1) ON CONFLICT(cell) DO NOTHING", + args: [], + }); + } finally { db.close(); } + // u33dc0e decodes to 52.5181, 13.4081 (Berlin); this bbox contains it. + const zones = await jsonOf("/api/ramble/zones?bbox=52.50,13.35,52.54,13.46&pips=1"); + assert.equal(zones.hearts.filter((h) => h.cell === "u33dc0e").length, 1, + "a cell unlocked before this feature shipped still has its heart waiting"); + + // And walking there really does collect the pip the map just drew. + const before = (await jsonOf("/api/ramble/pet")).hearts; + const walked = await jsonOf("/api/ramble/area", { + method: "POST", body: { lat: 52.5181, lon: 13.4081, here: { lat: 52.5181, lon: 13.4081, accuracy_m: 15 } }, + }); + assert.equal(walked.heart_picked, 1, "the pip the map drew is the heart the walk grants"); + assert.equal(walked.hearts, before + 1); + const after = await jsonOf("/api/ramble/zones?bbox=52.50,13.35,52.54,13.46&pips=1"); + assert.equal(after.hearts.filter((h) => h.cell === "u33dc0e").length, 0, "and the pip is gone"); + }); +}); + +test("GET /api/ramble/pet carries the heart count and the ceiling", async () => { + const body = await jsonOf("/api/ramble/pet"); + assert.equal(typeof body.hearts, "number"); + assert.equal(typeof body.energy_max, "number"); + assert.equal(body.energy_max, 100 + body.hearts * 10, + "the ceiling is derived from the count the same response reports"); +}); diff --git a/tests/ramble-tools.test.js b/tests/ramble-tools.test.js index 79c67dca..b620a160 100644 --- a/tests/ramble-tools.test.js +++ b/tests/ramble-tools.test.js @@ -104,6 +104,8 @@ test("ramble_pet_state returns the pet's current state", async () => { assert.equal(typeof state.places_week, "number"); assert.equal(typeof state.unlocks_week, "number"); 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"); }); test("ramble_unlock on an in-range open mark feeds unlock_mark (unlocks_week increments)", async () => { From 970cebba2e9037a80c35d6b3d4c55656d6589d27 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:22:26 -0500 Subject: [PATCH 11/15] ramble: a heart container, drawn in the seed's own hand --- bundles/ramble/server/bird-svg.cjs | 15 ++++++++++++++- tests/ramble-bird-svg.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/bundles/ramble/server/bird-svg.cjs b/bundles/ramble/server/bird-svg.cjs index 711d05db..d7ac1a06 100644 --- a/bundles/ramble/server/bird-svg.cjs +++ b/bundles/ramble/server/bird-svg.cjs @@ -108,6 +108,19 @@ + ''; } function mountSeed(el) { el.setAttribute("viewBox", "0 0 24 24"); el.innerHTML = drawSeed(); } + /* The rare counterpart to the seed. Same warm family and the same highlight + * placement so the two read as one set, but deeper and richer, because this + * is the thing you go out of your way for. Flat fills only: it renders at + * 18px on a map and inline in a sentence on the pet page. */ + function drawHeart() { + return '' + + ''; + } + function mountHeart(el) { el.setAttribute("viewBox", "0 0 24 24"); el.innerHTML = drawHeart(); } function isValidBird(x) { return !!x && typeof x === "object" && ROSTER.indexOf(x.species) >= 0 && isUint32(x.seed); } - return { ROSTER: ROSTER, SPECIES: SPECIES, PARTS: PARTS, rollGenome: rollGenome, drawBird: drawBird, drawEgg: drawEgg, drawWalkingEgg: drawWalkingEgg, drawSeed: drawSeed, mountBird: mountBird, mountWalkingEgg: mountWalkingEgg, mountSeed: mountSeed, isValidBird: isValidBird }; + return { ROSTER: ROSTER, SPECIES: SPECIES, PARTS: PARTS, rollGenome: rollGenome, drawBird: drawBird, drawEgg: drawEgg, drawWalkingEgg: drawWalkingEgg, drawSeed: drawSeed, drawHeart: drawHeart, mountBird: mountBird, mountWalkingEgg: mountWalkingEgg, mountSeed: mountSeed, mountHeart: mountHeart, isValidBird: isValidBird }; }); diff --git a/tests/ramble-bird-svg.test.js b/tests/ramble-bird-svg.test.js index 87293b0e..36a9519f 100644 --- a/tests/ramble-bird-svg.test.js +++ b/tests/ramble-bird-svg.test.js @@ -75,3 +75,28 @@ test("drawSeed: a grain that reads as food, not as another UI dot", () => { assert.equal(el.attrs.viewBox, "0 0 24 24"); assert.equal(el.html, svg); }); + +test("drawHeart returns inert SVG children in the seed's own idiom", () => { + const svg = Bird.drawHeart(); + assert.equal(typeof svg, "string"); + assert.ok(svg.length > 0); + assert.ok(/ { + const calls = []; + const el = { + setAttribute: (k, v) => calls.push([k, v]), + set innerHTML(v) { calls.push(["innerHTML", v]); }, + }; + Bird.mountHeart(el); + assert.deepEqual(calls[0], ["viewBox", "0 0 24 24"], "same box as mountSeed, so the pips match in size"); + assert.equal(calls[1][0], "innerHTML"); + assert.equal(calls[1][1], Bird.drawHeart()); +}); From 9fd64cf51bad0d2caa758e588b6a262ad3f5c9c6 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:27:08 -0500 Subject: [PATCH 12/15] ramble: hearts on the map, and the bird says so when you find one --- bundles/ramble/panel/ramble.js | 1 + bundles/ramble/panel/static/ramble.css | 26 +++++++++ bundles/ramble/panel/static/ramble.js | 73 +++++++++++++++++++++++++- tests/ramble-panel.test.js | 32 +++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) diff --git a/bundles/ramble/panel/ramble.js b/bundles/ramble/panel/ramble.js index b4a44f98..e2e4c50a 100644 --- a/bundles/ramble/panel/ramble.js +++ b/bundles/ramble/panel/ramble.js @@ -124,6 +124,7 @@ export default { Visible: off 0seed + 0hearts
diff --git a/bundles/ramble/panel/static/ramble.css b/bundles/ramble/panel/static/ramble.css index 8e5bcede..8f4840cc 100644 --- a/bundles/ramble/panel/static/ramble.css +++ b/bundles/ramble/panel/static/ramble.css @@ -589,6 +589,7 @@ #ramble .rb-hatch .rb-hatch-bird, #ramble .rb-unlock-flash, #ramble .rb-here-pet.is-walking > *, + #ramble .rb-heart-pop, #ramble .rb-seed-pop { animation: none; } #ramble .rb-btn, #ramble .rb-ring-prg, @@ -883,6 +884,13 @@ #ramble .rb-seed-pip { display: grid; place-items: center; } #ramble .rb-seed-pip > svg { width: 18px; height: 18px; filter: drop-shadow(1px 2px 0 var(--rb-shadow-col)); } #ramble .rb-seed-dot { fill: var(--rb-accent-2); } +/* A heart container waiting in ground already unlocked. #d8556a is a literal + rather than a token deliberately: the fallback dot stands in for the + engine's heart when the engine did not load, so it has to be the engine's + own colour, not the seed's ochre accent. */ +#ramble .rb-heart-pip { display: grid; place-items: center; } +#ramble .rb-heart-pip > svg { width: 20px; height: 20px; filter: drop-shadow(1px 2px 0 var(--rb-shadow-col)); } +#ramble .rb-heart-dot { fill: #d8556a; } #ramble .rb-beacon { stroke: var(--rb-line); fill: var(--rb-accent-2); opacity: 0.7; } #ramble .rb-beacon-nest { fill: var(--rb-accent); } @@ -931,6 +939,24 @@ from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-16px); } } +#ramble .rb-hearts { + position: relative; /* anchors the +1 pop */ + display: inline-flex; align-items: center; gap: 5px; + padding: 6px 11px; border-radius: 999px; + border: var(--rb-line-w) solid var(--rb-line); + background: var(--rb-surface); color: var(--rb-text); + font: 800 13px var(--rb-font-display); +} +#ramble .rb-heart-pop { + position: absolute; left: 50%; bottom: 100%; + transform: translateX(-50%); + font-weight: 700; color: #d8556a; pointer-events: none; + animation: rb-heart-rise 1.4s ease-out forwards; +} +@keyframes rb-heart-rise { + from { opacity: 1; transform: translate(-50%, 0); } + to { opacity: 0; transform: translate(-50%, -1.6rem); } +} /* ---------------------------------------------- phase 5: near labels + art */ diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index 6398200b..03a56ed0 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -420,7 +420,14 @@ if (out && out.unlocked) celebrateUnlock(out.unlocked); /* A pickup is its own moment, and the pip it just consumed has to go — * an unlock repaints via celebrateUnlock, but a plain harvest does not. */ - if (out && out.seed_picked) { celebrateSeed(out.seed_picked); if (!out.unlocked) refreshZones(); } + if (out && out.seed_picked) celebrateSeed(out.seed_picked); + if (out && typeof out.hearts === "number") paintHearts(out.hearts); + if (out && out.heart_picked) { + celebrateHeart(!!out.unlocked, out.heart_source, out.energy_max === out.energy_max_cap); + } + /* One /zones fetch however many pips were just consumed. celebrateUnlock + * already refreshed on a first unlock, which is what the guard is for. */ + if (out && (out.seed_picked || out.heart_picked) && !out.unlocked) refreshZones(); refreshPet(); return refreshMarks(); }) @@ -476,6 +483,36 @@ if (el) el.textContent = String(n); } + /* The heart moment. A heart is rare enough to be worth saying out loud, so + * this does both: the number pops, and the bird speaks. sayMoment is the only + * thing that opens the bubble on its own, and an arrival is exactly what it + * is for. */ + function celebrateHeart(alsoUnlocked, source, atCap) { + var chip = $("rb-heart-count"); + if (chip && chip.parentNode) { + var pop = document.createElement("span"); + pop.className = "rb-heart-pop"; + pop.textContent = "+1"; + chip.parentNode.appendChild(pop); + setTimeout(function () { if (pop.parentNode) pop.parentNode.removeChild(pop); }, 1400); + } + /* ONE line, not two. celebrateUnlock has already said "New ground." on a + * first unlock, and sayMoment holds for 4200ms -- a second call overwrites + * the first, so the unlock moment would be erased every time a new cell + * also paid a heart, which is one arrival in three. When both happen, say + * the thing that covers both. */ + if (atCap) sayMoment("Another heart container. Your bird is as strong as it gets."); + else if (alsoUnlocked) sayMoment("New ground, and a heart container in it."); + else if (source === "wild") sayMoment("A heart container, grown here since you last came by."); + else sayMoment("A heart container. Your bird can hold more now."); + } + + function paintHearts(n) { + if (typeof n !== "number") return; + var el = $("rb-heart-count"); + if (el) el.textContent = String(n); + } + /* ---------------------------------------------------------------- marks */ /* The one label rule (spec 2026-09-08 §3.1), mirrored from server/labels.js: @@ -1517,6 +1554,7 @@ if (map.getZoom() >= MIN_CELL_DETAIL_ZOOM) { paintCells(out.frontier || [], "rb-frontier-cell"); paintSeedPips(out.seed || []); + paintHeartPips(out.hearts || []); } } @@ -1558,6 +1596,39 @@ } } + /* A heart container waiting in ground you have already unlocked: the rare + * counterpart to a seed pip, and the reason an existing player has somewhere + * to walk on the day this ships. Not interactive -- you collect it by walking + * there, exactly like seed. + * + * Each pip needs its OWN element: appending an Element MOVES it, so one + * shared node would leave a single heart hopping between cells. */ + function heartIcon() { + if (!Bird || typeof Bird.mountHeart !== "function") return null; + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + try { Bird.mountHeart(svg); } catch (e) { return null; } + var opts = { className: "rb-heart-pip", iconSize: [22, 22], iconAnchor: [11, 11] }; + opts.html = svg; /* an Element: Leaflet appends, so this is no markup sink */ + return L.divIcon(opts); + } + + function paintHeartPips(spots) { + for (var i = 0; i < spots.length; i++) { + var c = spots[i]; + if (!c || !isFinite(c.lat) || !isFinite(c.lon)) continue; + var ll = [c.lat, c.lon]; + var icon = heartIcon(); + if (icon) { + L.marker(ll, { pane: "rb-fog", icon: icon, interactive: false, keyboard: false }).addTo(zoneLayer); + } else { + L.circleMarker(ll, { + pane: "rb-fog", className: "rb-heart-dot", radius: 5, weight: 0, + fillOpacity: 0.95, interactive: false + }).addTo(zoneLayer); + } + } + } + function addHoles(holes, cells) { for (var i = 0; i < (cells || []).length; i++) { var c = cells[i]; diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index 4500e1c0..47985756 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -289,6 +289,9 @@ test("panel handler renders the world-first shell, its three views and every ass assert.ok(sent.includes('id="rb-perch-say"'), "the status strip stays"); assert.ok(sent.includes('id="rb-seed-count"'), "the map bar carries the seed counter"); + assert.ok(sent.includes('id="rb-heart-count"'), "the map bar carries the heart counter"); + assert.ok(sent.indexOf('id="rb-heart-count"') > sent.indexOf('id="rb-seed-count"'), + "common currency first, rare currency second"); }); // -------------------------------------------------------------- auth scoping @@ -907,6 +910,20 @@ test("GET /ramble/static/ramble.js serves the client script as JavaScript", asyn assert.ok(body.includes('opts.className = "rb-here-pet rb-here-plain"'), "a plain dot survives the bird engine failing to load"); }); +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"); + assert.ok(body.includes("function heartIcon()"), "pips carry the engine's heart art"); + assert.ok(body.includes("Bird.mountHeart(svg)"), "drawn by the shared engine, like every other creature part"); + assert.ok(body.includes("rb-heart-dot"), "and a plain dot survives the engine failing to load"); + assert.ok(body.includes("paintHeartPips(out.hearts || [])"), "fed from the server's own list"); + assert.ok(body.includes("out.heart_picked"), "the pickup is consumed from the area response"); + assert.ok(body.includes("out.heart_source"), "and a regrown heart gets its own line, not the once-ever one's"); + assert.ok(body.includes("grown here since you last came by"), "the wild heart's copy is actually there"); + assert.ok(body.includes("function paintHearts("), "the counter is painted from the area response"); + assert.equal(body.split("`").length - 1, 0, "the panel client must contain ZERO backticks"); +}); + test("GET /ramble/static/ramble.css serves the panel stylesheet", async () => { const res = await req("/ramble/static/ramble.css"); assert.equal(res.status, 200); @@ -973,6 +990,21 @@ test("GET /ramble/static/ramble.css serves the panel stylesheet", async () => { assert.ok(body.includes("#ramble .rb-seed {"), "the seed counter has a rule"); }); +test("heart pips, the fallback dot and the pop all have styles", async () => { + const css = await (await req("/ramble/static/ramble.css")).text(); + assert.ok(css.includes("#ramble .rb-heart-pip {")); + assert.ok(css.includes("#ramble .rb-heart-dot {")); + assert.ok(css.includes("#ramble .rb-hearts {"), "the map-bar counter has a rule"); + assert.ok(css.includes("#ramble .rb-heart-pop {")); + assert.ok(css.includes("@keyframes rb-heart-rise")); + // The heart pop joins the EXISTING comma-separated reduced-motion list, so + // match it as a member of that list rather than as its own rule. + assert.match(css, /prefers-reduced-motion[\s\S]*#ramble \.rb-heart-pop,[\s\S]*animation: none/, + "the pop respects reduced motion, like the seed pop already does"); + assert.ok(css.includes("#ramble .rb-heart-pip > svg {"), + "the pip's svg is SIZED — without this it renders at the CSS default 300x150"); +}); + test("GET /ramble/static/ramble-ar.js serves the renderer as JavaScript: zero backticks, zero markup sinks, no emoji, no capture APIs, classic script", async () => { const res = await req("/ramble/static/ramble-ar.js"); assert.equal(res.status, 200); From 6abafc9842e90ad5708cf510a73e619550e62fc3 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:31:37 -0500 Subject: [PATCH 13/15] ramble: the pet page shows the whole bar, and what lengthened it --- bundles/ramble/panel/ramble.js | 3 +- bundles/ramble/panel/static/ramble.css | 4 +++ bundles/ramble/panel/static/ramble.js | 43 +++++++++++++++++++++++++- tests/ramble-panel.test.js | 12 +++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/bundles/ramble/panel/ramble.js b/bundles/ramble/panel/ramble.js index e2e4c50a..c8f19d22 100644 --- a/bundles/ramble/panel/ramble.js +++ b/bundles/ramble/panel/ramble.js @@ -250,8 +250,9 @@ export default {
Energy - + / 100
+

Checking on it…

diff --git a/bundles/ramble/panel/static/ramble.css b/bundles/ramble/panel/static/ramble.css index 8f4840cc..cdfdfd00 100644 --- a/bundles/ramble/panel/static/ramble.css +++ b/bundles/ramble/panel/static/ramble.css @@ -509,6 +509,10 @@ overflow: hidden; } #ramble .rb-meter-bar i { display: block; height: 100%; width: 0; background: var(--rb-accent-2); transition: width .4s ease; } +#ramble .rb-meter-of { color: var(--rb-muted); font-size: 0.85rem; } +#ramble .rb-hearts-line { display: flex; align-items: center; gap: 0.45rem; margin: 0.35rem 0 0; flex-wrap: wrap; } +#ramble .rb-heart-row { display: inline-flex; gap: 0.15rem; } +#ramble .rb-heart-one { width: 16px; height: 16px; display: block; } #ramble .rb-chores { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } #ramble .rb-chore { diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index 03a56ed0..529c4172 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -513,6 +513,35 @@ if (el) el.textContent = String(n); } + /* The containers themselves, above the bar they lengthened -- the number + * alone never explained where the extra bar came from. Capped at a row that + * still fits a phone; past that the sentence carries the count. */ + var HEART_ROW_MAX = 10; + + function paintHeartRow(n, max, cap) { + var row = $("rb-heart-row"); + if (!row) return; + while (row.firstChild) row.removeChild(row.firstChild); + var shown = Math.max(0, Math.min(HEART_ROW_MAX, n)); + for (var i = 0; i < shown; i++) { + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "rb-heart-one"); + if (Bird && typeof Bird.mountHeart === "function") { + try { Bird.mountHeart(svg); } catch (e) { /* cosmetic */ } + } + row.appendChild(svg); + } + var line = $("rb-heart-line"); + if (!line) return; + /* At the cap the bar cannot grow again, and saying nothing about that would + * leave the player collecting pips that change no number they can see. */ + if (typeof cap === "number" && typeof max === "number" && n > 0 && max >= cap) { + setText(line, n + " heart containers. The bar is as long as it goes."); + } else if (n <= 0) setText(line, "No heart containers yet. Walk somewhere new."); + else if (n === 1) setText(line, "One heart container."); + else setText(line, n + " heart containers."); + } + /* ---------------------------------------------------------------- marks */ /* The one label rule (spec 2026-09-08 §3.1), mirrored from server/labels.js: @@ -1273,9 +1302,21 @@ } var energy = typeof pet.energy === "number" ? pet.energy : 0; + /* Against the server's OWN ceiling. Drawing a percentage of a hardcoded 100 + * would paint a 150-energy bird at 150% and a 70-of-150 bird as nearly + * full: the bar has to read the same number the server clamps with. */ + var max = typeof pet.energy_max === "number" && pet.energy_max > 0 ? pet.energy_max : 100; var fill = $("rb-energy-fill"); - if (fill) fill.style.width = Math.max(0, Math.min(100, energy)) + "%"; + if (fill) fill.style.width = Math.max(0, Math.min(100, (energy / max) * 100)) + "%"; setText($("rb-energy-num"), String(energy)); + setText($("rb-energy-max"), String(max)); + var hearts = typeof pet.hearts === "number" ? pet.hearts : 0; + paintHeartRow(hearts, max, pet.energy_max_cap); + /* The map bar too, not only this page: the area response carries a wallet + * ONLY when it carried a position fix, so a player who denies geolocation + * would otherwise read 0 hearts on the map forever. paintSeed is called + * from here for exactly this reason. */ + paintHearts(hearts); setText($("rb-mood-line"), MOOD_LINE[pet.mood] || MOOD_LINE.happy); var chores = pet.chores || {}; diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index 47985756..d7efffce 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -231,6 +231,8 @@ test("panel handler renders the world-first shell, its three views and every ass assert.ok(sent.includes(src), `pet page names the energy source: ${src}`); } assert.ok(sent.includes("Getting out is worth more than tapping."), "the page says walking beats tapping"); + assert.ok(sent.includes('id="rb-energy-max"'), "the bar's ceiling is on the page"); + assert.ok(sent.includes('id="rb-heart-row"'), "and the containers that set it"); // The legacy ids are GONE — anything still selecting them is broken. assert.doesNotMatch(sent, /id="ramble-map"/); @@ -1834,3 +1836,13 @@ test("GET /api/ramble/pet carries the heart count and the ceiling", async () => assert.equal(body.energy_max, 100 + body.hearts * 10, "the ceiling is derived from the count the same response reports"); }); + +test("the energy bar is drawn against the server's ceiling, not a hardcoded 100", async () => { + const body = await (await req("/ramble/static/ramble.js")).text(); + assert.ok(body.includes("pet.energy_max"), "the painter reads the ceiling the server clamped with"); + assert.ok(!body.includes('Math.min(100, energy)) + "%"'), "the old hardcoded-100 bar is gone"); + assert.ok(body.includes("(energy / max) * 100"), "the bar is a fraction of the real ceiling"); + assert.ok(body.includes("function paintHeartRow("), "the pet page shows the containers themselves"); + assert.ok(body.includes("paintHearts(hearts)"), + "the map-bar counter is painted from the pet read too, not only from a position fix"); +}); From d2b984b49fe10c839251e190d5f3cb2a4896b969 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:35:30 -0500 Subject: [PATCH 14/15] ramble 0.10.0: heart containers --- bundles/ramble/manifest.json | 2 +- bundles/ramble/package.json | 2 +- docs/es/guide/ramble.md | 10 ++++++++-- docs/guide/ramble.md | 10 ++++++++-- registry/add-ons.json | 2 +- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/bundles/ramble/manifest.json b/bundles/ramble/manifest.json index da867c72..a487759b 100644 --- a/bundles/ramble/manifest.json +++ b/bundles/ramble/manifest.json @@ -1,7 +1,7 @@ { "id": "ramble", "name": "Ramble", - "version": "0.9.5", + "version": "0.10.0", "type": "mcp-server", "author": "Crow", "category": "social", diff --git a/bundles/ramble/package.json b/bundles/ramble/package.json index 5b3ee7ee..09a5baba 100644 --- a/bundles/ramble/package.json +++ b/bundles/ramble/package.json @@ -1,6 +1,6 @@ { "name": "crow-ramble", - "version": "0.9.5", + "version": "0.10.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 8c4e62c6..d0c8d78e 100644 --- a/docs/es/guide/ramble.md +++ b/docs/es/guide/ramble.md @@ -113,7 +113,7 @@ Camina hasta quedar a menos de **75 m** de un nido y toca **Tomar el huevo** (`P Siempre incuba exactamente un huevo. Desde la pantalla **Bandada** puedes **incubar** cualquier huevo del estante (`POST /api/ramble/eggs/:id/incubate`); el que reemplaza pasa al estante conservando su calor. Instance sync distingue un huevo que *tú* aparcaste (`shelf_origin = 'user'`) de uno que la capa de sincronización dejó en el estante al reconciliar dos instancias (`'sync'`): solo este último se recupera automáticamente a la ranura de incubación. -**El mapa se desbloquea al caminar.** El terreno donde realmente has estado queda desbloqueado para siempre: puedes leer las marcas y los caws que hay allí y recoger el huevo de cualquier nido. Unas manzanas más allá está la frontera, donde ves que algo te espera sin ver qué es. Todo lo demás es niebla hasta que vayas. Solo el mapa público funciona así: la marca de un contacto siempre te llega, estés donde estés. Caminar por terreno que ya desbloqueaste hace aparecer **alpiste**, que vuelve a crecer al cabo de un día. El alpiste aparece en aproximadamente una de cada cuatro celdas despejadas, en un punto dentro de ella, así que un paseo tiene unos pocos lugares a los que merece la pena ir en vez de uno en cada cuadro; lo recoges al pasar por allí, no al tocarlo. Aleja el mapa para ver la forma completa del terreno que has despejado. +**El mapa se desbloquea al caminar.** El terreno donde realmente has estado queda desbloqueado para siempre: puedes leer las marcas y los caws que hay allí y recoger el huevo de cualquier nido. Unas manzanas más allá está la frontera, donde ves que algo te espera sin ver qué es. Todo lo demás es niebla hasta que vayas. Solo el mapa público funciona así: la marca de un contacto siempre te llega, estés donde estés. Caminar por terreno que ya desbloqueaste hace aparecer **alpiste**, que vuelve a crecer al cabo de un día. El alpiste aparece en aproximadamente una de cada cuatro celdas despejadas, en un punto dentro de ella, así que un paseo tiene unos pocos lugares a los que merece la pena ir en vez de uno en cada cuadro; lo recoges al pasar por allí, no al tocarlo. Aleja el mapa para ver la forma completa del terreno que has despejado. De vez en cuando un lugar nuevo también contiene un **contenedor de corazón**, que alarga de forma permanente la barra de energía de tu pájaro — aproximadamente uno de cada tres lugares la primera vez que entras en él, y con mucha menos frecuencia en terreno que ya has despejado. Los corazones son lo único que eleva el máximo; le dan a tu pájaro un margen más largo entre paseos antes de que decaiga, y nada más. Un corazón que no has recogido aparece en el mapa allí donde te espera, así que los lugares que despejaste antes de que existieran los corazones merecen la pena volver a caminarlos. Lo recoges caminando hasta él, igual que el alpiste. ## Tu bandada @@ -195,6 +195,12 @@ Cada peso de la tabla anterior es también un override de `ramble_settings`, le | `seed.rate` | 4 | Aproximadamente una de cada tantas celdas despejadas lleva alpiste (entero ≥ 1). Menos significa más denso. | | `seed.respawn.hours` | 24 | Cuánto tarda el alpiste en volver a aparecer en un lugar. | | `seed.per.pickup` | 1 | Cuánto alpiste da un lugar. | +| `heart.rate` | 3 | Aproximadamente uno de cada tantos lugares contiene un contenedor de corazón la primera vez que entras en él (entero ≥ 1). | +| `heart.wild.days` | 30 | Cuánto tarda un corazón en poder reaparecer en terreno que ya has despejado. | +| `heart.wild.rate` | 40 | Aproximadamente uno de cada tantos lugares despejados contiene ese corazón que reaparece (entero ≥ 1). | +| `energy.max.base` | 100 | La longitud de la barra de energía sin ningún contenedor de corazón. | +| `energy.max.per.heart` | 10 | Cuánto alarga la barra cada contenedor de corazón. | +| `energy.max.cap` | 300 | Lo máximo que puede llegar a medir la barra, por muchos corazones que encuentres. | | `unlock.max.accuracy.m` | 100 | Qué tan precisa debe ser tu ubicación para que un lugar cuente como visitado. | ## Herramientas MCP @@ -234,7 +240,7 @@ La segunda significa que el bundle está instalado pero nunca se publicará ni s El límite de una recogida por día y el tope del estante se comprueban por instancia (las recogidas no se replican), así que un usuario con dos Crows puede recoger un huevo por día en cada una. -El mapa de los lugares que has desbloqueado, y tu saldo de alpiste, se replican a tus propios Crows enlazados, y nunca llegan a un contacto. +El mapa de los lugares que has desbloqueado, y tus saldos de alpiste y de corazones, se replican a tus propios Crows enlazados, y nunca llegan a un contacto. El tope solo limita las recogidas. Incubar un huevo que la capa de sincronización había dejado aparcado (`shelf_origin='sync'`) manda al estante el huevo que reemplaza sin que nada salga de él, así que el estante puede leer brevemente `6 de 5`; se estabiliza a medida que eclosionas huevos. diff --git a/docs/guide/ramble.md b/docs/guide/ramble.md index 4ce9b910..a95952b4 100644 --- a/docs/guide/ramble.md +++ b/docs/guide/ramble.md @@ -113,7 +113,7 @@ Walk within **75 m** of a nest and tap **Take the egg** (`POST /api/ramble/nests Exactly one egg incubates at a time. From the **Flock** screen you can **incubate** any shelf egg (`POST /api/ramble/eggs/:id/incubate`); the one it replaces goes to the shelf keeping its warmth. Instance sync distinguishes an egg *you* parked (`shelf_origin = 'user'`) from one the sync layer shelved while reconciling two instances (`'sync'`): only the latter is ever pulled back into the incubating slot automatically. -**The map unlocks as you walk.** Ground you have actually stood in stays unlocked for good: you can read the marks and caws left there and claim any nest. A few blocks further out is the frontier, where you can see that something is waiting without seeing what it is. Everything beyond that is fog until you go there. Only the public map works this way — a contact's mark always reaches you wherever you are. Walking ground you have already unlocked turns up **bird seed**, which regrows after a day. Seed appears in about one cleared cell in four, at a spot inside it, so a walk has a handful of places worth heading for rather than one in every square; you collect it by walking there, not by tapping. Zoom out to see the whole shape of the ground you have cleared. +**The map unlocks as you walk.** Ground you have actually stood in stays unlocked for good: you can read the marks and caws left there and claim any nest. A few blocks further out is the frontier, where you can see that something is waiting without seeing what it is. Everything beyond that is fog until you go there. Only the public map works this way — a contact's mark always reaches you wherever you are. Walking ground you have already unlocked turns up **bird seed**, which regrows after a day. Seed appears in about one cleared cell in four, at a spot inside it, so a walk has a handful of places worth heading for rather than one in every square; you collect it by walking there, not by tapping. Zoom out to see the whole shape of the ground you have cleared. Now and then a new place also holds a **heart container**, which permanently lengthens your bird's energy bar — about one place in three the first time you enter it, and much more rarely in ground you have already cleared. Hearts are the only thing that raises the maximum; they buy your bird a longer stretch between walks before it droops, and nothing else. A heart you have not collected shows on the map wherever it is waiting, so places you cleared before hearts existed are worth walking again. You collect one by walking to it, the same way you collect seed. ## Your flock @@ -195,6 +195,12 @@ Every weight from the table above is also a `ramble_settings` override, read liv | `seed.rate` | 4 | About one cleared cell in this many carries seed (integer ≥ 1). Lower means denser. | | `seed.respawn.hours` | 24 | How long before bird seed regrows in a place. | | `seed.per.pickup` | 1 | How much seed a place gives. | +| `heart.rate` | 3 | About one place in this many holds a heart container the first time you enter it (integer ≥ 1). | +| `heart.wild.days` | 30 | How long before a heart may reappear in ground you have already cleared. | +| `heart.wild.rate` | 40 | About one cleared place in this many holds that reappearing heart (integer ≥ 1). | +| `energy.max.base` | 100 | The energy bar's length with no heart containers. | +| `energy.max.per.heart` | 10 | How much each heart container lengthens it. | +| `energy.max.cap` | 300 | The longest the bar can ever get, however many hearts you find. | | `unlock.max.accuracy.m` | 100 | How sharp your location has to be before a place counts as visited. | ## MCP tools @@ -234,7 +240,7 @@ The second line means the bundle is installed but nothing will ever be published The one-claim-per-day limit and the shelf cap are checked per instance (claims do not replicate), so a user with two Crows can claim once per day on each. -The map of places you have unlocked, and your seed balance, replicate to your own linked Crows, and they never go to a contact. +The map of places you have unlocked, and your seed and heart balances, replicate to your own linked Crows, and they never go to a contact. The cap only gates claims. Incubating an egg the sync layer had parked (`shelf_origin='sync'`) moves the egg it replaces to your own shelf without anything leaving, so the shelf can briefly read `6 of 5`; it settles as you hatch. diff --git a/registry/add-ons.json b/registry/add-ons.json index 6155c287..e404cc20 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -4939,7 +4939,7 @@ { "id": "ramble", "name": "Ramble", - "version": "0.9.5", + "version": "0.10.0", "type": "mcp-server", "author": "Crow", "category": "social", From 65dc503dbb0806fe233ba9b728d89a638b67338c Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 11:57:10 -0500 Subject: [PATCH 15/15] ramble: credit the heart before the walk, so the bar never shrinks on the find --- bundles/ramble/panel/routes.js | 13 +++++++++- bundles/ramble/panel/static/ramble.js | 7 ++++-- servers/sharing/instance-sync.js | 7 ++++++ tests/ramble-panel.test.js | 36 +++++++++++++++++++++++++++ tests/ramble-pet.test.js | 14 ++++++++--- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/bundles/ramble/panel/routes.js b/bundles/ramble/panel/routes.js index 8b094200..1e532f19 100644 --- a/bundles/ramble/panel/routes.js +++ b/bundles/ramble/panel/routes.js @@ -722,7 +722,6 @@ export default function rambleRouter(dashboardAuth, options = {}) { // the same real place only ever counts once a week no matter how many // times the panel posts its position. const cell = mods.anchorsMod.encodeGeohash(here.lat, here.lon, 7); - await feedActivity({ type: "visit_place", cell }); // 2026-09-08 §2.1: standing in a cell unlocks it, permanently. Reported // back only on the FIRST unlock so the panel celebrates once, not on // every position post. `emit` is what makes the row replicate. @@ -756,6 +755,18 @@ export default function rambleRouter(dashboardAuth, options = {}) { // regrew, so the source has to survive the trip. heartSource = got.source || null; } + // 2026-09-09 fix-wave: feedActivity — which clamps the pet's energy + // against maxEnergy(db) — runs AFTER the heart pickup above, not before. + // maxEnergy is derived live from the hearts wallet (hearts.js), so + // crediting this walk's +15 against the OLD ceiling and only raising the + // ceiling afterward made the energy bar visibly SHRINK (100/100 -> + // 100/110) at the exact moment a heart was found. feedAll's warmth/hatch + // path (creditWarmth, onHatch) touches ramble_eggs/ramble_credits/ + // ramble_pet only — nothing in it reads ramble_cells or ramble_wallet — + // so moving it after the unlock/seed/heart writes changes no other + // outcome: the same hatch still fires off the same event, and the + // response above never reads feedActivity's return value. + await feedActivity({ type: "visit_place", cell }); } poke("ramble:area"); diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index 529c4172..e894701a 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -526,10 +526,13 @@ for (var i = 0; i < shown; i++) { var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("class", "rb-heart-one"); + var drew = false; if (Bird && typeof Bird.mountHeart === "function") { - try { Bird.mountHeart(svg); } catch (e) { /* cosmetic */ } + try { Bird.mountHeart(svg); drew = true; } catch (e) { /* cosmetic */ } } - row.appendChild(svg); + /* A failed mount must not leave an empty, invisible 16px gap in the row -- + * only append the element when the engine actually drew into it. */ + if (drew) row.appendChild(svg); } var line = $("rb-heart-line"); if (!line) return; diff --git a/servers/sharing/instance-sync.js b/servers/sharing/instance-sync.js index d7487846..bb673107 100644 --- a/servers/sharing/instance-sync.js +++ b/servers/sharing/instance-sync.js @@ -596,6 +596,13 @@ export async function applyRambleCell(db, op, row, lamportTs) { * spend row must be keyed uniquely by the PURCHASE, never by something as * coarse as cell:window, so two instances can never compute two different * amounts for one key. Do not make this conflict rule arbitrate money. + * + * `heart.wild.days` is a live, replicated setting, and changing it renumbers + * the wild heart key's windows (`cell:floor(now / days)`) — retuning 30 to 60 + * re-buckets them, so a wild key already taken can fall outside the new + * window and that cell can pay a wild heart again. Both instances converge + * (the setting itself replicates), so this is a re-earn on retune, not a + * divergence — the same class as `seed.respawn.hours`. */ export async function applyRambleWallet(db, op, row, lamportTs) { if (!row || !row.kind || !row.key) return; diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index d7efffce..a86ab8fb 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -1714,6 +1714,42 @@ test("POST /api/ramble/area grants a heart on a first unlock, and reports the ne }); }); +test("fix-wave 2026-09-09: the heart's new ceiling is what THIS WALK's energy lands against", async () => { + await withHeartSettings(HEARTS_ON, async () => { + // Moscow: fresh ground for this file, guaranteed to hold a heart at + // HEARTS_ON's rate 1. + const here = { lat: 55.7558, lon: 37.6173, accuracy_m: 20 }; + const before = await jsonOf("/api/ramble/pet"); + + // Pin the bird at the OLD ceiling before the walk. Starting anywhere below + // it would clamp to the same number whether the heart's new ceiling was + // applied before or after the walk's +15 — only starting AT the ceiling + // makes the old (buggy) order and the fixed order produce different, + // observable results. + const db = createDbClient(); + try { + await db.execute({ sql: "UPDATE ramble_pet SET energy = ? WHERE owner = 'self'", args: [before.energy_max] }); + } finally { + db.close(); + } + + const out = await jsonOf("/api/ramble/area", { method: "POST", body: { ...here, here } }); + assert.equal(out.heart_picked, 1, "this test proves nothing without a heart in fresh ground"); + assert.equal(out.energy_max, before.energy_max + 10, "the ceiling rose by energy.max.per.heart"); + + const after = await jsonOf("/api/ramble/pet"); + assert.equal(after.energy_max, before.energy_max + 10); + // The bug this fix closes: crediting the walk's +15 against the OLD + // ceiling and only raising the ceiling afterward left the bar reading + // `energy: 100, energy_max: 110` — visibly SHRUNK at the exact moment the + // heart was found. Fixed: the heart's new ceiling is in place before the + // walk's energy is credited, so a bird already at the old ceiling ends + // the walk at the NEW one, full — never below it. + assert.equal(after.energy, after.energy_max, + "a bird at the old ceiling must end this walk at the new ceiling, full — not below it"); + }); +}); + test("a fix too vague to unlock is also too vague to pay a heart", async () => { await withHeartSettings(HEARTS_ON, async () => { const before = (await jsonOf("/api/ramble/pet")).hearts; diff --git a/tests/ramble-pet.test.js b/tests/ramble-pet.test.js index 3efb1a57..20d6a43c 100644 --- a/tests/ramble-pet.test.js +++ b/tests/ramble-pet.test.js @@ -294,9 +294,14 @@ test("a ceiling that drops underneath a bird never destroys its energy — on AN // one at a time with no ordering guarantee between a pet row and the wallet // rows that justify its energy, so an instance can apply a synced 150-energy // bird while it still computes a ceiling of 100 from wallet rows it hasn't - // received yet — and `POST /api/ramble/area` feeds the pet (visit_place) - // BEFORE the heart pickup runs. A test that only reads petState at a frozen - // `now` proves nothing: no decay interval elapses, so nothing is written at all. + // received yet — this module's `feed()` must never let a stale ceiling pull + // that energy DOWN, on ANY caller's ordering (2026-09-09: `POST + // /api/ramble/area` itself now feeds the pet AFTER the heart pickup runs, + // precisely so a heart's new ceiling is what a walk's energy lands against — + // but this unit test exercises `feed()` directly against a ceiling that has + // already collapsed, which is the scenario regardless of caller order). A + // test that only reads petState at a frozen `now` proves nothing: no decay + // interval elapses, so nothing is written at all. const db = await freshDb(); await giveHearts(db, 5); for (let i = 0; i < 12; i++) await feed(db, { type: "meet_crow" }, { now: 1000 }); @@ -316,7 +321,8 @@ test("a ceiling that drops underneath a bird never destroys its energy — on AN assert.equal((await petState(db, { now: 1000 })).energy, 150); assert.equal(await stored(), 150, "a read never truncates"); - // Path 2: FEEDING, which is what an area post does before the hearts arrive. + // Path 2: FEEDING against a ceiling that has already collapsed — the shape + // a sync race leaves behind, whatever order the local caller used. const fed = await feed(db, { type: "visit_place" }, { now: 1000 }); assert.equal(fed.energy, 150, "an addition stops at the ceiling but never pulls the bird DOWN to it"); assert.equal(await stored(), 150);