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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ Built with TypeScript + Vite as an installable PWA. Rendering is a single `<canv
| **Waystations** | `src/waystation.ts` | Every staircase opens a choice — delve deeper, or rest in a safe sídhe mound first. The mound sits *between* floors (visiting never consumes a floor number, so bosses/smiths can't be dodged): no falling stone, no monsters, no fog. Every between-floor choice lives here as a person or fixture: the seanchaí (mound lore, plus "ask for your own tale" — the run recap mid-run), the hearth-fire (one full heal), the Fear Dearg's stall (shop), An Draoi's deity emissary (the pact ceremony), any pending floor event as a sheltering stranger, Aoife with a vengeance bounty when none is sworn, an ogham standing stone that opens the lore codex, the Well of Segais (gold for XP, priced by depth), the Sídhe coffer (bank gold across runs — your next character inherits half), and the Ogham-mark tattooist on some visits. The ambient drone retunes warmer inside. The stairs-choice dialog names whoever is currently waiting. |
| **Fidchell** | `src/fidchell.ts`, `docs/fidchell.md` | Every 7th (non-boss) floor a Fomorian gambler bars the crossing with fidchell — "the wooden wisdom," built on the Irish 7×7 *tafl* game **brandub**. You're dealt a side at random: the **King's escape** (slip the High King to a corner dún) or the **Raiders' hunt** (surround and take the King). Turn-based, tap-driven, rook-slide moves with custodial (flanking) capture and a "weak" King. Win → gold, a boon, and a shortcut past the floor; lose → the gambler drops onto the floor as an elite to fight. Both sides are played by a depth-3 alpha-beta AI so whichever role you draw is a real contest (~60/40 King/raider self-play balance). |
| **Tutorial** | `src/tutorial.ts` | A skippable guided tutorial on the first run ever (re-runnable via "New here?" on the start screen): a non-blocking callout card whose 7 steps advance when the player actually performs each action — move, steer, drop, clear, fight, descend — observed from the game's normal event stream. Luck-dependent steps auto-advance after a few landings so it can never stall. Input-mode-aware copy (keyboard vs touch). |
| **Rescues** | `src/data/rescues.json` | Souls-style: captives occasionally ride down inside a shape under Fomorian elite guard (captive + two captors on one piece). Kill every guard, then talk to free them — they beam away and join the mound for the rest of the run: the Gobán Saor shapes your next piece to order, Fedelm the Seeress names the boss ahead, Bricriu of the Feast serves the Champion's Portion (+ATK until the next descent), Airmed the Herb-Wise sells permanent Max HP from the herbs of Miach's grave, and Abcán the Harper plays the suantraí so the next floor's monsters arrive drowsy. A line clear that swallows the captive loses them — they may ride again on a later floor. |
| **Rescues** | `src/data/rescues.json` | Souls-style: captives occasionally ride down inside a shape under Fomorian elite guard (captive + two captors on one piece). Kill every guard, then talk to free them — they beam away and join the mound for the rest of the run: the Gobán Saor shapes your next piece to order, Fedelm the Seeress names the boss ahead, Bricriu of the Feast serves the Champion's Portion (+ATK until the next descent), Airmed the Herb-Wise turns gathered herbs into permanent Max HP (see below), and Abcán the Harper plays the suantraí so the next floor's monsters arrive drowsy. A line clear that swallows the captive loses them — they may ride again on a later floor. |
| **Airmed's herbs** | `src/game.ts`, `src/vendorOffers.ts` | Once Airmed the Herb-Wise 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). It locks as a green tile; walk onto it to gather it (herbs travel with you between floors, though a line clear can crush an ungathered one). Deliver your gathered herbs to Airmed in the mound and she works each into a permanent **+20% Max HP** — a find-and-deliver quest in place of a gold shop. Tunables in `balance.json`'s `rescues` block (`herbSpawnChance`, `herbHpPct`). |
| **Combat legibility** | `renderer.ts`, `game.ts` | Tap any tile to inspect it (incl. your hit-chance vs a monster). Monsters that can strike next turn are telegraphed. |
| **Gorgoth endgame** | `src/bossEncounters.ts`, `monsterAI.ts` | Fixed stats (1450 HP, ATK 54, D20 — see `balance.json`'s `gorgoth` block). Descends one tile every ~2 turns, phasing through terrain. No retreat once summoned — stairs vanish from the board the moment he appears. |
| **Difficulty** | `src/data/balance.json` (`difficulty`), `src/game.ts` | Chosen at run start (before class/curse): *Slí an Scéalaí* (Storyteller — slower stone, +30% HP, softer foes), *Slí an Laoich* (Hero — the default tuning), *Slí na bhFomhórach* (Fomorian — faster stone, harder foes, leaner gold, +15% XP). Pure `balance.json` multipliers applied at the spawn/gold/gravity choke points; the last pick is remembered and a non-default choice shows a sidebar badge. |
Expand Down
19 changes: 9 additions & 10 deletions schema/balance.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -547,9 +547,8 @@
"rollChance",
"pieceThreshold",
"portionAtk",
"healerBaseCost",
"healerCostPerFloor",
"healerHpGain"
"herbSpawnChance",
"herbHpPct"
],
"properties": {
"rollChance": {
Expand All @@ -563,14 +562,14 @@
"portionAtk": {
"type": "number"
},
"healerBaseCost": {
"type": "number"
},
"healerCostPerFloor": {
"type": "number"
"herbSpawnChance": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"healerHpGain": {
"type": "number"
"herbHpPct": {
"type": "number",
"minimum": 0
}
}
},
Expand Down
60 changes: 55 additions & 5 deletions src/__tests__/game.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2268,15 +2268,15 @@ describe('New omens (lore expansion) and rescue services', () => {
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;
Expand All @@ -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', () => {
Expand Down
15 changes: 9 additions & 6 deletions src/__tests__/vendorOffers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion src/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };
Expand Down
5 changes: 2 additions & 3 deletions src/data/balance.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/data/rescues.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -1049,6 +1055,7 @@ const CELL_SPRITE: Partial<Record<number, string>> = {
[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',
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading