Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bundles/ramble/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "ramble",
"name": "Ramble",
"version": "0.9.4",
"version": "0.9.5",
"type": "mcp-server",
"author": "Crow",
"category": "social",
Expand Down
2 changes: 1 addition & 1 deletion bundles/ramble/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 3 additions & 4 deletions bundles/ramble/panel/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 7 additions & 5 deletions bundles/ramble/panel/static/ramble.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
66 changes: 59 additions & 7 deletions bundles/ramble/server/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,69 @@
* 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) {
const h = Number.isFinite(hours) && hours >= 1 ? hours : RESPAWN_HOURS_DEFAULT;
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;
Expand All @@ -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`,
Expand Down Expand Up @@ -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],
Expand All @@ -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 {}
Expand Down
3 changes: 2 additions & 1 deletion docs/es/guide/ramble.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. |
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/ramble.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion registry/add-ons.json
Original file line number Diff line number Diff line change
Expand Up @@ -4887,7 +4887,7 @@
{
"id": "ramble",
"name": "Ramble",
"version": "0.9.4",
"version": "0.9.5",
"type": "mcp-server",
"author": "Crow",
"category": "social",
Expand Down
18 changes: 13 additions & 5 deletions tests/ramble-panel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1571,19 +1571,27 @@ 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";
const first = await (await req(url)).json();
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.
Expand Down
Loading
Loading