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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ 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 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. |
| **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, Bé Chuille the Enchantress names the boss ahead, Nuada Airgetlám pours the king's draught from Goibniu's feast (+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. |
Expand Down
14 changes: 7 additions & 7 deletions src/__tests__/game.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2252,31 +2252,31 @@ describe('Waystations (the sídhe mound offered at every staircase)', () => {
expect(game.npcTiles.some(n => n.npcId === '__rescue_goban__')).toBe(true);
});

it("Bricriu's Champion's Portion grants ATK until the next descent, one helping per floor", () => {
it("Nuada's draught grants ATK until the next descent, one cup per floor", () => {
const onFloorEvent = vi.fn();
const cb = { ...makeCallbacks(), onFloorEvent };
const game = new Game(cb);
game.rescuedIds.add('bricriu');
game.rescuedIds.add('nuada');
(game as unknown as { enterWaystation(): void }).enterWaystation();
const resident = game.npcTiles.find(n => n.npcId === '__rescue_bricriu__')!;
const resident = game.npcTiles.find(n => n.npcId === '__rescue_nuada__')!;
const atkBefore = game.player.atk;
const bump = (): void => {
game.player.x = resident.x - 1; game.player.y = resident.y;
game.map[resident.x - 1]![resident.y] = Tile.FLOOR;
game.npcTiles = game.npcTiles.filter(n => n.npcId === '__rescue_bricriu__');
game.npcTiles = game.npcTiles.filter(n => n.npcId === '__rescue_nuada__');
onFloorEvent.mockClear();
game.paused = false;
game.handleHeroMove(1, 0);
};
bump();
let [event, onChoice] = onFloorEvent.mock.calls[0]!;
expect(event.options[0].label).toContain('Portion');
expect(event.options[0].label).toContain('draught');
onChoice(0);
expect(game.player.atk).toBe(atkBefore + Balance.CONFIG.rescues.portionAtk);
// Second helping refused this floor.
// Second cup refused this floor.
bump();
[event, onChoice] = onFloorEvent.mock.calls[0]!;
expect(event.options.some((o: { label: string }) => o.label.includes('Portion'))).toBe(false);
expect(event.options.some((o: { label: string }) => o.label.includes('draught'))).toBe(false);
onChoice(0);
expect(game.player.atk).toBe(atkBefore + Balance.CONFIG.rescues.portionAtk);
// The portion ends at the descent.
Expand Down
20 changes: 10 additions & 10 deletions src/__tests__/vendorOffers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ describe('VendorOffers', () => {
expect(game.nextType).toBe('I');
});

it("Fedelm (seer) only reads the floors ahead — a single dismissable line", () => {
game.vendorOffers.rescueService(rescue('fedelm'));
it("Bé Chuille (seer) only reads the floors ahead — a single dismissable line", () => {
game.vendorOffers.rescueService(rescue('bechuille'));
const ev = cb.ev()!;
expect(ev.event.options).toHaveLength(1);
ev.onChoice(0);
Expand Down Expand Up @@ -150,23 +150,23 @@ describe('VendorOffers', () => {
expect(game.harperLullFloor).toBe(game.dungeonLevel + 1);
});

it("Bricriu (cook) serves the Champion's Portion — +ATK until next descent", () => {
it("Nuada (cook) pours the king's draught — +ATK until next descent", () => {
const before = game.player.atk;
game.vendorOffers.rescueService(rescue('bricriu'));
cb.ev()!.onChoice(0); // eat the portion
game.vendorOffers.rescueService(rescue('nuada'));
cb.ev()!.onChoice(0); // drink the draught
expect(game.portionAtkBonus).toBeGreaterThan(0);
expect(game.player.atk).toBe(before + game.portionAtkBonus);
});

it("Bricriu offers nothing a second time (the portion is one helping)", () => {
game.vendorOffers.rescueService(rescue('bricriu'));
it("Nuada offers nothing a second time (the cup answers once per descent)", () => {
game.vendorOffers.rescueService(rescue('nuada'));
cb.ev()!.onChoice(0);
const atkAfterFirst = game.player.atk;
game.vendorOffers.rescueService(rescue('bricriu'));
game.vendorOffers.rescueService(rescue('nuada'));
const ev = cb.ev()!;
expect(ev.event.flavor).toMatch(/one portion|entire point/i);
expect(ev.event.flavor).toMatch(/answers once|only ale/i);
ev.onChoice(0);
expect(game.player.atk).toBe(atkAfterFirst); // no second helping
expect(game.player.atk).toBe(atkAfterFirst); // no second cup
});
});
});
18 changes: 9 additions & 9 deletions src/data/floor-events.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
{
"id": "fallen_champion",
"emoji": "sprite_equip_iron_sword",
"title": "The Fallen Fian",
"flavor": "A warrior of the Fianna lies here, spear still in hand, felled far from Tara.",
"title": "The Fallen of Mag Tuired",
"flavor": "A warrior of the Tuatha Dé lies here, spear still in hand, carried down into the deep long after the great battle ended.",
"options": [
{
"label": "Take their boon",
Expand Down Expand Up @@ -214,8 +214,8 @@
{
"id": "salmon_of_knowledge",
"emoji": "sprite_crystal",
"title": "The Bradán Feasa",
"flavor": "A salmon glimmers beneath black water, said to hold all the world's wisdom in its fleshwhoever tastes it first will know all things.",
"title": "The Salmon of Segais",
"flavor": "A salmon glimmers beneath black water. Nine hazels overhang the Well of Segais and drop their nuts into it, and the salmon that eat them take up all the wisdom of the worldevery drop of which runs down here eventually.",
"options": [
{
"label": "Taste the salmon",
Expand All @@ -235,13 +235,13 @@
]
},
{
"id": "warp_spasm_echo",
"id": "neit_fury",
"emoji": "sprite_equip_war_axe",
"title": "The Ríastrad's Echo",
"flavor": "The echo of the Hound of Ulster's battle-frenzy lingers here — the warp-spasm that turned Cú Chulainn monstrous in war, one eye swallowed and the other bulging forth.",
"title": "Neit's Fury",
"flavor": "Old war hangs in this stone like smoke. Neit, god of battle, was grandfather to Balor and husband to Nemain, and the rage he lent the Tuatha Dé at Mag Tuired never entirely left the ground it was spent on.",
"options": [
{
"label": "Embrace the ríastrad",
"label": "Take up Neit's rage",
"desc": "+14 ATK, −20 Max HP.",
"handler": "bargain_accept",
"params": {
Expand All @@ -253,7 +253,7 @@
"label": "Master your fury",
"desc": "Nothing happens.",
"handler": "static_message",
"resultMsg": "You steady your breath. The warp-spasm passes you by, this time."
"resultMsg": "You steady your breath. The war-god's rage passes you by, this time."
}
]
},
Expand Down
11 changes: 6 additions & 5 deletions src/data/npcs.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,15 @@
"returnLine": "Still the same shape as last time, I see. A pity. You'd make a passable eagle."
},
{
"id": "scathach",
"char": "npc_scathach",
"name": "Scáthach of the Shadows",
"id": "ogma",
"char": "npc_ogma",
"name": "Ogma Grianainech",
"kind": "flavor",
"lines": [
"I trained the Hound of Ulster himself on the Isle of Shadows, and even he needed the salmon-leap shown twice. Keep your feet under you when the stone falls — that is the whole of war, in the end.",
"I cut the first ogham into birch and stood it up as a warning, and men have been reading warnings ever since. Keep your feet under you when the stone falls — that is the whole of war, in the end.",
"You grip your weapon like a farmer. Loosen the wrist. There — now you might live past the next floor.",
"I gave Cú Chulainn the Gáe Bolg and half a mind to keep it. Bres's deep is full of old students of mine, wanderer. Most of them are bones. Do better."
"At Mag Tuired I took Orna from Tethra's own hand, and when I unsheathed it the blade recited every deed it had done. A boastful thing. But it had earned the boasting.",
"They call me sun-faced. It is the strain of hauling the Fomorian dead off the field, if you want the truth of it. Bres's deep is filling with fresh ones. Do better than they did."
],
"returnLine": "Back again, and still breathing. My teaching sticks to you better than it did to most."
},
Expand Down
24 changes: 12 additions & 12 deletions src/data/rescues.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,22 @@
"serviceFlavor": "The Gobán Saor sizes up the rift's falling stone with a builder's contempt. \"Bres sends rubble. Tell me what you need and I'll send you something with a shape to it.\""
},
{
"id": "fedelm",
"char": "npc_fedelm",
"name": "Fedelm the Seeress",
"id": "bechuille",
"char": "npc_bechuille",
"name": "Bé Chuille the Enchantress",
"service": "seer",
"captiveLine": "A woman sits perfectly still among her captors, eyes closed, a cold blue flame guttering at her fingertips. \"I saw you coming,\" she murmurs. \"I saw them falling. Get on with it.\"",
"thanksLine": "Fedelm opens her eyes at last. \"I see crimson on them, and none on you. Well cut. Look for me by the hearth — the future is easier to read out of the wind.\"",
"serviceFlavor": "Fedelm turns her cold flame over in her hands, and shapes appear in it."
"captiveLine": "A woman sits perfectly still among her captors, eyes closed, turning a small grey pebble over and over in her bound hands. \"I asked the stones who was coming,\" she murmurs. \"They said you. Get on with it.\"",
"thanksLine": "Bé Chuille opens her eyes at last. \"At Mag Tuired I swore to Lugh that I would wake the trees and the stones and the very sods of the earth into a host under arms. I keep my oaths. Look for me by the hearth — falling stone has a great deal to say, and I have the ear for it.\"",
"serviceFlavor": "Bé Chuille sets a pebble spinning on her palm. It does not stop, and where it points the dark thins."
},
{
"id": "bricriu",
"char": "npc_bricriu",
"name": "Bricriu of the Feast",
"id": "nuada",
"char": "npc_nuada",
"name": "Nuada Airgetlám",
"service": "cook",
"captiveLine": "A man in a gold chain manages, somehow, to look put-upon rather than terrified. \"These gentlemen have no appreciation for hospitality. Remove them and my table is yours.\"",
"thanksLine": "Bricriu straightens his chain and bows just slightly too deep to be sincere. \"My savior. Come to my table in the mounds — the Champion's Portion is yours by right, and I never poison the guest of honor.\"",
"serviceFlavor": "Bricriu sweeps a hand over a table that wasn't there a moment ago — steam, fat, honey, and a cut of meat that is unmistakably the hero's."
"captiveLine": "A tall man stands bound among his captors, unbowed, one hand catching the light wrongly — silver, jointed, finer than any hand of flesh. \"They took my kingdom with this arm,\" he says evenly. \"They will not take the arm. Cut them down.\"",
"thanksLine": "Nuada flexes the silver hand and looks at it without fondness. \"Bres held my throne while I healed, and gave the Tuatha Dé nothing but hunger for it — no ale, no fire, no grease on any knife at his table. Come to the mound and I will show you how a king is supposed to feed his people.\"",
"serviceFlavor": "Nuada sets out the ale of Goibniu's feast — the draught that kept the Tuatha Dé from age and from wounds through the long day at Mag Tuired."
},
{
"id": "airmed",
Expand Down
6 changes: 3 additions & 3 deletions src/data/sprite-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,12 @@
"npc_emissary": { "sheet": "rogues", "sx": 128, "sy": 96, "sw": 32, "sh": 32 },
"npc_stranger": { "sheet": "rogues", "sx": 96, "sy": 0, "sw": 32, "sh": 32 },
"npc_goban": { "sheet": "rogues", "sx": 0, "sy": 96, "sw": 32, "sh": 32 },
"npc_fedelm": { "sheet": "rogues", "sx": 160, "sy": 128, "sw": 32, "sh": 32 },
"npc_bricriu": { "sheet": "rogues", "sx": 96, "sy": 64, "sw": 32, "sh": 32 },
"npc_bechuille": { "sheet": "rogues", "sx": 160, "sy": 128, "sw": 32, "sh": 32 },
"npc_nuada": { "sheet": "rogues", "sx": 96, "sy": 64, "sw": 32, "sh": 32 },
"npc_airmed": { "sheet": "rogues", "sx": 32, "sy": 64, "sw": 32, "sh": 32 },
"npc_abcan": { "sheet": "rogues", "sx": 0, "sy": 64, "sw": 32, "sh": 32 },
"npc_tuan": { "sheet": "rogues", "sx": 128, "sy": 64, "sw": 32, "sh": 32 },
"npc_scathach": { "sheet": "rogues", "sx": 160, "sy": 96, "sw": 32, "sh": 32 },
"npc_ogma": { "sheet": "rogues", "sx": 160, "sy": 96, "sw": 32, "sh": 32 },
"npc_cliodhna": { "sheet": "rogues", "sx": 64, "sy": 64, "sw": 32, "sh": 32 },
"npc_dagda": { "sheet": "rogues", "sx": 32, "sy": 128, "sw": 32, "sh": 32 },

Expand Down
6 changes: 3 additions & 3 deletions src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ export class Game {
public pendingGuardKey: string | null = null;
/** The live captor monsters — the captive can't be freed until every one is dead. */
public rescueGuards: Monster[] = []; // public: read/written by SaveGame
/** ATK granted by Bricriu's Champion's Portion, reverted on the next descent. */
public portionAtkBonus = 0; // public: read/written by VendorOffers (Bricriu) and descent revert
/** ATK granted by Nuada's draught from Goibniu's feast, reverted on the next descent. */
public portionAtkBonus = 0; // public: read/written by VendorOffers (Nuada) and descent revert

/** While the first-run tutorial is teaching, natural enemy spawns are suppressed — the tutorial introduces its own single practice foe (see spawnTutorialFoe). */
public tutorialSafety = false;
Expand Down Expand Up @@ -1425,7 +1425,7 @@ export class Game {
this.pendingRescueId = null;
this.pendingGuardKey = null;
this.rescueGuards = [];
// Bricriu's Champion's Portion is a single meal — it ends at the descent.
// Nuada's draught is a single cup — it ends at the descent.
if (this.portionAtkBonus > 0) {
this.player.atk -= this.portionAtkBonus;
this.portionAtkBonus = 0;
Expand Down
Loading