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.5",
"version": "0.10.0",
"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.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",
Expand Down
4 changes: 3 additions & 1 deletion bundles/ramble/panel/ramble.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export default {
<button class="rb-chip" id="rb-chip-visible" type="button" aria-haspopup="dialog">${icon("eye")}<span id="rb-chip-visible-label">Visible: off</span></button>
<button class="rb-chip" id="rb-chip-ar" type="button" aria-haspopup="dialog">${icon("ar")}<span>Look around</span></button>
<span class="rb-seed" title="Bird seed"><strong id="rb-seed-count">0</strong><span>seed</span></span>
<span class="rb-hearts" title="Heart containers"><strong id="rb-heart-count">0</strong><span>hearts</span></span>
</div>

<div class="rb-perch">
Expand Down Expand Up @@ -249,8 +250,9 @@ export default {
<div class="rb-meter">
<strong class="rb-meter-label">Energy</strong>
<span class="rb-meter-bar"><i id="rb-energy-fill"></i></span>
<strong id="rb-energy-num">&mdash;</strong>
<strong id="rb-energy-num">&mdash;</strong><span class="rb-meter-of">/ <span id="rb-energy-max">100</span></span>
</div>
<p class="rb-hearts-line"><span id="rb-heart-row" class="rb-heart-row"></span><span class="rb-muted rb-fine" id="rb-heart-line"></span></p>
<p class="rb-muted rb-fine" id="rb-mood-line">Checking on it&hellip;</p>
</section>

Expand Down
72 changes: 64 additions & 8 deletions bundles/ramble/panel/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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();
Expand Down Expand Up @@ -713,12 +715,13 @@ 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
// 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.
Expand All @@ -733,17 +736,57 @@ 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;
}
// 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");
// `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,
} : {}),
});
}));

Expand All @@ -767,7 +810,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) => {
Expand Down Expand Up @@ -883,18 +933,24 @@ 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.
res.json({
unlocked: mods.zonesMod.coalesceBoxes(out.unlocked),
frontier: mods.zonesMod.coalesceBoxes(out.frontier),
seed,
hearts,
depth,
});
}));
Expand Down
30 changes: 30 additions & 0 deletions bundles/ramble/panel/static/ramble.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -589,6 +593,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,
Expand Down Expand Up @@ -883,6 +888,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); }

Expand Down Expand Up @@ -931,6 +943,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 */

Expand Down
Loading
Loading