diff --git a/README.md b/README.md index e7248c6..50dd581 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Built with TypeScript + Vite as an installable PWA. Rendering is a single ` { }); }); +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).mockClear(); + priv(game).spawnBlock(); + expect(cb.onToast).toHaveBeenCalledWith(expect.stringContaining('ripe'), expect.any(String)); + (cb.onToast as ReturnType).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 }; diff --git a/src/balance.ts b/src/balance.ts index d0e201e..24c3e39 100644 --- a/src/balance.ts +++ b/src/balance.ts @@ -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 }; diff --git a/src/data/balance.json b/src/data/balance.json index cefc89e..cea3712 100644 --- a/src/data/balance.json +++ b/src/data/balance.json @@ -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 }, diff --git a/src/game.ts b/src/game.ts index 755f42a..b796d14 100644 --- a/src/game.ts +++ b/src/game.ts @@ -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). */ @@ -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; @@ -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; @@ -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; @@ -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(); } @@ -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.'; + } } diff --git a/src/npcEncounters.ts b/src/npcEncounters.ts index c192bd6..0c8475a 100644 --- a/src/npcEncounters.ts +++ b/src/npcEncounters.ts @@ -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."`; } /** diff --git a/src/style.css b/src/style.css index 4387574..d89e30f 100644 --- a/src/style.css +++ b/src/style.css @@ -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 ────────────────────────────────────────── diff --git a/src/types.ts b/src/types.ts index 05c39ff..206f746 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; }; } diff --git a/src/ui.ts b/src/ui.ts index 867249b..36208ed 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -196,6 +196,20 @@ export class UIManager { const fp = state.floorProgress; const fpSegments: string[] = []; const fpSidebar: string[] = []; + // Readiness leads the dial — it's the "how much Tetris?" answer. + if (fp.readiness) { + const r = fp.readiness; + if (r.state === 'ripe') { + fpSegments.push(`${SpriteService.iconHTML('tile_stairs_up', 10)}ripe`); + fpSidebar.push(`Floor ripe — descend when ready (${r.pieces}/${r.target} blocks)`); + } else if (r.state === 'lingering') { + fpSegments.push(`${SpriteService.iconHTML('ui_warning', 10)}linger`); + fpSidebar.push(`Lingering — ${r.pieces} blocks (ripe at ${r.target})`); + } else { + fpSegments.push(`${SpriteService.iconHTML('tile_stone_a', 10)}${r.pieces}/${r.target}`); + fpSidebar.push(`Build the floor — ${r.pieces}/${r.target} blocks to ripe`); + } + } if (fp.smithTarget !== null) { fpSegments.push(`${SpriteService.iconHTML('smith_goibniu', 10)}${Math.min(fp.pieces, fp.smithTarget)}/${fp.smithTarget}`); fpSidebar.push(`Smith at ${fp.smithTarget} blocks (${Math.min(fp.pieces, fp.smithTarget)})`);