diff --git a/bundles/ramble/manifest.json b/bundles/ramble/manifest.json index d22f3fe5..da867c72 100644 --- a/bundles/ramble/manifest.json +++ b/bundles/ramble/manifest.json @@ -1,7 +1,7 @@ { "id": "ramble", "name": "Ramble", - "version": "0.9.4", + "version": "0.9.5", "type": "mcp-server", "author": "Crow", "category": "social", diff --git a/bundles/ramble/package.json b/bundles/ramble/package.json index 1054ef78..5b3ee7ee 100644 --- a/bundles/ramble/package.json +++ b/bundles/ramble/package.json @@ -1,6 +1,6 @@ { "name": "crow-ramble", - "version": "0.9.4", + "version": "0.9.5", "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/bundles/ramble/panel/routes.js b/bundles/ramble/panel/routes.js index e92d1240..7d5a2163 100644 --- a/bundles/ramble/panel/routes.js +++ b/bundles/ramble/panel/routes.js @@ -885,10 +885,9 @@ export default function rambleRouter(dashboardAuth, options = {}) { let seed = []; if (req.query?.pips === "1") { // Read the cell ids BEFORE coalescing: a merged run is not one cell. - const seedSet = new Set( - await mods.walletMod.harvestableCells(db, out.unlocked.map((b) => b.cell), { now: Date.now() }), - ); - seed = out.unlocked.filter((b) => seedSet.has(b.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() }); } // 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. diff --git a/bundles/ramble/panel/static/ramble.js b/bundles/ramble/panel/static/ramble.js index de577d9e..6398200b 100644 --- a/bundles/ramble/panel/static/ramble.js +++ b/bundles/ramble/panel/static/ramble.js @@ -1537,11 +1537,13 @@ return L.divIcon(opts); } - function paintSeedPips(cells) { - for (var i = 0; i < cells.length; i++) { - var c = cells[i]; - if (!cellUsable(c)) continue; - var ll = [(c.south + c.north) / 2, (c.west + c.east) / 2]; + function paintSeedPips(spots) { + for (var i = 0; i < spots.length; i++) { + var c = spots[i]; + /* A POINT inside the cell, not the cell's box — the server places it so a + * street's worth of seed does not line up like a pegboard. */ + if (!c || !isFinite(c.lat) || !isFinite(c.lon)) continue; + var ll = [c.lat, c.lon]; var icon = seedIcon(); if (icon) { L.marker(ll, { pane: "rb-fog", icon: icon, interactive: false, keyboard: false }).addTo(zoneLayer); diff --git a/bundles/ramble/server/wallet.js b/bundles/ramble/server/wallet.js index 1267f346..c6153df3 100644 --- a/bundles/ramble/server/wallet.js +++ b/bundles/ramble/server/wallet.js @@ -12,11 +12,15 @@ * a familiar route pays, pacing one cell does not, because the key is the cell * AND the window. */ +import { createHash } from "node:crypto"; import { CELL7_RE } from "./nests.js"; +import { decodeGeohash } from "./anchors.js"; export const SEED_KIND = "seed"; +export const SEED_SALT = "ramble-seed-v1:"; const RESPAWN_HOURS_DEFAULT = 24; const PER_PICKUP_DEFAULT = 1; +const SEED_RATE_DEFAULT = 4; /** Which respawn window `now` falls in. Same cell, same window = already harvested. */ export function harvestWindow(now, hours) { @@ -24,18 +28,53 @@ export function harvestWindow(now, hours) { return Math.floor(Number(now) / (h * 3600 * 1000)); } +/** + * Does this cell hold seed in this window, and exactly where in it? + * + * Copies `nestFor`'s trick deliberately: a public hash of the cell and the + * window, so the answer is identical on every device with nothing stored and + * nothing to sync, and cannot be re-rolled by leaving and coming back. + * + * WHY IT IS SPARSE. The first version paid in EVERY unlocked cell, which + * carpeted the map — a player reported most of the visible seed sat beyond any + * walk, strung out along a freeway. One cell in `rate` keeps a walkable frame + * to a handful you can actually reach, and the density is a setting rather + * than a constant so it can be tuned without a deploy. + * + * The position is a hash-derived point INSIDE the cell, not its centre, so a + * row of seed along a street does not look like a pegboard. + */ +export function seedFor(cell, window, { rate = SEED_RATE_DEFAULT } = {}) { + if (typeof cell !== "string" || !CELL7_RE.test(cell)) return null; + if (!Number.isFinite(Number(window))) return null; + const r = Number.isInteger(rate) && rate >= 1 ? rate : SEED_RATE_DEFAULT; + const h = createHash("sha256").update(SEED_SALT + cell + ":" + String(window)).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, + lat: c.lat - c.latErr + fy * 2 * c.latErr, + lon: c.lon - c.lonErr + fx * 2 * c.lonErr, + }; +} + /** Live settings (spec §6.4), each falling back on junk or a negative. */ export async function readWalletSettings(db) { - const out = { respawnHours: RESPAWN_HOURS_DEFAULT, perPickup: PER_PICKUP_DEFAULT }; + const out = { respawnHours: RESPAWN_HOURS_DEFAULT, perPickup: PER_PICKUP_DEFAULT, rate: SEED_RATE_DEFAULT }; try { const { rows } = await db.execute({ - sql: "SELECT key, value FROM ramble_settings WHERE key IN ('seed.respawn.hours', 'seed.per.pickup')", + sql: "SELECT key, value FROM ramble_settings WHERE key IN ('seed.respawn.hours', 'seed.per.pickup', 'seed.rate')", args: [], }); for (const r of rows || []) { const n = parseInt(r.value, 10); if (r.key === "seed.respawn.hours" && Number.isInteger(n) && n >= 1) out.respawnHours = n; if (r.key === "seed.per.pickup" && Number.isInteger(n) && n >= 0) out.perPickup = n; + if (r.key === "seed.rate" && Number.isInteger(n) && n >= 1) out.rate = n; } } catch { /* defaults */ } return out; @@ -57,12 +96,16 @@ export async function recordSeedPickup(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 { respawnHours, perPickup } = await readWalletSettings(db); + const { respawnHours, perPickup, rate } = await readWalletSettings(db); // NOT `Number(now) || Date.now()` — that treats `now: 0` as falsy and // silently substitutes the real clock, which breaks a replayed pickup at // epoch 0 (exercised directly by this file's own tests). const at = Number.isFinite(Number(now)) ? Number(now) : Date.now(); - const key = `${cell}:${harvestWindow(at, respawnHours)}`; + const window = harvestWindow(at, respawnHours); + // The SAME gate the map draws from. Without this the map would be a liar: + // it would show seed in one cell in four while every cell quietly paid. + if (!seedFor(cell, window, { rate })) return none; + const key = `${cell}:${window}`; const res = await db.execute({ sql: `INSERT INTO ramble_wallet (kind, key, delta, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(kind, key) DO NOTHING`, @@ -92,9 +135,10 @@ export async function harvestableCells(db, cells, { now = Date.now() } = {}) { const list = (Array.from(cells || [])).filter((c) => typeof c === "string" && CELL7_RE.test(c)); if (!db || list.length === 0) return []; try { - const { respawnHours } = await readWalletSettings(db); + const { respawnHours, rate } = await readWalletSettings(db); const at = Number.isFinite(Number(now)) ? Number(now) : Date.now(); - const suffix = ":" + harvestWindow(at, respawnHours); + const window = harvestWindow(at, respawnHours); + const suffix = ":" + window; const { rows } = await db.execute({ sql: "SELECT key FROM ramble_wallet WHERE kind = ? AND key LIKE ?", args: [SEED_KIND, "%" + suffix], @@ -104,7 +148,15 @@ export async function harvestableCells(db, cells, { now = Date.now() } = {}) { const key = String(r.key || ""); if (key.endsWith(suffix)) taken.add(key.slice(0, -suffix.length)); } - return list.filter((c) => !taken.has(c)); + // Returns POINTS, not cells: the pip sits where the seed actually is, + // which is a hash-derived spot inside the cell rather than its centre. + const out = []; + for (const c of list) { + if (taken.has(c)) continue; + const at2 = seedFor(c, window, { rate }); + if (at2) out.push(at2); + } + return out; } catch (err) { // A map that cannot say where seed is should still draw. Never throw here. try { console.warn("[ramble] harvestableCells failed:", err?.message); } catch {} diff --git a/docs/es/guide/ramble.md b/docs/es/guide/ramble.md index b75aada7..8c4e62c6 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. Un punto marca cada celda despejada que tiene alpiste esperando, así ves dónde vale la pena caminar; 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. ## Tu bandada @@ -192,6 +192,7 @@ Cada peso de la tabla anterior es también un override de `ramble_settings`, le | `nest.rate` | 24 | Aproximadamente un nido cada este número de celdas geohash-7 por semana (entero ≥ 1). Se replica con tus ajustes, así que tus propias instancias concuerdan; es un ajuste de operador, y una tasa distinta ya no coincide con los nidos de otras personas. | | `shelf.cap` | 5 | Cuántos huevos sin eclosionar caben en el estante (entero ≥ 0; 0 desactiva la recogida). | | `frontier.depth` | 3 | Cuántas manzanas más allá de tu terreno desbloqueado puedes ver. | +| `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. | | `unlock.max.accuracy.m` | 100 | Qué tan precisa debe ser tu ubicación para que un lugar cuente como visitado. | diff --git a/docs/guide/ramble.md b/docs/guide/ramble.md index adac34fa..4ce9b910 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. A small pip marks every cleared cell with seed waiting, so you can see where a walk pays; 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. ## Your flock @@ -192,6 +192,7 @@ Every weight from the table above is also a `ramble_settings` override, read liv | `nest.rate` | 24 | About one nest per this many geohash-7 cells per week (integer ≥ 1). Replicates with your settings, so your own instances agree; it is an operator knob, and a changed rate no longer matches other people's nests. | | `shelf.cap` | 5 | How many unhatched eggs the shelf holds (integer ≥ 0; 0 turns claiming off). | | `frontier.depth` | 3 | How many blocks ahead of your unlocked ground you can see. | +| `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. | | `unlock.max.accuracy.m` | 100 | How sharp your location has to be before a place counts as visited. | diff --git a/registry/add-ons.json b/registry/add-ons.json index 361afddd..933e1044 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -4887,7 +4887,7 @@ { "id": "ramble", "name": "Ramble", - "version": "0.9.4", + "version": "0.9.5", "type": "mcp-server", "author": "Crow", "category": "social", diff --git a/tests/ramble-panel.test.js b/tests/ramble-panel.test.js index 5e8e5509..6d285618 100644 --- a/tests/ramble-panel.test.js +++ b/tests/ramble-panel.test.js @@ -1571,6 +1571,15 @@ test("GET /api/ramble/zones reports which visible cells still have seed waiting" const pad = 0.004; const bbox = [LAT - pad, LON - pad, LAT + pad, LON + pad].join(","); + // Seed is SPARSE (one cell in seed.rate). This test is about the ROUTE, not + // the spawn lottery, so pin rate 1 — otherwise it passes or fails on whether + // this particular cell happened to hash lucky. seedFor's own tests cover the + // lottery. + const rateDb = createDbClient(); + try { + await rateDb.execute("INSERT INTO ramble_settings (key, value) VALUES ('seed.rate', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value"); + } finally { rateDb.close(); } + // First visit UNLOCKS and pays nothing — so the cell is offering seed. await walkTo(LAT, LON); const url = "/api/ramble/zones?bbox=" + encodeURIComponent(bbox) + "&pips=1"; @@ -1578,12 +1587,11 @@ test("GET /api/ramble/zones reports which visible cells still have seed waiting" assert.ok(Array.isArray(first.seed), "the wire carries a seed list"); assert.equal(first.seed.length, 1, "the freshly unlocked cell is offering seed"); const pip = first.seed[0]; - assert.ok(pip.south < pip.north && pip.west < pip.east, - "a pip carries a real footprint, so the client needs no geohash code"); - // unlocked is coalesced, so it has no cell ids — assert containment instead. + // A POINT, not a footprint: the seed sits somewhere inside its cell. + assert.ok(Number.isFinite(pip.lat) && Number.isFinite(pip.lon), "a pip carries a real position"); assert.ok( - first.unlocked.some((c) => c.south <= pip.south && c.north >= pip.north && c.west <= pip.west && c.east >= pip.east), - "a pip only ever sits on unlocked ground", + first.unlocked.some((c) => c.south <= pip.lat && c.north >= pip.lat && c.west <= pip.lon && c.east >= pip.lon), + "and it only ever sits on unlocked ground", ); // Second visit HARVESTS it, so the pip must go. diff --git a/tests/ramble-wallet.test.js b/tests/ramble-wallet.test.js index 95e4fb61..96489c23 100644 --- a/tests/ramble-wallet.test.js +++ b/tests/ramble-wallet.test.js @@ -8,13 +8,25 @@ 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 { recordSeedPickup, seedBalance, harvestWindow, readWalletSettings, harvestableCells, SEED_KIND } from "../bundles/ramble/server/wallet.js"; +import { cellBox } from "../bundles/ramble/server/zones.js"; +import { recordSeedPickup, seedBalance, harvestWindow, readWalletSettings, harvestableCells, seedFor, SEED_KIND } from "../bundles/ramble/server/wallet.js"; async function freshDb() { const db = createClient({ url: "file::memory:" }); await initRambleTables(db); return db; } + +/** + * Seed is SPARSE by design — one cell in `seed.rate` bears any. A ledger test + * that just picks a cell would then pass or fail on the spawn lottery rather + * than on the ledger, so these tests pin rate 1 (every cell bears seed) and + * leave the lottery itself to the seedFor tests below. + */ +async function everyCellBears(db) { + await db.execute("INSERT INTO ramble_settings (key, value) VALUES ('seed.rate', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value"); + return db; +} const HOUR = 3600 * 1000; test("harvestWindow: the same window inside the period, the next one after it", () => { @@ -27,16 +39,18 @@ test("harvestWindow: the same window inside the period, the next one after it", test("readWalletSettings: defaults, live overrides, and junk falling back", async () => { const db = await freshDb(); - assert.deepEqual(await readWalletSettings(db), { respawnHours: 24, perPickup: 1 }); - await db.execute("INSERT INTO ramble_settings (key, value) VALUES ('seed.respawn.hours', '6'), ('seed.per.pickup', '3')"); - assert.deepEqual(await readWalletSettings(db), { respawnHours: 6, perPickup: 3 }); + assert.deepEqual(await readWalletSettings(db), { respawnHours: 24, perPickup: 1, rate: 4 }); + await db.execute("INSERT INTO ramble_settings (key, value) VALUES ('seed.respawn.hours', '6'), ('seed.per.pickup', '3'), ('seed.rate', '2')"); + assert.deepEqual(await readWalletSettings(db), { respawnHours: 6, perPickup: 3, rate: 2 }); await db.execute("UPDATE ramble_settings SET value = 'banana' WHERE key = 'seed.respawn.hours'"); await db.execute("UPDATE ramble_settings SET value = '-4' WHERE key = 'seed.per.pickup'"); - assert.deepEqual(await readWalletSettings(db), { respawnHours: 24, perPickup: 1 }, "junk and negatives fall back"); + await db.execute("UPDATE ramble_settings SET value = '0' WHERE key = 'seed.rate'"); + assert.deepEqual(await readWalletSettings(db), { respawnHours: 24, perPickup: 1, rate: 4 }, + "junk, negatives and a zero rate all fall back — rate 0 would divide the lottery by nothing"); }); test("recordSeedPickup: once per cell per window; the balance is the sum of the ledger", async () => { - const db = await freshDb(); + const db = await everyCellBears(await freshDb()); assert.equal(await seedBalance(db), 0); assert.deepEqual(await recordSeedPickup(db, "9vk79ed", { now: 0 }), { picked: true, amount: 1 }); @@ -55,7 +69,7 @@ test("recordSeedPickup: once per cell per window; the balance is the sum of the }); test("recordSeedPickup EMITS on a real pickup, and never on a no-op", async () => { - const db = await freshDb(); + const db = await everyCellBears(await freshDb()); const emitted = []; const emit = async (table, op, row) => { emitted.push({ table, op, key: row.key, delta: row.delta }); }; await recordSeedPickup(db, "9vk79ed", { now: 0, emit }); @@ -69,7 +83,7 @@ test("recordSeedPickup EMITS on a real pickup, and never on a no-op", async () = }); test("recordSeedPickup: junk is refused without throwing, and honours seed.per.pickup", async () => { - const db = await freshDb(); + const db = await everyCellBears(await freshDb()); for (const bad of ["nope", "", null, 7]) { assert.deepEqual(await recordSeedPickup(db, bad, { now: 0 }), { picked: false, amount: 0 }, String(bad)); } @@ -81,7 +95,7 @@ test("recordSeedPickup: junk is refused without throwing, and honours seed.per.p }); test("seedBalance nets spends against earns, and never throws on a bare database", async () => { - const db = await freshDb(); + const db = await everyCellBears(await freshDb()); await recordSeedPickup(db, "9vk79ed", { now: 0 }); await recordSeedPickup(db, "9vk79ee", { now: 0 }); await db.execute({ @@ -93,34 +107,40 @@ test("seedBalance nets spends against earns, and never throws on a bare database }); test("harvestableCells: reports the cells whose seed has regrown, so the map can show pips", async () => { - const db = await freshDb(); + const db = await everyCellBears(await freshDb()); const A = "9vk79e9", B = "9vk79ed", C = "9vk79e2"; const now = 100 * 24 * HOUR; - assert.deepEqual((await harvestableCells(db, [A, B, C], { now })).sort(), [C, A, B].sort(), - "nothing harvested yet, so every cell is offering"); + // Returns POINTS now, not cell names — the pip sits where the seed is. + const cellsOf = async (t) => (await harvestableCells(db, [A, B, C], { now: t })).map((p) => p.cell).sort(); + + assert.deepEqual(await cellsOf(now), [A, B, C].sort(), "nothing harvested yet, so every cell is offering"); + + const spot = (await harvestableCells(db, [A], { now }))[0]; + assert.ok(Number.isFinite(spot.lat) && Number.isFinite(spot.lon), "each offering carries a real position"); + const box = cellBox(A); + assert.ok(spot.lat > box.south && spot.lat < box.north && spot.lon > box.west && spot.lon < box.east, + "and that position is strictly INSIDE its own cell"); await recordSeedPickup(db, A, { now }); - assert.deepEqual((await harvestableCells(db, [A, B, C], { now })).sort(), [B, C].sort(), - "the harvested cell stops offering inside its window"); + assert.deepEqual(await cellsOf(now), [B, C].sort(), "the harvested cell stops offering inside its window"); // The NEXT window regrows it — the property the whole pip is advertising. - assert.deepEqual((await harvestableCells(db, [A, B, C], { now: now + 24 * HOUR })).sort(), [A, B, C].sort(), - "seed regrows in the next window"); + assert.deepEqual(await cellsOf(now + 24 * HOUR), [A, B, C].sort(), "seed regrows in the next window"); }); test("harvestableCells: matches on the window, not merely on the cell name", async () => { - const db = await freshDb(); + const db = await everyCellBears(await freshDb()); const cell = "9vk79e9"; const now = 100 * 24 * HOUR; // A row from a DIFFERENT window must not suppress today's pip. A naive // "does any row mention this cell" check would get this wrong. await recordSeedPickup(db, cell, { now: now - 24 * HOUR }); - assert.deepEqual(await harvestableCells(db, [cell], { now }), [cell]); + assert.deepEqual((await harvestableCells(db, [cell], { now })).map((p) => p.cell), [cell]); }); test("harvestableCells: junk in, empty out, and never a throw", async () => { - const db = await freshDb(); + const db = await everyCellBears(await freshDb()); assert.deepEqual(await harvestableCells(db, [], { now: 0 }), []); assert.deepEqual(await harvestableCells(db, null, { now: 0 }), []); assert.deepEqual(await harvestableCells(db, ["nope", 7, null, ""], { now: 0 }), [], @@ -128,5 +148,60 @@ test("harvestableCells: junk in, empty out, and never a throw", async () => { assert.deepEqual(await harvestableCells(null, ["9vk79e9"], { now: 0 }), [], "no db is not a crash"); // now: 0 is a real timestamp, not a missing one — the same trap the ledger // guards elsewhere in this file. - assert.deepEqual(await harvestableCells(db, ["9vk79e9"], { now: 0 }), ["9vk79e9"]); + assert.deepEqual((await harvestableCells(db, ["9vk79e9"], { now: 0 })).map((p) => p.cell), ["9vk79e9"]); +}); + +test("seedFor: sparse, deterministic, and placed inside its own cell", () => { + // Deterministic: the same cell and window must answer identically forever and + // on every device, or two Crows would disagree about where seed is and a + // player could re-roll a cell by walking out and back. + const a = seedFor("9vk79e9", 20705, { rate: 1 }); + const b = seedFor("9vk79e9", 20705, { rate: 1 }); + assert.deepEqual(a, b, "same cell, same window, same answer"); + + const box = cellBox("9vk79e9"); + assert.ok(a.lat > box.south && a.lat < box.north && a.lon > box.west && a.lon < box.east, + "the seed sits strictly inside its cell, not on the boundary"); + + // Not the cell CENTRE — a street's worth of seed centred in every cell reads + // as a pegboard rather than as something scattered. + const mid = { lat: (box.south + box.north) / 2, lon: (box.west + box.east) / 2 }; + assert.ok(a.lat !== mid.lat || a.lon !== mid.lon, "and is offset within it"); + + // The window is part of the hash, so the same cell moves day to day. + assert.notDeepEqual(seedFor("9vk79e9", 20706, { rate: 1 }), a, "a new window is a new roll"); + + // Sparse: rate 1 means every cell bears seed; a real rate means most do not. + const cells = []; + for (const c of ["9vk79e0", "9vk79e1", "9vk79e2", "9vk79e3", "9vk79e4", "9vk79e5", + "9vk79e6", "9vk79e7", "9vk79e8", "9vk79e9", "9vk79eb", "9vk79ec", + "9vk79ed", "9vk79ee", "9vk79ef", "9vk79eg"]) cells.push(c); + assert.equal(cells.filter((c) => seedFor(c, 20705, { rate: 1 })).length, cells.length, + "rate 1 is the every-cell case the ledger tests rely on"); + const bearing = cells.filter((c) => seedFor(c, 20705, { rate: 4 })).length; + assert.ok(bearing > 0 && bearing < cells.length, + "rate 4 leaves some cells bearing and most not (" + bearing + " of " + cells.length + ")"); + + // Junk must not throw — this runs on every zones fetch. + assert.equal(seedFor("nope", 20705), null); + assert.equal(seedFor(null, 20705), null); + assert.equal(seedFor("9vk79e9", "banana"), null); + assert.ok(seedFor("9vk79e9", 20705, { rate: 0 }) !== undefined, "a zero rate falls back rather than dividing by nothing"); +}); + +test("a cell that bears no seed pays nothing, so the map never promises what it cannot give", async () => { + const db = await freshDb(); // real rate, NOT the every-cell fixture + const now = 100 * 24 * HOUR; + const { rate } = await readWalletSettings(db); + const window = harvestWindow(now, 24); + + const barren = ["9vk79e0", "9vk79e1", "9vk79e2", "9vk79e3", "9vk79e4", "9vk79e5", + "9vk79e6", "9vk79e7", "9vk79e8", "9vk79e9"].find((c) => !seedFor(c, window, { rate })); + assert.ok(barren, "the fixture needs at least one cell the lottery skipped"); + + const got = await recordSeedPickup(db, barren, { now }); + assert.equal(got.picked, false, "walking a barren cell earns nothing"); + assert.equal(await seedBalance(db), 0); + assert.deepEqual(await harvestableCells(db, [barren], { now }), [], + "and the map shows no pip there — the harvest and the pip read the SAME rule"); });