From 8ab469909628eba5cdd6385746dd00b429144b1e Mon Sep 17 00:00:00 2001 From: Map Date: Mon, 18 May 2026 00:11:23 +0100 Subject: [PATCH 1/2] CG-0MP2A0X0K007JRU4: Add HUD status-bar tooltips for Coins, Reputation, and Score - Add MainStreetHudTooltips.ts module with tooltip content builder functions for Coins (income breakdown), Reputation (multiplier details), and Score (estimate + next tier threshold). - Include i18n key registry (HUD_TOOLTIP_I18N_KEYS, HUD_ARIA_I18N_KEYS) and default localizable strings (HUD_TOOLTIP_STRINGS). - Add ARIA labels for each interactive HUD zone (HUD_ARIA_LABELS). - Modify MainStreetRenderer.refreshHud() to attach interactive tooltip zones to coinText, repText, and scoreText via new attachHudTooltipZone() method. - Desktop: pointerover shows tooltip; pointerout hides it. - Mobile: tap toggles tooltip on/off. - Tooltips respect TooltipManager / SettingsPanel.showTooltips toggle. - Add unit tests (18 tests) for tooltip builders and i18n key consistency. - Add browser integration test verifying tooltip zones are attached and tooltipManager.show() is called with correct content. --- .../scenes/MainStreetHudTooltips.ts | 194 ++++++++++++++ .../main-street/scenes/MainStreetRenderer.ts | 75 ++++++ .../MainStreetScene.browser.test.ts | 58 ++++ tests/main-street/hud-tooltips.test.ts | 251 ++++++++++++++++++ 4 files changed, 578 insertions(+) create mode 100644 example-games/main-street/scenes/MainStreetHudTooltips.ts create mode 100644 tests/main-street/hud-tooltips.test.ts diff --git a/example-games/main-street/scenes/MainStreetHudTooltips.ts b/example-games/main-street/scenes/MainStreetHudTooltips.ts new file mode 100644 index 0000000..437d193 --- /dev/null +++ b/example-games/main-street/scenes/MainStreetHudTooltips.ts @@ -0,0 +1,194 @@ +/** + * MainStreetHudTooltips -- Tooltip content builders for the HUD status bar. + * + * Provides localizable string keys and builder functions for the Coins, + * Reputation, and Score tooltips shown when hovering/tapping HUD values. + * + * ## i18n key convention + * Each tooltip string is keyed under `hud.tooltip.` so that a future + * i18n system can swap implementations. For now the strings are built with + * template functions that embed computed numeric values. + * + * ## ARIA labels + * Each interactive zone carries a static `aria-label` for screen readers. + * These are also localizable via the same key convention. + * + * @module + */ + +import { computeIncome, type IncomeResult } from '../MainStreetAdjacency'; +import { reputationCoinMultiplier, applyReputationMultiplier } from '../MainStreetDifficulty'; +import { ORDERED_TIER_DEFINITIONS } from '../MainStreetTiers'; +import { computeScore } from '../MainStreetEngine'; +import type { MainStreetState, MainStreetCampaignProgress } from '../MainStreetState'; + +// ── i18n Keys ─────────────────────────────────────────────── + +/** The set of i18n keys used by HUD tooltips. A future localisation layer can + * swap implementations for these keys. */ +export const HUD_TOOLTIP_I18N_KEYS = { + coinsTitle: 'hud.tooltip.coins.title', + coinsIncomeLabel: 'hud.tooltip.coins.income', + coinsPreMultiplierLabel: 'hud.tooltip.coins.preMultiplier', + coinsPostMultiplierLabel: 'hud.tooltip.coins.postMultiplier', + coinsCalcNote: 'hud.tooltip.coins.calcNote', + repTitle: 'hud.tooltip.rep.title', + repValueLabel: 'hud.tooltip.rep.value', + repMultiplierLabel: 'hud.tooltip.rep.multiplier', + repEffectLabel: 'hud.tooltip.rep.effect', + scoreTitle: 'hud.tooltip.score.title', + scoreEstimateLabel: 'hud.tooltip.score.estimate', + scoreNextTierLabel: 'hud.tooltip.score.nextTier', + scoreAllTiersUnlocked: 'hud.tooltip.score.allTiersUnlocked', +} as const; + +/** ARIA label i18n keys (for screen-reader accessibility). */ +export const HUD_ARIA_I18N_KEYS = { + coins: 'hud.aria.coins', + rep: 'hud.aria.rep', + score: 'hud.aria.score', +} as const; + +// ── ARIA label defaults (localize here) ───────────────────── + +/** Default ARIA labels for each HUD interactive zone. */ +export const HUD_ARIA_LABELS = { + coins: 'Coins status — hover for expected income breakdown', + rep: 'Reputation status — hover for multiplier details', + score: 'Score status — hover for next tier threshold', +} as const; + +// ── Default string templates (localize here) ────────────────── + +/** Default English string templates. Swappable via i18n key map. */ +export const HUD_TOOLTIP_STRINGS = { + coinsTitle: 'Income This Turn', + coinsIncomeLabel: 'Base income', + coinsPreMultiplierLabel: 'Before reputation', + coinsPostMultiplierLabel: 'After reputation', + coinsCalcNote: 'Sum of business incomes + synergy bonuses', + repTitle: 'Reputation', + repValueLabel: 'Reputation', + repMultiplierLabel: 'Coin multiplier', + repEffectLabel: 'Higher reputation multiplies coin income (capped)', + scoreTitle: 'Score Estimate', + scoreEstimateLabel: 'Estimated score', + scoreNextTierLabel: 'Next tier', + scoreAllTiersUnlocked: 'All tiers unlocked', +} as const; + +// ── Tooltip Content Builders ───────────────────────────────── + +/** + * Builds the tooltip content string for the Coins HUD element. + * + * Shows: + * - Base income this turn (pre-reputation multiplier) + * - Multiplied income (post-reputation multiplier) + * - Brief calculation note + */ +export function buildCoinsTooltip(state: MainStreetState): string { + const incomeResult = computeIncome(state.streetGrid, state.config.synergyBonusPerNeighbor); + const baseIncome = incomeResult.total; + const multipliedIncome = applyReputationMultiplier( + baseIncome, + state.resourceBank.reputation, + state.config, + ); + const multiplier = reputationCoinMultiplier(state.resourceBank.reputation, state.config); + const multiplierStr = Number.isFinite(multiplier) ? multiplier.toFixed(1) : '1.0'; + + const lines = [ + HUD_TOOLTIP_STRINGS.coinsTitle, + `${HUD_TOOLTIP_STRINGS.coinsPreMultiplierLabel}: ${baseIncome}`, + `${HUD_TOOLTIP_STRINGS.coinsPostMultiplierLabel}: ${multipliedIncome} (×${multiplierStr})`, + HUD_TOOLTIP_STRINGS.coinsCalcNote, + ]; + + return lines.join('\n'); +} + +/** + * Builds the full IncomeResult for external use (e.g. tests). + */ +export function getIncomeResult(state: MainStreetState): IncomeResult { + return computeIncome(state.streetGrid, state.config.synergyBonusPerNeighbor); +} + +/** + * Builds the tooltip content string for the Reputation HUD element. + * + * Shows: + * - Current reputation value + * - Active coin multiplier (numeric) + * - Short explanation of reputation effect on income + */ +export function buildReputationTooltip(state: MainStreetState): string { + const rep = state.resourceBank.reputation; + const multiplier = reputationCoinMultiplier(rep, state.config); + const multiplierStr = Number.isFinite(multiplier) ? multiplier.toFixed(1) : '1.0'; + + const lines = [ + HUD_TOOLTIP_STRINGS.repTitle, + `${HUD_TOOLTIP_STRINGS.repValueLabel}: ${rep}`, + `${HUD_TOOLTIP_STRINGS.repMultiplierLabel}: ×${multiplierStr}`, + HUD_TOOLTIP_STRINGS.repEffectLabel, + ]; + + return lines.join('\n'); +} + +/** + * Builds the tooltip content string for the Score HUD element. + * + * Shows: + * - Current final-score estimate + * - Next locked tier name and reputation threshold (or "All tiers unlocked") + */ +export function buildScoreTooltip( + state: MainStreetState, + campaign: MainStreetCampaignProgress | null, +): string { + const score = computeScore(state); + + // Determine next locked tier + const unlockedTiers = campaign?.unlockedTiers ?? ['tier-1']; + const nextTier = findNextLockedTier(unlockedTiers); + + const lines = [ + HUD_TOOLTIP_STRINGS.scoreTitle, + `${HUD_TOOLTIP_STRINGS.scoreEstimateLabel}: ${score}`, + ]; + + if (nextTier) { + lines.push( + `${HUD_TOOLTIP_STRINGS.scoreNextTierLabel}: ${nextTier.name} (requires Rep ≥ ${nextTier.reputationThreshold})`, + ); + } else { + lines.push(HUD_TOOLTIP_STRINGS.scoreAllTiersUnlocked); + } + + return lines.join('\n'); +} + +// ── Helpers ─────────────────────────────────────────────────── + +/** + * Finds the next tier that is NOT yet unlocked. + * Returns the tier definition, or undefined if all tiers are unlocked. + */ +export function findNextLockedTier( + unlockedTiers: string[], +): { id: string; name: string; reputationThreshold: number } | undefined { + const unlockedSet = new Set(unlockedTiers); + for (const tier of ORDERED_TIER_DEFINITIONS) { + if (!unlockedSet.has(tier.id)) { + return { + id: tier.id, + name: tier.name, + reputationThreshold: tier.reputationThreshold, + }; + } + } + return undefined; +} \ No newline at end of file diff --git a/example-games/main-street/scenes/MainStreetRenderer.ts b/example-games/main-street/scenes/MainStreetRenderer.ts index 552216a..ef0a21b 100644 --- a/example-games/main-street/scenes/MainStreetRenderer.ts +++ b/example-games/main-street/scenes/MainStreetRenderer.ts @@ -13,6 +13,12 @@ import { REFRESH_INVESTMENTS_COST, } from '../MainStreetCards'; import { computeScore } from '../MainStreetEngine'; +import { + buildCoinsTooltip, + buildReputationTooltip, + buildScoreTooltip, + HUD_ARIA_LABELS, +} from './MainStreetHudTooltips'; import { getAffordableBusinessCards, getAffordableUpgradeCards, @@ -301,6 +307,13 @@ export class MainStreetRenderer { }).setOrigin(0, 0.5)); s.hudContainer.add(scoreText); + // HUD tooltip zones (desktop: pointer hover, mobile: tap toggle) + if (!s.replayMode) { + this.attachHudTooltipZone(coinText, HUD_ARIA_LABELS.coins, () => buildCoinsTooltip(s.state)); + this.attachHudTooltipZone(repText, HUD_ARIA_LABELS.rep, () => buildReputationTooltip(s.state)); + this.attachHudTooltipZone(scoreText, HUD_ARIA_LABELS.score, () => buildScoreTooltip(s.state, s.campaign)); + } + s.animateHudValueChanges({ coins, reputation, @@ -310,6 +323,68 @@ export class MainStreetRenderer { }); } + /** + * Attaches an interactive tooltip zone to a HUD text element. + * + * On desktop (pointer), shows the tooltip on hover and hides on leave. + * On mobile (touch), the first tap shows the tooltip and a second + * tap (or tap elsewhere) dismisses it. + * + * ARIA labels are set on the underlying text object for screen-readers. + */ + private attachHudTooltipZone( + textObj: Phaser.GameObjects.Text, + ariaLabel: string, + contentBuilder: () => string, + ): void { + const s = this.scene; + + // Set ARIA label for screen-reader accessibility + try { + const node = (textObj as any).node; + if (node && typeof node.setAttribute === 'function') { + node.setAttribute('aria-label', ariaLabel); + node.setAttribute('role', 'button'); + node.setAttribute('tabindex', '0'); + } + } catch (_) { /* ignore in non-DOM environments */ } + + // Compute hit area size from text metrics + const w = Math.max(textObj.width, 60); + const h = Math.max(textObj.height, 20); + + textObj.setInteractive( + new Phaser.Geom.Rectangle(0, 0, w / textObj.scaleX, h / textObj.scaleY), + Phaser.Geom.Rectangle.Contains, + ); + + // Mobile tap-toggle state (per element) + let tooltipVisible = false; + + textObj.on('pointerover', () => { + tooltipVisible = true; + const content = contentBuilder(); + s.tooltipManager?.show(content, textObj.x, textObj.y - 10); + }); + + textObj.on('pointerout', () => { + tooltipVisible = false; + s.tooltipManager?.hide(); + }); + + // Mobile / tap: toggle on pointerdown + textObj.on('pointerdown', () => { + if (tooltipVisible) { + tooltipVisible = false; + s.tooltipManager?.hide(); + } else { + tooltipVisible = true; + const content = contentBuilder(); + s.tooltipManager?.show(content, textObj.x, textObj.y - 10); + } + }); + } + public refreshChallengeTracker(): void { const s = this.scene; s.challengeContainer.removeAll(true); diff --git a/tests/main-street/MainStreetScene.browser.test.ts b/tests/main-street/MainStreetScene.browser.test.ts index ba49baa..1986419 100644 --- a/tests/main-street/MainStreetScene.browser.test.ts +++ b/tests/main-street/MainStreetScene.browser.test.ts @@ -408,4 +408,62 @@ describe('MainStreetScene browser tests', () => { destroyGame(game); game = null; }); + + it('attaches interactive tooltip zones to HUD Coins, Reputation, and Score text elements', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as Phaser.Scene & Record; + + // The HUD should have been refreshed with tooltip zones on the text elements. + // We verify that hovering over the coin/rep/score text triggers tooltipManager.show. + const tooltipShowSpy = vi.spyOn(scene.tooltipManager, 'show'); + const tooltipHideSpy = vi.spyOn(scene.tooltipManager, 'hide'); + + // Find transient text objects in hudContainer + const hudList = scene.hudContainer.list as Phaser.GameObjects.GameObject[]; + const textObjects = hudList.filter( + (obj) => obj instanceof Phaser.GameObjects.Text && (obj as any)._hudTransient, + ) as Phaser.GameObjects.Text[]; + + // Should have at least coin, rep, score, and strip texts + expect(textObjects.length).toBeGreaterThanOrEqual(3); + + // Find specific text objects by content + const coinText = textObjects.find((t) => t.text.startsWith('Coins:')); + const repText = textObjects.find((t) => t.text.startsWith('Rep:')); + const scoreText = textObjects.find((t) => t.text.startsWith('Score:')); + + expect(coinText).toBeTruthy(); + expect(repText).toBeTruthy(); + expect(scoreText).toBeTruthy(); + + // Emit pointerover on coin text + coinText!.emit('pointerover'); + expect(tooltipShowSpy).toHaveBeenCalled(); + const coinCallArgs = tooltipShowSpy.mock.calls[0]; + // The tooltip content should mention income + expect(coinCallArgs[0]).toContain('Income'); + tooltipShowSpy.mockClear(); + + // Emit pointerout + coinText!.emit('pointerout'); + expect(tooltipHideSpy).toHaveBeenCalled(); + tooltipHideSpy.mockClear(); + + // Emit pointerover on rep text + repText!.emit('pointerover'); + expect(tooltipShowSpy).toHaveBeenCalled(); + const repCallArgs = tooltipShowSpy.mock.calls[0]; + expect(repCallArgs[0]).toContain('Reputation'); + tooltipShowSpy.mockClear(); + + // Emit pointerover on score text + scoreText!.emit('pointerover'); + expect(tooltipShowSpy).toHaveBeenCalled(); + const scoreCallArgs = tooltipShowSpy.mock.calls[0]; + expect(scoreCallArgs[0]).toContain('Score'); + tooltipShowSpy.mockClear(); + + destroyGame(game); + game = null; + }); }); diff --git a/tests/main-street/hud-tooltips.test.ts b/tests/main-street/hud-tooltips.test.ts new file mode 100644 index 0000000..fd9bb80 --- /dev/null +++ b/tests/main-street/hud-tooltips.test.ts @@ -0,0 +1,251 @@ +/** + * Main Street: HUD Tooltip Content Builder Tests + * + * Unit tests for the HUD tooltip content builders in MainStreetHudTooltips + * and integration-style tests verifying tooltip zone attachment in the + * browser test suite (MainStreetScene.browser.test.ts). + * + * Work item: CG-0MP2A0X0K007JRU4 + */ +import { describe, it, expect } from 'vitest'; + +import { + buildCoinsTooltip, + buildReputationTooltip, + buildScoreTooltip, + findNextLockedTier, + HUD_TOOLTIP_I18N_KEYS, + HUD_ARIA_I18N_KEYS, + HUD_TOOLTIP_STRINGS, +} from '../../example-games/main-street/scenes/MainStreetHudTooltips'; + +import { + setupMainStreetGame, + type MainStreetCampaignProgress, +} from '../../example-games/main-street/MainStreetState'; + +import { + reputationCoinMultiplier, +} from '../../example-games/main-street/MainStreetDifficulty'; + +import { ORDERED_TIER_DEFINITIONS } from '../../example-games/main-street/MainStreetTiers'; +import { computeIncome } from '../../example-games/main-street/MainStreetAdjacency'; +import { computeScore } from '../../example-games/main-street/MainStreetEngine'; + +// ── Unit tests: i18n key consistency ───────────────────────── + +describe('HUD tooltip i18n keys', () => { + it('has a key for every tooltip string', () => { + const stringKeys = Object.keys(HUD_TOOLTIP_STRINGS); + const i18nKeys = Object.keys(HUD_TOOLTIP_I18N_KEYS); + // Each tooltip string should have a corresponding i18n key + for (const key of stringKeys) { + expect(i18nKeys).toContain(key); + } + }); + + it('has a key for every ARIA label', () => { + const ariaKeys = Object.keys(HUD_ARIA_I18N_KEYS); + expect(ariaKeys).toContain('coins'); + expect(ariaKeys).toContain('rep'); + expect(ariaKeys).toContain('score'); + }); +}); + +// ── Unit tests: findNextLockedTier ──────────────────────────── + +describe('findNextLockedTier', () => { + it('returns tier-2 when only tier-1 is unlocked', () => { + const result = findNextLockedTier(['tier-1']); + expect(result).toBeDefined(); + expect(result!.id).toBe('tier-2'); + expect(result!.name).toBe('Rising Street'); + expect(result!.reputationThreshold).toBe(8); + }); + + it('returns tier-3 when tier-1 and tier-2 are unlocked', () => { + const result = findNextLockedTier(['tier-1', 'tier-2']); + expect(result).toBeDefined(); + expect(result!.id).toBe('tier-3'); + expect(result!.reputationThreshold).toBe(16); + }); + + it('returns tier-5 when tier-1 through tier-4 are unlocked', () => { + const result = findNextLockedTier(['tier-1', 'tier-2', 'tier-3', 'tier-4']); + expect(result).toBeDefined(); + expect(result!.id).toBe('tier-5'); + expect(result!.reputationThreshold).toBe(64); + }); + + it('returns undefined when all tiers are unlocked', () => { + const allTiers = ORDERED_TIER_DEFINITIONS.map(t => t.id); + const result = findNextLockedTier(allTiers); + expect(result).toBeUndefined(); + }); + + it('handles empty unlockedTiers gracefully (returns tier-1)', () => { + const result = findNextLockedTier([]); + expect(result).toBeDefined(); + expect(result!.id).toBe('tier-1'); + }); +}); + +// ── Unit tests: buildCoinsTooltip ─────────────────────────── + +describe('buildCoinsTooltip', () => { + it('shows base income and multiplied income for the default state', () => { + const state = setupMainStreetGame({ seed: 'test-coins' }); + const tooltip = buildCoinsTooltip(state); + + // Should contain title + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.coinsTitle); + // Should contain pre-multiplier label + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.coinsPreMultiplierLabel); + // Should contain post-multiplier label + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.coinsPostMultiplierLabel); + // Should contain the calculation note + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.coinsCalcNote); + }); + + it('shows base income of 0 for a fresh game with no businesses placed', () => { + const state = setupMainStreetGame({ seed: 'test-zero' }); + // Fresh game: no businesses on the street, so base income is 0 + const incomeResult = computeIncome(state.streetGrid, state.config.synergyBonusPerNeighbor); + expect(incomeResult.total).toBe(0); + + const tooltip = buildCoinsTooltip(state); + // Pre-multiplier income should be 0 + expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.coinsPreMultiplierLabel}: 0`); + // Post-multiplier should also be 0 (0 * any multiplier = 0) + expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.coinsPostMultiplierLabel}: 0`); + }); + + it('includes the multiplier value in the post-multiplier line', () => { + const state = setupMainStreetGame({ seed: 'test-mult' }); + const tooltip = buildCoinsTooltip(state); + // Should contain "×1." pattern (multiplier is at least 1.0) + expect(tooltip).toMatch(/×\d+\.\d/); + }); + + it('reflects multiplier changes with different reputation values', () => { + const state = setupMainStreetGame({ seed: 'test-rep' }); + state.resourceBank.reputation = 20; // should give 2.0x multiplier + + const mult = reputationCoinMultiplier(20, state.config); + expect(mult).toBeCloseTo(2.0); + + const tooltip = buildCoinsTooltip(state); + expect(tooltip).toContain('×2.0'); + }); +}); + +// ── Unit tests: buildReputationTooltip ─────────────────────── + +describe('buildReputationTooltip', () => { + it('shows current reputation and multiplier for default state', () => { + const state = setupMainStreetGame({ seed: 'test-rep-tooltip' }); + const tooltip = buildReputationTooltip(state); + + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.repTitle); + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.repMultiplierLabel); + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.repEffectLabel); + + // Should contain the current reputation value + const rep = state.resourceBank.reputation; + expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.repValueLabel}: ${rep}`); + }); + + it('shows 1.0 multiplier when reputation is 0', () => { + const state = setupMainStreetGame({ seed: 'test-rep-zero' }); + state.resourceBank.reputation = 0; + + const tooltip = buildReputationTooltip(state); + expect(tooltip).toContain('×1.0'); + }); + + it('shows capped multiplier for high reputation', () => { + const state = setupMainStreetGame({ seed: 'test-rep-high' }); + state.resourceBank.reputation = 100; // capped at maxReputationCoinMultiplier + + const tooltip = buildReputationTooltip(state); + // With default config, max multiplier is 3.0 + expect(tooltip).toContain('×3.0'); + }); +}); + +// ── Unit tests: buildScoreTooltip ─────────────────────────── + +describe('buildScoreTooltip', () => { + it('shows current score and next tier for a new campaign', () => { + const state = setupMainStreetGame({ seed: 'test-score' }); + const campaign: MainStreetCampaignProgress = { + schemaVersion: 1, + unlockedTiers: ['tier-1'], + unlockedCardIds: [], + milestoneHistory: [], + persistentReputation: 0, + highestScore: 0, + totalRuns: 0, + totalWins: 0, + lastUpdatedAt: new Date().toISOString(), + }; + + const tooltip = buildScoreTooltip(state, campaign); + + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.scoreTitle); + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.scoreEstimateLabel); + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.scoreNextTierLabel); + + // Should mention tier-2 (next locked after tier-1) + expect(tooltip).toContain('Rising Street'); + expect(tooltip).toContain('Rep ≥ 8'); + }); + + it('shows "All tiers unlocked" when all tiers are unlocked', () => { + const state = setupMainStreetGame({ seed: 'test-score-all' }); + const allTiers = ORDERED_TIER_DEFINITIONS.map(t => t.id); + const campaign: MainStreetCampaignProgress = { + schemaVersion: 1, + unlockedTiers: allTiers, + unlockedCardIds: [], + milestoneHistory: [], + persistentReputation: 0, + highestScore: 0, + totalRuns: 0, + totalWins: 0, + lastUpdatedAt: new Date().toISOString(), + }; + + const tooltip = buildScoreTooltip(state, campaign); + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.scoreAllTiersUnlocked); + }); + + it('handles null campaign gracefully (defaults to tier-1 only)', () => { + const state = setupMainStreetGame({ seed: 'test-score-null' }); + const tooltip = buildScoreTooltip(state, null); + + expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.scoreTitle); + // With null campaign, should default to tier-1 unlocked, next tier is tier-2 + expect(tooltip).toContain('Rising Street'); + }); + + it('includes numeric score estimate', () => { + const state = setupMainStreetGame({ seed: 'test-score-num' }); + state.resourceBank.coins = 50; + state.resourceBank.reputation = 10; + + const expectedScore = computeScore(state); + const tooltip = buildScoreTooltip(state, null); + + expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.scoreEstimateLabel}: ${expectedScore}`); + }); +}); + +// ── Integration-style tests: tooltip zone attachment ───────── +// +// These tests verify that MainStreetRenderer.attachHudTooltipZone is called +// correctly through the text-based tooltip content. Since MainStreetRenderer +// imports Phaser (which is not available in node-only tests), the integration +// tests live in the browser test file where a full Phaser game can be booted. +// +// See: tests/main-street/MainStreetScene.browser.test.ts \ No newline at end of file From a0ab325f2599119724abfbc4ff9f16c2a1938f87 Mon Sep 17 00:00:00 2001 From: Map Date: Mon, 18 May 2026 01:19:41 +0100 Subject: [PATCH 2/2] CG-0MP2A0X0K007JRU4: Wire HUD tooltip strings through i18n lookup system instead of hardcoded English defaults - Add src/core-engine/I18n.ts: minimal i18n module providing t(), registerLocale(), setLocale(), getLocale(), and resetI18n() for locale-aware string lookup - Register HUD_TOOLTIP_STRINGS and HUD_ARIA_STRINGS as the 'en' locale bundle at module load time in MainStreetHudTooltips.ts - Refactor all tooltip builder functions (buildCoinsTooltip, buildReputationTooltip, buildScoreTooltip) to use t(HUD_TOOLTIP_I18N_KEYS.xxx) instead of direct HUD_TOOLTIP_STRINGS.xxx lookups - Refactor HUD_ARIA_LABELS to use getter properties that resolve through t(), making ARIA labels locale-aware - Export I18n module from core-engine/index.ts barrel file - Add comprehensive I18n tests (14 unit + integration tests) - Add 4 new tests to hud-tooltips.test.ts verifying i18n resolution for tooltip strings, ARIA labels, and locale overrides This addresses the audit gap: HUD_TOOLTIP_I18N_KEYS were defined but not consumed by any lookup mechanism. The builders now use the i18n t() function which resolves the active locale bundle with en fallback, satisfying the constraint to not ship hardcoded English-only strings. --- .../scenes/MainStreetHudTooltips.ts | 75 +++++--- src/core-engine/I18n.ts | 98 ++++++++++ src/core-engine/index.ts | 10 + tests/core-engine/I18n.test.ts | 175 ++++++++++++++++++ tests/main-street/hud-tooltips.test.ts | 51 +++++ 5 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 src/core-engine/I18n.ts create mode 100644 tests/core-engine/I18n.test.ts diff --git a/example-games/main-street/scenes/MainStreetHudTooltips.ts b/example-games/main-street/scenes/MainStreetHudTooltips.ts index 437d193..bac9322 100644 --- a/example-games/main-street/scenes/MainStreetHudTooltips.ts +++ b/example-games/main-street/scenes/MainStreetHudTooltips.ts @@ -1,17 +1,18 @@ /** * MainStreetHudTooltips -- Tooltip content builders for the HUD status bar. * - * Provides localizable string keys and builder functions for the Coins, + * Provides localisable string keys and builder functions for the Coins, * Reputation, and Score tooltips shown when hovering/tapping HUD values. * - * ## i18n key convention - * Each tooltip string is keyed under `hud.tooltip.` so that a future - * i18n system can swap implementations. For now the strings are built with - * template functions that embed computed numeric values. + * ## i18n integration + * Every user-facing string is looked up via the core-engine `t()` function + * which resolves the active locale bundle and falls back to English defaults. + * The English defaults are registered at module load time so they are always + * available, even without an explicit locale setup. * * ## ARIA labels * Each interactive zone carries a static `aria-label` for screen readers. - * These are also localizable via the same key convention. + * These are also localisable through the same i18n system. * * @module */ @@ -21,6 +22,7 @@ import { reputationCoinMultiplier, applyReputationMultiplier } from '../MainStre import { ORDERED_TIER_DEFINITIONS } from '../MainStreetTiers'; import { computeScore } from '../MainStreetEngine'; import type { MainStreetState, MainStreetCampaignProgress } from '../MainStreetState'; +import { t, registerLocale } from '../../../src/core-engine/I18n'; // ── i18n Keys ─────────────────────────────────────────────── @@ -49,18 +51,18 @@ export const HUD_ARIA_I18N_KEYS = { score: 'hud.aria.score', } as const; -// ── ARIA label defaults (localize here) ───────────────────── +// ── ARIA label lookup via i18n ────────────────────────────── -/** Default ARIA labels for each HUD interactive zone. */ +/** ARIA labels for HUD interactive zones — resolved through the i18n system. */ export const HUD_ARIA_LABELS = { - coins: 'Coins status — hover for expected income breakdown', - rep: 'Reputation status — hover for multiplier details', - score: 'Score status — hover for next tier threshold', -} as const; + get coins() { return t(HUD_ARIA_I18N_KEYS.coins); }, + get rep() { return t(HUD_ARIA_I18N_KEYS.rep); }, + get score() { return t(HUD_ARIA_I18N_KEYS.score); }, +}; -// ── Default string templates (localize here) ────────────────── +// ── Default English strings (registered as the 'en' locale bundle) ──── -/** Default English string templates. Swappable via i18n key map. */ +/** Default English string templates. Registered as the `en` locale bundle. */ export const HUD_TOOLTIP_STRINGS = { coinsTitle: 'Income This Turn', coinsIncomeLabel: 'Base income', @@ -77,6 +79,27 @@ export const HUD_TOOLTIP_STRINGS = { scoreAllTiersUnlocked: 'All tiers unlocked', } as const; +/** ARIA label default English strings. Registered as the `en` locale bundle. */ +export const HUD_ARIA_STRINGS = { + coins: 'Coins status — hover for expected income breakdown', + rep: 'Reputation status — hover for multiplier details', + score: 'Score status — hover for next tier threshold', +} as const; + +// ── Register the English locale bundle ──────────────────────────────── +// This is done at module-load time so the default strings are always available. +// The i18n keys (HUD_TOOLTIP_I18N_KEYS / HUD_ARIA_I18N_KEYS) are used as the +// lookup keys; HUD_TOOLTIP_STRINGS and HUD_ARIA_STRINGS supply the values. + +const enBundle: Record = {}; +for (const [k, v] of Object.entries(HUD_TOOLTIP_STRINGS)) { + enBundle[HUD_TOOLTIP_I18N_KEYS[k as keyof typeof HUD_TOOLTIP_STRINGS]] = v; +} +for (const [k, v] of Object.entries(HUD_ARIA_STRINGS)) { + enBundle[HUD_ARIA_I18N_KEYS[k as keyof typeof HUD_ARIA_STRINGS]] = v; +} +registerLocale('en', enBundle); + // ── Tooltip Content Builders ───────────────────────────────── /** @@ -99,10 +122,10 @@ export function buildCoinsTooltip(state: MainStreetState): string { const multiplierStr = Number.isFinite(multiplier) ? multiplier.toFixed(1) : '1.0'; const lines = [ - HUD_TOOLTIP_STRINGS.coinsTitle, - `${HUD_TOOLTIP_STRINGS.coinsPreMultiplierLabel}: ${baseIncome}`, - `${HUD_TOOLTIP_STRINGS.coinsPostMultiplierLabel}: ${multipliedIncome} (×${multiplierStr})`, - HUD_TOOLTIP_STRINGS.coinsCalcNote, + t(HUD_TOOLTIP_I18N_KEYS.coinsTitle), + `${t(HUD_TOOLTIP_I18N_KEYS.coinsPreMultiplierLabel)}: ${baseIncome}`, + `${t(HUD_TOOLTIP_I18N_KEYS.coinsPostMultiplierLabel)}: ${multipliedIncome} (×${multiplierStr})`, + t(HUD_TOOLTIP_I18N_KEYS.coinsCalcNote), ]; return lines.join('\n'); @@ -129,10 +152,10 @@ export function buildReputationTooltip(state: MainStreetState): string { const multiplierStr = Number.isFinite(multiplier) ? multiplier.toFixed(1) : '1.0'; const lines = [ - HUD_TOOLTIP_STRINGS.repTitle, - `${HUD_TOOLTIP_STRINGS.repValueLabel}: ${rep}`, - `${HUD_TOOLTIP_STRINGS.repMultiplierLabel}: ×${multiplierStr}`, - HUD_TOOLTIP_STRINGS.repEffectLabel, + t(HUD_TOOLTIP_I18N_KEYS.repTitle), + `${t(HUD_TOOLTIP_I18N_KEYS.repValueLabel)}: ${rep}`, + `${t(HUD_TOOLTIP_I18N_KEYS.repMultiplierLabel)}: ×${multiplierStr}`, + t(HUD_TOOLTIP_I18N_KEYS.repEffectLabel), ]; return lines.join('\n'); @@ -156,16 +179,16 @@ export function buildScoreTooltip( const nextTier = findNextLockedTier(unlockedTiers); const lines = [ - HUD_TOOLTIP_STRINGS.scoreTitle, - `${HUD_TOOLTIP_STRINGS.scoreEstimateLabel}: ${score}`, + t(HUD_TOOLTIP_I18N_KEYS.scoreTitle), + `${t(HUD_TOOLTIP_I18N_KEYS.scoreEstimateLabel)}: ${score}`, ]; if (nextTier) { lines.push( - `${HUD_TOOLTIP_STRINGS.scoreNextTierLabel}: ${nextTier.name} (requires Rep ≥ ${nextTier.reputationThreshold})`, + `${t(HUD_TOOLTIP_I18N_KEYS.scoreNextTierLabel)}: ${nextTier.name} (requires Rep ≥ ${nextTier.reputationThreshold})`, ); } else { - lines.push(HUD_TOOLTIP_STRINGS.scoreAllTiersUnlocked); + lines.push(t(HUD_TOOLTIP_I18N_KEYS.scoreAllTiersUnlocked)); } return lines.join('\n'); diff --git a/src/core-engine/I18n.ts b/src/core-engine/I18n.ts new file mode 100644 index 0000000..b1e566f --- /dev/null +++ b/src/core-engine/I18n.ts @@ -0,0 +1,98 @@ +/** + * Minimal internationalisation (i18n) module. + * + * Provides a lightweight key→string lookup system so that UI-facing strings + * are not hardcoded in English throughout the codebase. Locale bundles can + * be registered at startup; every call to `t()` returns the string for the + * current locale, falling back to the `en` default when a key is missing. + * + * ## Usage + * + * ```ts + * import { t, registerLocale, setLocale } from '@core-engine/I18n'; + * + * // Register the English (default) bundle + * registerLocale('en', { 'hud.tooltip.coins.title': 'Income This Turn' }); + * + * // Register a French bundle (partial override is fine — missing keys fall back to en) + * registerLocale('fr', { 'hud.tooltip.coins.title': 'Revenus ce tour' }); + * + * setLocale('fr'); + * t('hud.tooltip.coins.title'); // → "Revenus ce tour" + * t('unknown.key'); // → "unknown.key" (missing-key fallback) + * ``` + * + * @module + */ + +/** A locale bundle maps i18n keys to translated strings. */ +export type I18nBundle = Record; + +// ── Internal state ────────────────────────────────────────── + +const bundles: Map = new Map(); +let currentLocale = 'en'; + +// ── Public API ────────────────────────────────────────────── + +/** + * Register (or merge) a locale bundle. + * + * If the locale already exists, new keys are merged and existing keys are + * overwritten by the incoming bundle. The English (`en`) bundle acts as + * the authoritative fallback — it **must** contain every key that will ever + * be looked up. + */ +export function registerLocale(locale: string, bundle: I18nBundle): void { + const existing = bundles.get(locale) ?? {}; + bundles.set(locale, { ...existing, ...bundle }); +} + +/** + * Switch the active locale. + * + * @throws Error if the locale has not been registered yet. + */ +export function setLocale(locale: string): void { + if (!bundles.has(locale)) { + throw new Error(`I18n: locale "${locale}" has not been registered. Call registerLocale() first.`); + } + currentLocale = locale; +} + +/** + * Get the currently active locale identifier. + */ +export function getLocale(): string { + return currentLocale; +} + +/** + * Look up a localised string by key. + * + * Resolution order: + * 1. Current locale bundle. + * 2. English (`en`) fallback bundle. + * 3. The key itself (so missing keys are still meaningful in the UI). + */ +export function t(key: string): string { + const current = bundles.get(currentLocale); + if (current && key in current) return current[key]; + + // Fallback to English + const en = bundles.get('en'); + if (en && key in en) return en[key]; + + // Last resort: return the key itself + return key; +} + +/** + * Reset all registered locales and the current locale to defaults. + * + * Useful for test teardown so that state doesn't leak between tests. + */ +export function resetI18n(): void { + bundles.clear(); + currentLocale = 'en'; +} \ No newline at end of file diff --git a/src/core-engine/index.ts b/src/core-engine/index.ts index f2353c2..df2027b 100644 --- a/src/core-engine/index.ts +++ b/src/core-engine/index.ts @@ -132,6 +132,16 @@ export { getPresetNames, } from './DifficultyPresets'; +// i18n / localisation helpers +export { + t, + registerLocale, + setLocale, + getLocale, + resetI18n, +} from './I18n'; +export type { I18nBundle } from './I18n'; + // Spatial rules API (CG-0MM5ZG7071KO7PVG) export type { Position, diff --git a/tests/core-engine/I18n.test.ts b/tests/core-engine/I18n.test.ts new file mode 100644 index 0000000..5a3580b --- /dev/null +++ b/tests/core-engine/I18n.test.ts @@ -0,0 +1,175 @@ +/** + * Core Engine: I18n module tests + * + * Verifies the minimal internationalisation lookup system used by + * HUD tooltips and ARIA labels. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + t, + registerLocale, + setLocale, + getLocale, + resetI18n, +} from '../../src/core-engine/I18n'; + +// ── Pure unit tests (reset I18n between each test) ────────────── + +describe('I18n (pure)', () => { + beforeEach(() => { + resetI18n(); + }); + + describe('registerLocale + t()', () => { + it('returns the English string when en bundle is registered', () => { + registerLocale('en', { 'greeting': 'Hello' }); + expect(t('greeting')).toBe('Hello'); + }); + + it('falls back to the key itself when key is missing in all bundles', () => { + registerLocale('en', {}); + expect(t('missing.key')).toBe('missing.key'); + }); + + it('falls back to English when the current locale is missing a key', () => { + registerLocale('en', { 'greeting': 'Hello', 'farewell': 'Goodbye' }); + registerLocale('fr', { 'greeting': 'Bonjour' }); + setLocale('fr'); + + // Key present in fr + expect(t('greeting')).toBe('Bonjour'); + // Key missing in fr — falls back to en + expect(t('farewell')).toBe('Goodbye'); + }); + + it('falls back to the key when neither current locale nor en has it', () => { + registerLocale('en', {}); + registerLocale('fr', {}); + setLocale('fr'); + + expect(t('unknown')).toBe('unknown'); + }); + }); + + describe('setLocale / getLocale', () => { + it('defaults to "en" locale', () => { + registerLocale('en', {}); + expect(getLocale()).toBe('en'); + }); + + it('switches to a registered locale', () => { + registerLocale('en', {}); + registerLocale('de', {}); + setLocale('de'); + expect(getLocale()).toBe('de'); + }); + + it('throws when setting an unregistered locale', () => { + expect(() => setLocale('ja')).toThrow(/not been registered/); + }); + }); + + describe('registerLocale merge', () => { + it('merges new keys into an existing bundle', () => { + registerLocale('en', { 'a': 'A', 'b': 'B' }); + registerLocale('en', { 'b': 'B-updated', 'c': 'C' }); + + expect(t('a')).toBe('A'); // original key preserved + expect(t('b')).toBe('B-updated'); // overwritten + expect(t('c')).toBe('C'); // new key added + }); + }); + + describe('resetI18n', () => { + it('clears all bundles and resets locale to en', () => { + registerLocale('en', { 'hello': 'Hello' }); + registerLocale('fr', { 'hello': 'Bonjour' }); + setLocale('fr'); + expect(t('hello')).toBe('Bonjour'); + + resetI18n(); + // After reset, en bundle is empty → key fallback + expect(getLocale()).toBe('en'); + expect(t('hello')).toBe('hello'); + }); + }); +}); + +// ── Integration tests with HUD tooltip module ──────────────────── +// These tests verify that the MainStreetHudTooltips module correctly +// registers its English locale bundle and that the i18n lookup works. + +describe('I18n (HUD tooltip integration)', () => { + // Ensure a clean state but re-register after reset + let HUD_TOOLTIP_I18N_KEYS: Record; + let HUD_ARIA_I18N_KEYS: Record; + let HUD_TOOLTIP_STRINGS: Record; + let HUD_ARIA_STRINGS: Record; + + beforeEach(async () => { + resetI18n(); + // Dynamic import triggers registerLocale('en', enBundle) side-effect + const mod = await import('../../example-games/main-street/scenes/MainStreetHudTooltips'); + HUD_TOOLTIP_I18N_KEYS = mod.HUD_TOOLTIP_I18N_KEYS as Record; + HUD_ARIA_I18N_KEYS = mod.HUD_ARIA_I18N_KEYS as Record; + HUD_TOOLTIP_STRINGS = mod.HUD_TOOLTIP_STRINGS as Record; + HUD_ARIA_STRINGS = mod.HUD_ARIA_STRINGS as Record; + + // After resetI18n, the module-level side-effect from the initial import + // may have been wiped. Re-register manually by reconstructing the bundle + // from the exported constants (these are deterministic). + const enBundle: Record = {}; + for (const [k, v] of Object.entries(HUD_TOOLTIP_STRINGS)) { + enBundle[HUD_TOOLTIP_I18N_KEYS[k]] = v; + } + for (const [k, v] of Object.entries(HUD_ARIA_STRINGS)) { + enBundle[HUD_ARIA_I18N_KEYS[k]] = v; + } + registerLocale('en', enBundle); + }); + + it('provides English defaults for all tooltip keys', () => { + for (const [key, i18nKey] of Object.entries(HUD_TOOLTIP_I18N_KEYS)) { + const expected = HUD_TOOLTIP_STRINGS[key]; + expect(t(i18nKey)).toBe(expected); + } + }); + + it('provides English defaults for all ARIA label keys', () => { + for (const [key, i18nKey] of Object.entries(HUD_ARIA_I18N_KEYS)) { + const expected = HUD_ARIA_STRINGS[key]; + expect(t(i18nKey)).toBe(expected); + } + }); + + it('allows overriding tooltip strings via a new locale', () => { + registerLocale('de', { + [HUD_TOOLTIP_I18N_KEYS['coinsTitle']]: 'Einkommen Diese Runde', + }); + setLocale('de'); + + expect(t(HUD_TOOLTIP_I18N_KEYS['coinsTitle'])).toBe('Einkommen Diese Runde'); + // Non-overridden keys fall back to en + expect(t(HUD_TOOLTIP_I18N_KEYS['coinsCalcNote'])).toBe('Sum of business incomes + synergy bonuses'); + }); + + it('HUD_ARIA_LABELS resolves through i18n', async () => { + const { HUD_ARIA_LABELS } = await import('../../example-games/main-street/scenes/MainStreetHudTooltips'); + expect(HUD_ARIA_LABELS.coins).toBe('Coins status — hover for expected income breakdown'); + expect(HUD_ARIA_LABELS.rep).toBe('Reputation status — hover for multiplier details'); + expect(HUD_ARIA_LABELS.score).toBe('Score status — hover for next tier threshold'); + }); + + it('HUD_ARIA_LABELS reflects locale changes', async () => { + registerLocale('de', { + [HUD_ARIA_I18N_KEYS['coins']]: 'Münzen — für Einkommensaufschlüsselung bewegen', + }); + setLocale('de'); + + // HUD_ARIA_LABELS uses getters, so they resolve dynamically via t() + const { HUD_ARIA_LABELS } = await import('../../example-games/main-street/scenes/MainStreetHudTooltips'); + expect(HUD_ARIA_LABELS.coins).toBe('Münzen — für Einkommensaufschlüsselung bewegen'); + // Non-overridden keys fall back to en + expect(HUD_ARIA_LABELS.rep).toBe('Reputation status — hover for multiplier details'); + }); +}); \ No newline at end of file diff --git a/tests/main-street/hud-tooltips.test.ts b/tests/main-street/hud-tooltips.test.ts index fd9bb80..b605d09 100644 --- a/tests/main-street/hud-tooltips.test.ts +++ b/tests/main-street/hud-tooltips.test.ts @@ -17,7 +17,10 @@ import { HUD_TOOLTIP_I18N_KEYS, HUD_ARIA_I18N_KEYS, HUD_TOOLTIP_STRINGS, + HUD_ARIA_STRINGS, + HUD_ARIA_LABELS, } from '../../example-games/main-street/scenes/MainStreetHudTooltips'; +import { t, setLocale, registerLocale, resetI18n } from '../../src/core-engine/I18n'; import { setupMainStreetGame, @@ -50,6 +53,54 @@ describe('HUD tooltip i18n keys', () => { expect(ariaKeys).toContain('rep'); expect(ariaKeys).toContain('score'); }); + + it('t() resolves every tooltip i18n key to its English default', () => { + for (const [key, i18nKey] of Object.entries(HUD_TOOLTIP_I18N_KEYS)) { + const expected = HUD_TOOLTIP_STRINGS[key as keyof typeof HUD_TOOLTIP_STRINGS]; + expect(t(i18nKey as string)).toBe(expected); + } + }); + + it('t() resolves every ARIA i18n key to its English default', () => { + for (const [key, i18nKey] of Object.entries(HUD_ARIA_I18N_KEYS)) { + const expected = HUD_ARIA_STRINGS[key as keyof typeof HUD_ARIA_STRINGS]; + expect(t(i18nKey as string)).toBe(expected); + } + }); + + it('HUD_ARIA_LABELS resolves through i18n', () => { + expect(HUD_ARIA_LABELS.coins).toBe(HUD_ARIA_STRINGS.coins); + expect(HUD_ARIA_LABELS.rep).toBe(HUD_ARIA_STRINGS.rep); + expect(HUD_ARIA_LABELS.score).toBe(HUD_ARIA_STRINGS.score); + }); + + it('tooltip builders use i18n — overriding locale changes content', () => { + const state = setupMainStreetGame({ seed: 'test-i18n-override' }); + + // Register a German locale that overrides a few keys + registerLocale('de', { + [HUD_TOOLTIP_I18N_KEYS.coinsTitle]: 'Einkommen Diese Runde', + }); + setLocale('de'); + + const tooltip = buildCoinsTooltip(state); + expect(tooltip).toContain('Einkommen Diese Runde'); + // Non-overridden keys should fall back to English + expect(tooltip).toContain('Before reputation'); + + // Reset to English for other tests + resetI18n(); + // Re-register en bundle since resetI18n cleared everything + const enBundle: Record = {}; + for (const [k, v] of Object.entries(HUD_TOOLTIP_STRINGS)) { + enBundle[HUD_TOOLTIP_I18N_KEYS[k as keyof typeof HUD_TOOLTIP_I18N_KEYS]] = v; + } + for (const [k, v] of Object.entries(HUD_ARIA_STRINGS)) { + enBundle[HUD_ARIA_I18N_KEYS[k as keyof typeof HUD_ARIA_I18N_KEYS]] = v; + } + registerLocale('en', enBundle); + setLocale('en'); + }); }); // ── Unit tests: findNextLockedTier ────────────────────────────