From e03312944cdaf5a10939b2799788ff41b105ef7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:42:09 +0000 Subject: [PATCH] feat(rescues): Airmed's herbs become a find-and-deliver quest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Airmed the Herb-Wise used to run a gold shop (+5 Max HP per purchase). Turn it into a foraging quest that fits the roguelike loop: - Once Airmed is freed, a "herb of Miach's grave" becomes a vanishingly rare rider on falling stone: ~1/365 per piece (one for each herb of the myth), gated on the rescue so it can't appear before she can use it, and never during the tutorial. - The herb locks as a green floor tile with a soft pulse; walk onto it to gather it. Gathered herbs are carried between floors; an ungathered herb can be crushed by a line clear (protect the row). - Deliver carried herbs to Airmed in the sídhe mound and she works each into a permanent +20% Max HP (compounding), then empties the satchel. Removes the gold-shop path entirely (healerBaseCost/CostPerFloor/HpGain → herbSpawnChance + herbHpPct in balance.json's rescues block). - types.ts: Cell.HERB - balance.json + schema + balance.ts: herbSpawnChance (1/365), herbHpPct (0.2) - game.ts: herbTiles/herbsCarried state; spawn injection; lockBlock tile; walk-on gather; line-clear loss; per-floor reset (carried persists) - renderer.ts: draw herb tiles (sprite_salve + green glow) + falling preview - vendorOffers.ts: healer branch → herb delivery (no gold) - rescues.json: Airmed's service flavor no longer a shop - tests: spawn gate, lock+gather, delivery (+20%/herb, emptied), persistence - README: new "Airmed's herbs" system row Save/resume carries herbTiles/herbsCarried automatically (generic scalar sweep). Verified live: herbs render on the floor and gather on walk-on, no page errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv --- README.md | 3 +- schema/balance.schema.json | 19 +++++----- src/__tests__/game.test.ts | 60 +++++++++++++++++++++++++++--- src/__tests__/vendorOffers.test.ts | 15 +++++--- src/balance.ts | 2 +- src/data/balance.json | 5 +-- src/data/rescues.json | 2 +- src/game.ts | 47 +++++++++++++++++++++++ src/renderer.ts | 9 ++++- src/types.ts | 1 + src/vendorOffers.ts | 51 +++++++++++++++---------- 11 files changed, 167 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 3565b14..e7248c6 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ Built with TypeScript + Vite as an installable PWA. Rendering is a single ` { expect(game.harperLullFloor).toBe(0); }); - it("Airmed's herbs trade gold for permanent Max HP", () => { + it('delivering gathered herbs to Airmed grants ~20% Max HP each and empties the satchel', () => { const onFloorEvent = vi.fn(); const cb = { ...makeCallbacks(), onFloorEvent }; const game = new Game(cb); game.rescuedIds.add('airmed'); + game.herbsCarried = 2; (game as unknown as { enterWaystation(): void }).enterWaystation(); const resident = game.npcTiles.find(n => n.npcId === '__rescue_airmed__')!; - const cost = Balance.CONFIG.rescues.healerBaseCost + game.dungeonLevel * Balance.CONFIG.rescues.healerCostPerFloor; - game.gold = cost; + const pct = Balance.CONFIG.rescues.herbHpPct; const maxBefore = game.player.maxHp; game.player.x = resident.x; game.player.y = resident.y + 1; game.map[resident.x]![resident.y + 1] = Tile.FLOOR; @@ -2285,8 +2285,58 @@ describe('New omens (lore expansion) and rescue services', () => { game.handleHeroMove(0, -1); const [, onChoice] = onFloorEvent.mock.calls[0]!; onChoice(0); - expect(game.gold).toBe(0); - expect(game.player.maxHp).toBe(maxBefore + Balance.CONFIG.rescues.healerHpGain); + // Two herbs compound: maxHp * (1+pct)^2 (rounded per step). + const step1 = maxBefore + Math.max(1, Math.round(maxBefore * pct)); + const expected = step1 + Math.max(1, Math.round(step1 * pct)); + expect(game.player.maxHp).toBe(expected); + expect(game.herbsCarried).toBe(0); + }); + + it('a herb-bearing piece only spawns once Airmed is freed', () => { + const sawHerb = (rescued: boolean): boolean => { + const game = new Game(makeCallbacks()); + game.dungeonLevel = 4; + if (rescued) game.rescuedIds.add('airmed'); + let seen = false; + for (let i = 0; i < 10 && !seen; i++) { + (game as unknown as { spawnBlock(): void }).spawnBlock(); + if (game.blockMatrix.flat().includes(Cell.HERB)) seen = true; + } + return seen; + }; + const original = Balance.CONFIG.rescues.herbSpawnChance; + Balance.CONFIG.rescues.herbSpawnChance = 1; // certain roll isolates the gate + try { + expect(sawHerb(false)).toBe(false); // not rescued → never a herb + expect(sawHerb(true)).toBe(true); // rescued → herb rides the next piece + } finally { + Balance.CONFIG.rescues.herbSpawnChance = original; + } + }); + + it('locking a herb cell plants a herb tile; walking onto it gathers it', () => { + const game = new Game(makeCallbacks()); + const g = game as unknown as { blockMatrix: number[][]; blockX: number; blockY: number; currentType: string; currentBlessed: boolean; currentCursed: boolean; lockBlock(): void }; + g.blockMatrix = [[Cell.HERB]] as number[][]; + g.blockX = 4; g.blockY = 20; g.currentType = 'O'; + g.currentBlessed = false; g.currentCursed = false; + g.lockBlock(); + expect(game.herbTiles.some(h => h.x === 4 && h.y === 20)).toBe(true); + // Stand next to it and step on. + game.player.x = 3; game.player.y = 20; + game.map[3]![20] = Tile.FLOOR; + game.handleHeroMove(1, 0); + expect(game.herbsCarried).toBe(1); + expect(game.herbTiles).toHaveLength(0); + }); + + it('carried herbs survive a floor change; ungathered floor herbs do not', () => { + const game = new Game(makeCallbacks()); + game.herbsCarried = 3; + game.herbTiles = [{ x: 1, y: 1 }]; + (game as unknown as { resetDungeonState(): void }).resetDungeonState(); + expect(game.herbsCarried).toBe(3); + expect(game.herbTiles).toHaveLength(0); }); it('all five rescued residents fit inside the mound chamber', () => { diff --git a/src/__tests__/vendorOffers.test.ts b/src/__tests__/vendorOffers.test.ts index 48a1b0c..7070c38 100644 --- a/src/__tests__/vendorOffers.test.ts +++ b/src/__tests__/vendorOffers.test.ts @@ -125,19 +125,22 @@ describe('VendorOffers', () => { expect(game.paused).toBe(false); }); - it('Airmed (healer) sells permanent Max HP when you can pay', () => { - game.gold = 100000; + it('Airmed (healer) turns carried herbs into permanent Max HP and empties the satchel', () => { + game.herbsCarried = 1; const before = game.player.maxHp; game.vendorOffers.rescueService(rescue('airmed')); - cb.ev()!.onChoice(0); // buy the herbs + cb.ev()!.onChoice(0); // give her the herbs expect(game.player.maxHp).toBeGreaterThan(before); + expect(game.herbsCarried).toBe(0); }); - it('Airmed refuses without the gold and leaves Max HP unchanged', () => { - game.gold = 0; + it('Airmed with no herbs offers only a dismissable line and leaves Max HP unchanged', () => { + game.herbsCarried = 0; const before = game.player.maxHp; game.vendorOffers.rescueService(rescue('airmed')); - cb.ev()!.onChoice(0); + const ev = cb.ev()!; + expect(ev.event.options).toHaveLength(1); + ev.onChoice(0); expect(game.player.maxHp).toBe(before); }); diff --git a/src/balance.ts b/src/balance.ts index 11aaae8..d0e201e 100644 --- a/src/balance.ts +++ b/src/balance.ts @@ -82,7 +82,7 @@ export interface BalanceConfig { omens: { rollChance: number }; well: { baseCost: number; costPerFloor: number; baseXp: number; xpPerFloor: number }; waystation: { tattooistChance: number; stashRecoveryPct: number }; - rescues: { rollChance: number; pieceThreshold: number; portionAtk: number; healerBaseCost: number; healerCostPerFloor: number; healerHpGain: number }; + rescues: { rollChance: number; pieceThreshold: number; portionAtk: number; herbSpawnChance: number; herbHpPct: number }; spearOfLugh: { dmgMult: number; cooldownMax: number }; difficulty: { presets: DifficultyPreset[] }; ngplus: { xpBonusPerHeat: number; tiers: HeatTier[] }; diff --git a/src/data/balance.json b/src/data/balance.json index 6e124cc..cefc89e 100644 --- a/src/data/balance.json +++ b/src/data/balance.json @@ -165,9 +165,8 @@ "rollChance": 0.3, "pieceThreshold": 6, "portionAtk": 2, - "healerBaseCost": 40, - "healerCostPerFloor": 6, - "healerHpGain": 5 + "herbSpawnChance": 0.00274, + "herbHpPct": 0.2 }, "spearOfLugh": { "dmgMult": 3, diff --git a/src/data/rescues.json b/src/data/rescues.json index 7c33208..858ad75 100644 --- a/src/data/rescues.json +++ b/src/data/rescues.json @@ -33,7 +33,7 @@ "service": "healer", "captiveLine": "A woman in healer's yellow kneels ringed by captors, hands bound behind her. \"They think my herbs will mend their king's ruined flesh. Cut them down and I'll tend to worthier wounds.\"", "thanksLine": "Airmed flexes her freed hands and is already assessing your bruises. \"Three hundred and sixty-five herbs grew from my brother's grave, and I remember every one my father scattered. Come to me in the mounds — flesh can always be made stronger.\"", - "serviceFlavor": "Airmed's stall smells of yarrow, comfrey, and things that have no name above ground. \"The herbs of Miach's grave,\" she says. \"Costly, and worth it.\"" + "serviceFlavor": "Airmed's corner of the mound smells of yarrow, comfrey, and things that have no name above ground." }, { "id": "abcan", diff --git a/src/game.ts b/src/game.ts index fe31481..755f42a 100644 --- a/src/game.ts +++ b/src/game.ts @@ -205,6 +205,12 @@ export class Game { /** Set once the ritual reward has been granted, stopping further brazier riders. */ public ritualComplete = false; // public: reset by Waystation.enter + // Airmed's herb quest (unlocked once Airmed is rescued) + /** Herbs of Miach's grave standing on this floor — walk onto one to gather it. Cleared each floor. */ + public herbTiles: { x: number; y: number }[] = []; + /** Herbs gathered and not yet delivered to Airmed. Persists across floors (it's the run inventory). */ + public herbsCarried = 0; + // Run stats public monstersKilled = 0; public bossesKilled = 0; @@ -512,6 +518,11 @@ export class Game { && this.blocksSpawnedThisFloor >= Balance.CONFIG.rescues.pieceThreshold; let rescueInjected = false; let guardsInjected = 0; + // A herb of Miach's grave: vanishingly rare (~1/365 per piece), and only + // once Airmed has been freed to make use of it. Not during the tutorial. + const herbDue = this.rescuedIds.has('airmed') && !this.tutorialSafety + && Math.random() < Balance.CONFIG.rescues.herbSpawnChance; + let herbInjected = false; this.blockMatrix = shape.matrix.map(row => row.map((cell): CellValue => { @@ -549,6 +560,12 @@ export class Game { return Cell.BRAZIER; } + // Herb of Miach's grave — one per due piece, gathered for Airmed + if (herbDue && !herbInjected) { + herbInjected = true; + return Cell.HERB; + } + // Stairs if (!stairsInjected && (this.blocksPlacedSinceStairs >= Balance.CONFIG.spawnRates.stairsForcedAfterBlocks || Math.random() < Balance.CONFIG.spawnRates.stairsRandomChance)) { stairsInjected = true; @@ -781,6 +798,13 @@ export class Game { this.brazierTiles.push({ x: tx, y: ty, lit: false }); this.cb.log('A cold need-fire settles into the stone. Walk to it to light it.', 'log-blockbuilding', 'tile_brazier'); lockedFloorCells.push({ x: tx, y: ty }); + } else if (cell === Cell.HERB) { + this.map[tx]![ty] = Tile.FLOOR; + this.colors[tx]![ty] = '#1c3a1e'; + this.herbTiles.push({ x: tx, y: ty }); + this.cb.log("A herb of Miach's grave has taken root in the fallen stone. Gather it for Airmed.", 'log-perk', 'sprite_salve'); + this.cb.onToast?.("A rare herb of Miach's grave — gather it and bring it to Airmed.", 'sprite_salve'); + lockedFloorCells.push({ x: tx, y: ty }); } else if (cell === Cell.TRAP_SPIKE) { this.map[tx]![ty] = Tile.FLOOR; this.colors[tx]![ty] = this.blockColor; @@ -1187,6 +1211,13 @@ export class Game { this.brazierTiles = this.brazierTiles .filter(b => b.y !== y) .map(b => b.y < y ? { ...b, y: b.y + 1 } : b); + // A herb crushed in a cleared row is lost — they're rare, so protect them. + if (this.herbTiles.some(h => h.y === y)) { + this.cb.log("A herb of Miach's grave is crushed under the settling stone.", 'log-neutral', 'sprite_salve'); + } + this.herbTiles = this.herbTiles + .filter(h => h.y !== y) + .map(h => h.y < y ? { x: h.x, y: h.y + 1 } : h); this.hazards = this.hazards .filter(h => h.y !== y) .map(h => h.y < y ? { ...h, y: h.y + 1 } : h); @@ -1380,6 +1411,9 @@ export class Game { this.brazierTiles = []; this.brazierLitCount = 0; this.ritualComplete = false; + // Ungathered herbs are left behind on the floor; the ones already carried + // (herbsCarried) travel with you down to Airmed. + this.herbTiles = []; // Ghost haunting roll — a fallen character close to your current level // may drift up from a previous run's save. this.activeGhost = null; @@ -1737,6 +1771,19 @@ export class Game { return; } + // Herb of Miach's grave — walk onto one to gather it for Airmed. + const herb = this.herbTiles.find(h => h.x === nx && h.y === ny); + if (herb) { + this.player.x = nx; this.player.y = ny; + this.herbTiles = this.herbTiles.filter(h => h !== herb); + this.herbsCarried++; + this.cb.onParticleBurst?.(nx, ny, 8, '#7bd86a', 'sprite_salve'); + this.cb.onAudio?.('npcEncounter'); + this.cb.log(`You gather a herb of Miach's grave. (${this.herbsCarried} carried — bring them to Airmed.)`, 'log-perk', 'sprite_salve'); + this.advanceTurn(); + return; + } + // Causeway-Duel islands — walk onto one to activate it. if (this.inCausewayDuel) { const sw = this.causewayDuel.switches.find(s => s.x === nx && s.y === ny && !s.lit); diff --git a/src/renderer.ts b/src/renderer.ts index 8809da7..a118615 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -672,7 +672,8 @@ export class Renderer { const altar = this.getAltarAt(game, x, y); const npcHere = game.npcTiles.find(n => n.x === x && n.y === y); const brazier = game.brazierTiles.find(b => b.x === x && b.y === y); - if (type !== Tile.STAIRS && !isMerchant && !altar && !npcHere && !brazier) continue; + const herb = game.herbTiles.find(h => h.x === x && h.y === y); + if (type !== Tile.STAIRS && !isMerchant && !altar && !npcHere && !brazier && !herb) continue; ctx.globalAlpha = visible ? 1.0 : 0.5; if (type === Tile.STAIRS) { @@ -684,6 +685,11 @@ export class Renderer { const inset = TS * 0.12; if (!brazier.lit) ctx.globalAlpha *= 0.8; this.drawSprite('tile_brazier', x * TS + inset, y * TS + inset, TS - 2 * inset, TS - 2 * inset); + } else if (herb) { + // A rare herb of Miach's grave — a soft green pulse to draw the eye. + if (visible) this.drawPulseGlow(x, y, '123,216,106'); + const inset = TS * 0.14; + this.drawSprite('sprite_salve', x * TS + inset, y * TS + inset, TS - 2 * inset, TS - 2 * inset); } else if (isMerchant) { if (visible) this.drawLivingSprite('tile_merchant', x, y, '217,164,65', x * 7 + y * 13); else this.drawSprite('tile_merchant', x * TS, y * TS, TS, TS); @@ -1049,6 +1055,7 @@ const CELL_SPRITE: Partial> = { [Cell.RESCUE]: 'npc_sidhe', [Cell.ELITE_GUARD]: 'sprite_skel_01', [Cell.BRAZIER]: 'tile_brazier', + [Cell.HERB]: 'sprite_salve', [Cell.TRAP_SPIKE]: 'trap_spike', [Cell.TRAP_SMOKE]: 'trap_smoke', [Cell.TRAP_TELEPORT]: 'trap_teleport', diff --git a/src/types.ts b/src/types.ts index 81cafe2..05c39ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -103,6 +103,7 @@ export const Cell = { BRAZIER: 23, RESCUE: 24, ELITE_GUARD: 25, + HERB: 26, } as const; /** Value type of {@link Cell}. */ export type CellValue = (typeof Cell)[keyof typeof Cell]; diff --git a/src/vendorOffers.ts b/src/vendorOffers.ts index aeae40b..7144f11 100644 --- a/src/vendorOffers.ts +++ b/src/vendorOffers.ts @@ -151,27 +151,40 @@ export class VendorOffers { options: [{ label: 'Thank her', desc: '', apply: (): string => 'The flame gutters out. Fedelm is already looking at something else — something further down.' }], }; } else if (rescue.service === 'healer') { - const cost = Balance.CONFIG.rescues.healerBaseCost + g.dungeonLevel * Balance.CONFIG.rescues.healerCostPerFloor; - const hpGain = Balance.CONFIG.rescues.healerHpGain; + const herbs = g.herbsCarried; + const pct = Balance.CONFIG.rescues.herbHpPct; + const pctLabel = `${Math.round(pct * 100)}%`; event = { id: `__service_${rescue.id}__`, emoji: rescue.char, title: rescue.name, - flavor: rescue.serviceFlavor, - options: [ - { - label: `Buy her herbs (${cost} gold)`, - desc: `+${hpGain} Max HP, permanently.`, - apply: (game: Game): string => { - if (game.gold < cost) return 'Airmed folds the herbs away. "Healing is costly. Dying is costlier — come back with gold."'; - game.gold -= cost; - game.player.maxHp += hpGain; - game.player.hp += hpGain; - game.storyBeats.push("ate of the herbs of Miach's grave"); - game.pushUI(); - return `The herbs are bitter as grief and warm as a hearth. +${hpGain} Max HP, forever.`; - }, - }, - { label: 'Not today', desc: '', apply: (): string => '"Then don\'t come crying to me with your ribs showing," she says, not unkindly.' }, - ], + flavor: herbs > 0 + ? `${rescue.serviceFlavor} Her eyes go straight to what you carry. "You found some. Give them here — I'll work them into you, not sell them to you."` + : `${rescue.serviceFlavor} "But you come empty-handed. The herbs of my brother's grave grow in the deep, one in three hundred and sixty-five stones. Find them and bring them to me — then we'll make you harder to kill."`, + options: herbs > 0 + ? [ + { + label: `Give her your herbs (${herbs})`, + desc: `+${pctLabel} Max HP per herb, permanently.`, + apply: (game: Game): string => { + const n = game.herbsCarried; + if (n <= 0) return 'Airmed checks your hands and finds them empty. "Come back when you\'ve found some."'; + const before = game.player.maxHp; + for (let i = 0; i < n; i++) { + const inc = Math.max(1, Math.round(game.player.maxHp * pct)); + game.player.maxHp += inc; + game.player.hp += inc; + } + const gained = Math.round(game.player.maxHp - before); + game.herbsCarried = 0; + game.storyBeats.push("ate of the herbs of Miach's grave"); + game.pushUI(); + return `Airmed grinds ${n === 1 ? 'the herb' : `all ${n} herbs`} into a bitter salve and works ${n === 1 ? 'it' : 'them'} into you. +${gained} Max HP, forever.`; + }, + }, + { label: 'Keep them for now', desc: '', apply: (): string => '"Suit yourself," Airmed says. "They keep. So does the offer."' }, + ] + : [ + { label: 'You will look for them', desc: '', apply: (): string => '"Good," she says. "Three hundred and sixty-five of them. You only need find a few."' }, + ], }; } else if (rescue.service === 'harper') { const played = g.harperLullFloor === g.dungeonLevel + 1;