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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Built with TypeScript + Vite as an installable PWA. Rendering is a single `<canv
| **Lugh's Spear questline** | `src/data/smiths.json`, `src/smithQuest.ts` | Every 3rd floor (skipping boss floors) toasts "You hear the clang of an anvil..." on entry, "The sound of anvils is getting stronger!" at 10 shapes placed, and embeds one of three legendary smiths (Luchta, Credne, Goibniu) as a guaranteed encounter on the 20th shape dropped that floor (`balance.json`'s `smiths` block). Each grants one spear part; Goibniu's third meeting reforges it, replacing the player's ranged ability with a bolt that pierces every monster straight up their own shape column. |
| **Shape reward** | `src/game.ts` | Clearing all 4 lines at once (once per run) draws the eye of **An Dagda** himself: no dialog interrupts the clear — the Good God takes a seat in the sídhe mound's corner with his cauldron, and greeting him gifts one random tier-3 Geis. |
| **Floor progress dial** | `src/ui.ts`, `#hud-strip` | The HUD shows one compact segment per pending milestone — smith piece count, boss fill %, stairs pity countdown (hidden while a stairs tile is on the board) — so "keep stacking or descend?" is an informed choice. |
| **Floor readiness** | `src/game.ts`, `src/ui.ts` (`balance.json` → `floorReadiness`) | The soft answer to "how much Tetris should I play?" Each non-boss floor has a readiness target that scales with depth (`baseTarget + (level-1)×perLevel` blocks). The dial leads with it — *building* `6/12` → a glowing *ripe* once you've built the floor out → *lingering* past `target×lingerMult` — with one-time toasts at the *ripe* ("descend when you're ready") and *lingering* ("the rift grows restless") thresholds. **Descent is never gated** — it's pure guidance. The run's average pieces/target ratio is banked at each descent, and the **seanchaí** in the mound judges it: leaving too soon, lingering too long, or judging it well (thresholds `seanchaiEarlyRatio`/`seanchaiLateRatio`). |
| **Floor omens** | `src/data/omens.json`, `src/game.ts` | ~60% of non-boss floors past floor 1 roll a per-floor modifier, announced by toast and shown as a sidebar badge: the Rising Bog (low rows turn swamp), Féth Fíada (vision −2), Cluricaun's Hoard (2× line-clear gold), the Unquiet Cairn (more monsters, skeleton-heavy), Fomorian Weight (gravity +20%), Wild Rift-Surge (2.5× cursed/blessed pieces), and the Night of Bealtaine ritual below. All param-driven — adding an omen is a JSON edit. |
| **Night of Bealtaine** | `src/game.ts` | A ritual omen: every 5th piece carries a brazier; walk into an unlit one to light it. Line clears destroy braziers (protect their rows!) but lit progress banks and replacements keep coming. Light all three → a free tier-III Geis choice. |
| **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. |
Expand Down
19 changes: 19 additions & 0 deletions schema/balance.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"ghosts",
"narrative",
"smiths",
"floorReadiness",
"omens",
"spearOfLugh",
"difficulty",
Expand Down Expand Up @@ -482,6 +483,24 @@
}
}
},
"floorReadiness": {
"type": "object",
"additionalProperties": false,
"required": [
"baseTarget",
"perLevel",
"lingerMult",
"seanchaiEarlyRatio",
"seanchaiLateRatio"
],
"properties": {
"baseTarget": { "type": "number", "minimum": 1 },
"perLevel": { "type": "number", "minimum": 0 },
"lingerMult": { "type": "number", "minimum": 1 },
"seanchaiEarlyRatio": { "type": "number", "minimum": 0, "maximum": 1 },
"seanchaiLateRatio": { "type": "number", "minimum": 1 }
}
},
"omens": {
"type": "object",
"additionalProperties": false,
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/game.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1571,6 +1571,91 @@ describe('Biome-signature terrain (per-biome density)', () => {
});
});

describe('Floor readiness (soft "how much Tetris?" guidance)', () => {
type Priv = { blocksSpawnedThisFloor: number; spawnBlock(): void };
const priv = (g: Game): Priv => g as unknown as Priv;

it('the readiness target scales with depth: base at floor 1, +perLevel per floor', () => {
const c = Balance.CONFIG.floorReadiness;
const game = new Game(makeCallbacks());
game.dungeonLevel = 1;
expect(game.floorReadinessTarget()).toBe(c.baseTarget);
game.dungeonLevel = 4;
expect(game.floorReadinessTarget()).toBe(c.baseTarget + 3 * c.perLevel);
// The lingering ceiling is target * lingerMult.
expect(game.floorLingerCeiling()).toBe(Math.ceil((c.baseTarget + 3 * c.perLevel) * c.lingerMult));
});

it('the dial reports building → ripe → lingering as blocks accumulate', () => {
const game = new Game(makeCallbacks());
game.dungeonLevel = 2;
const target = game.floorReadinessTarget();
const ceiling = game.floorLingerCeiling();
priv(game).blocksSpawnedThisFloor = target - 1;
expect(game.floorProgressState().readiness?.state).toBe('building');
priv(game).blocksSpawnedThisFloor = target;
expect(game.floorProgressState().readiness?.state).toBe('ripe');
priv(game).blocksSpawnedThisFloor = ceiling;
expect(game.floorProgressState().readiness?.state).toBe('lingering');
});

it('readiness is suppressed on boss floors, in the mound, and during the tutorial', () => {
const game = new Game(makeCallbacks());
game.dungeonLevel = Balance.CONFIG.floors.bossFloorInterval; // a boss floor
expect(game.floorProgressState().readiness).toBeNull();
game.dungeonLevel = 2;
game.inWaystation = true;
expect(game.floorProgressState().readiness).toBeNull();
game.inWaystation = false;
game.tutorialSafety = true;
expect(game.floorProgressState().readiness).toBeNull();
});

it('crossing the target toasts "ripe" exactly once', () => {
const cb = makeCallbacks();
const game = new Game(cb);
game.dungeonLevel = 2;
const target = game.floorReadinessTarget();
priv(game).blocksSpawnedThisFloor = target - 1; // next spawn crosses it
(cb.onToast as ReturnType<typeof vi.fn>).mockClear();
priv(game).spawnBlock();
expect(cb.onToast).toHaveBeenCalledWith(expect.stringContaining('ripe'), expect.any(String));
(cb.onToast as ReturnType<typeof vi.fn>).mockClear();
priv(game).spawnBlock(); // still past target, but already announced
expect(cb.onToast).not.toHaveBeenCalledWith(expect.stringContaining('ripe'), expect.any(String));
});

it('recordFloorPacing banks pieces/target on normal floors and skips boss floors', () => {
const game = new Game(makeCallbacks());
game.dungeonLevel = 2;
priv(game).blocksSpawnedThisFloor = game.floorReadinessTarget(); // ratio exactly 1
game.recordFloorPacing();
expect(game.pacingSamples).toBe(1);
expect(game.pacingRatioSum).toBeCloseTo(1, 5);
// A boss floor contributes nothing (judged by the fight, not readiness).
game.dungeonLevel = Balance.CONFIG.floors.bossFloorInterval;
priv(game).blocksSpawnedThisFloor = 99;
game.recordFloorPacing();
expect(game.pacingSamples).toBe(1);
});

it('the seanchai withholds a verdict until two floors, then reads the average pacing', () => {
const c = Balance.CONFIG.floorReadiness;
const game = new Game(makeCallbacks());
expect(game.pacingAdvice()).toBe(''); // no samples yet
// Two hasty descents (well under the early ratio) → "too soon".
game.pacingSamples = 2;
game.pacingRatioSum = (c.seanchaiEarlyRatio - 0.2) * 2;
expect(game.pacingAdvice()).toContain('too soon');
// Two long stays (over the late ratio) → "linger".
game.pacingRatioSum = (c.seanchaiLateRatio + 0.3) * 2;
expect(game.pacingAdvice().toLowerCase()).toContain('linger');
// In-band → the approving line.
game.pacingRatioSum = 1 * 2;
expect(game.pacingAdvice()).toContain('judge');
});
});

describe('Floor omens (per-floor modifiers)', () => {
const asOmen = (g: Game): { maybeRollOmen(isBossFloor: boolean): void } =>
g as unknown as { maybeRollOmen(isBossFloor: boolean): void };
Expand Down
2 changes: 2 additions & 0 deletions src/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export interface BalanceConfig {
ghosts: { encounterChance: number; levelTolerance: number; maxStored: number };
narrative: { closeCallHpFraction: number };
smiths: { floorInterval: number; pieceThreshold: number; warningThreshold: number };
/** Soft per-floor "how much Tetris?" guidance. `baseTarget + (level-1)*perLevel` pieces ripens a floor; `target*lingerMult` marks over-staying. The seanchai judges the run's average pieces/target ratio against the two ratio bounds. */
floorReadiness: { baseTarget: number; perLevel: number; lingerMult: number; seanchaiEarlyRatio: number; seanchaiLateRatio: number };
omens: { rollChance: number };
well: { baseCost: number; costPerFloor: number; baseXp: number; xpPerFloor: number };
waystation: { tattooistChance: number; stashRecoveryPct: number };
Expand Down
7 changes: 7 additions & 0 deletions src/data/balance.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@
"pieceThreshold": 20,
"warningThreshold": 10
},
"floorReadiness": {
"baseTarget": 12,
"perLevel": 3,
"lingerMult": 2,
"seanchaiEarlyRatio": 0.7,
"seanchaiLateRatio": 1.6
},
"omens": {
"rollChance": 0.6
},
Expand Down
81 changes: 80 additions & 1 deletion src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,17 @@ export class Game {
private blocksSpawnedThisFloor = 0;
/** Whether the "anvils are getting stronger" mid-floor warning has already fired this floor. */
private smithWarningShown = false;

// Floor-readiness guidance (see Balance.CONFIG.floorReadiness). Advisory only —
// descent is never gated; these just tell the player when a floor is worth leaving.
/** Whether the one-time "this floor is ripe" toast has fired this floor. */
private floorRipeAnnounced = false;
/** Whether the one-time "you're lingering" toast has fired this floor. */
private floorLingerAnnounced = false;
/** Running sum of (pieces built / readiness target) over the non-boss floors left so far — the seanchaí reads the average to judge pacing. */
public pacingRatioSum = 0;
/** How many non-boss floors have contributed to {@link pacingRatioSum}. */
public pacingSamples = 0;
/** Same rider-preview pattern as {@link pendingNpcId}, for the falling piece's `Cell.SMITH` cell. */
public pendingSmithId: string | null = null;
/** How many of the three legendary smiths have been met this run (capped at 3, once the spear is forged). */
Expand Down Expand Up @@ -485,6 +496,22 @@ export class Game {
this.cb.onToast?.('The sound of anvils is getting stronger!', 'fx_impact');
}

// Floor readiness: a one-time "ripe" cue when the floor is well-built (the
// answer to "how much Tetris?"), then a one-time "lingering" cue past the
// point of good returns. Only on normal building floors — boss floors are
// judged by the fill dial, and the mound/duel have no Tetris to pace.
if (this.readinessApplies()) {
if (!this.floorRipeAnnounced && this.blocksSpawnedThisFloor >= this.floorReadinessTarget()) {
this.floorRipeAnnounced = true;
this.cb.log('The floor is well-built now — room made, ground laid, spoils gathered. Descend whenever you like.', 'log-success', 'tile_stairs_up');
this.cb.onToast?.('This floor is ripe — descend when you are ready.', 'tile_stairs_up');
} else if (this.floorRipeAnnounced && !this.floorLingerAnnounced && this.blocksSpawnedThisFloor >= this.floorLingerCeiling()) {
this.floorLingerAnnounced = true;
this.cb.log('You have wrung this floor near dry; the rift grows restless around you.', 'log-neutral', 'ui_warning');
this.cb.onToast?.('The rift grows restless — press on soon.', 'ui_warning');
}
}

let stairsInjected = false;
let bossInjected = false;
let smithInjected = false;
Expand Down Expand Up @@ -1339,6 +1366,7 @@ export class Game {

/** Advances the dungeon level counter and rebuilds the floor (used when the stack's top row itself scrolls off the bottom). */
private transitionToNextFloor(): void {
this.recordFloorPacing(); // a stack-collapse counts as staying to the very limit
this.dungeonLevel++;
this.floorsDescended++;
const isBossFloor = this.dungeonLevel % Balance.CONFIG.floors.bossFloorInterval === 0;
Expand Down Expand Up @@ -1390,6 +1418,8 @@ export class Game {
this.npcTilesSpawnedThisFloor = 0;
this.blocksSpawnedThisFloor = 0;
this.smithWarningShown = false;
this.floorRipeAnnounced = false;
this.floorLingerAnnounced = false;
// A rescue that never landed (or was never freed) lapses with the floor —
// the captive may ride again later; their captors stayed behind either way.
this.pendingRescueId = null;
Expand Down Expand Up @@ -1856,7 +1886,10 @@ export class Game {
if (this.inCausewayDuel) { this.inCausewayDuel = false; this.openStairsChoice(); }
// The mound's own exit stairs go straight down — you already rested.
else if (this.inWaystation) this.descendFloor();
else this.openStairsChoice();
// A normal floor's stairs: bank how long this floor was worked (the
// player is leaving its Tetris now, whether they rest or delve) before
// the delve-or-rest choice.
else { this.recordFloorPacing(); this.openStairsChoice(); }
} else {
this.advanceTurn();
}
Expand Down Expand Up @@ -2182,6 +2215,52 @@ export class Game {
stairsPity: this.stairsOnBoard()
? null
: { placed: this.blocksPlacedSinceStairs, target: Balance.CONFIG.spawnRates.stairsForcedAfterBlocks },
readiness: this.readinessApplies()
? {
pieces: this.blocksSpawnedThisFloor,
target: this.floorReadinessTarget(),
state: this.blocksSpawnedThisFloor >= this.floorLingerCeiling()
? 'lingering'
: this.blocksSpawnedThisFloor >= this.floorReadinessTarget()
? 'ripe'
: 'building',
}
: null,
};
}

/** Whether floor-readiness guidance is meaningful right now: a normal building floor, not a boss floor, the mound, a duel, the endgame, or the tutorial. */
private readinessApplies(): boolean {
return !this.tutorialSafety && !this.inWaystation && !this.gorgothSummoned && !this.inCausewayDuel
&& this.dungeonLevel % Balance.CONFIG.floors.bossFloorInterval !== 0;
}

/** Pieces that ripen the current floor: `baseTarget + (level-1)*perLevel`, so later floors ask for more Tetris (all knobs in `balance.json`'s `floorReadiness`). */
public floorReadinessTarget(level = this.dungeonLevel): number {
const c = Balance.CONFIG.floorReadiness;
return Math.max(1, Math.round(c.baseTarget + Math.max(0, level - 1) * c.perLevel));
}

/** Piece count past which staying reads as over-mining: `target * lingerMult`. */
public floorLingerCeiling(level = this.dungeonLevel): number {
return Math.ceil(this.floorReadinessTarget(level) * Balance.CONFIG.floorReadiness.lingerMult);
}

/** Banks one pieces/target pacing sample for the floor being left (non-boss floors only), for the seanchaí's read on the run. */
public recordFloorPacing(): void {
if (this.tutorialSafety) return;
if (this.dungeonLevel % Balance.CONFIG.floors.bossFloorInterval === 0) return;
this.pacingRatioSum += this.blocksSpawnedThisFloor / this.floorReadinessTarget();
this.pacingSamples++;
}

/** The seanchaí's verdict on the run's average descent pacing, or `''` before there's enough to judge. */
public pacingAdvice(): string {
if (this.pacingSamples < 2) return '';
const avg = this.pacingRatioSum / this.pacingSamples;
const c = Balance.CONFIG.floorReadiness;
if (avg < c.seanchaiEarlyRatio) return 'You go down too soon, I think — half a floor mined and already hunting the stair. There is more up here for the patient.';
if (avg > c.seanchaiLateRatio) return 'You linger long in every hall, wringing each for its last stone. Brave — but the deep rewards the bold who move, not only the thorough.';
return 'You judge your descents well — you take what a floor owes you, and no more. That is a rare sense down here.';
}
}
4 changes: 3 additions & 1 deletion src/npcEncounters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ export class NpcEncounters {
? `already you ${beats[0]!}`
: `already you ${beats.slice(0, -1).join(', ')}, and ${beats[beats.length - 1]!}`;
const more = g.storyBeats.length > 5 ? ' …and more besides — the verse grows long.' : '';
return `He closes his eyes and speaks it like an old poem: "${cls}, ${g.dungeonLevel} floor${g.dungeonLevel === 1 ? '' : 's'} into the dark — ${joined}.${more}" He opens one eye. "The ending, now. That part is still yours."`;
const pacing = g.pacingAdvice();
const pacingLine = pacing ? ` He weighs you a moment. "${pacing}"` : '';
return `He closes his eyes and speaks it like an old poem: "${cls}, ${g.dungeonLevel} floor${g.dungeonLevel === 1 ? '' : 's'} into the dark — ${joined}.${more}"${pacingLine} He opens one eye. "The ending, now. That part is still yours."`;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ body {
.hud-floor-progress .fp-smith { color: #d9a441; }
.hud-floor-progress .fp-boss { color: #e57373; }
.hud-floor-progress .fp-stairs { color: #9fa8da; }
/* Floor readiness: neutral while building, gold+glow when ripe (leave now),
amber when lingering (over-mined). */
.hud-floor-progress .fp-build { color: #9a9482; }
.hud-floor-progress .fp-ripe { color: #ffd257; text-shadow: 0 0 6px rgba(255, 210, 87, 0.7); }
.hud-floor-progress .fp-linger { color: #e8a13c; }
.hud-next-preview { display: flex; align-items: center; justify-content: center; }

/* ── Input-mode visibility helpers ──────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,8 @@ export interface UIState {
bossFillTarget: number | null;
/** Pieces placed since the last stairs cell and the count that force-injects the next one — null while a stairs tile is already somewhere on the board (the countdown would be misleading). */
stairsPity: { placed: number; target: number } | null;
/** Soft "how much Tetris?" guidance: pieces vs the floor's readiness target, and which band the floor is in (keep building / ripe to leave / lingering). Null on boss floors, in the mound, and mid-duel, where readiness doesn't apply. */
readiness: { pieces: number; target: number; state: 'building' | 'ripe' | 'lingering' } | null;
};
}

Expand Down
Loading