diff --git a/example-games/main-street/scenes/MainStreetHudTooltips.ts b/example-games/main-street/scenes/MainStreetHudTooltips.ts new file mode 100644 index 0000000..bac9322 --- /dev/null +++ b/example-games/main-street/scenes/MainStreetHudTooltips.ts @@ -0,0 +1,217 @@ +/** + * MainStreetHudTooltips -- Tooltip content builders for the HUD status bar. + * + * Provides localisable string keys and builder functions for the Coins, + * Reputation, and Score tooltips shown when hovering/tapping HUD 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 localisable through the same i18n system. + * + * @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'; +import { t, registerLocale } from '../../../src/core-engine/I18n'; + +// ── 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 lookup via i18n ────────────────────────────── + +/** ARIA labels for HUD interactive zones — resolved through the i18n system. */ +export const HUD_ARIA_LABELS = { + 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 English strings (registered as the 'en' locale bundle) ──── + +/** Default English string templates. Registered as the `en` locale bundle. */ +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; + +/** 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 ───────────────────────────────── + +/** + * 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 = [ + 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'); +} + +/** + * 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 = [ + 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'); +} + +/** + * 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 = [ + t(HUD_TOOLTIP_I18N_KEYS.scoreTitle), + `${t(HUD_TOOLTIP_I18N_KEYS.scoreEstimateLabel)}: ${score}`, + ]; + + if (nextTier) { + lines.push( + `${t(HUD_TOOLTIP_I18N_KEYS.scoreNextTierLabel)}: ${nextTier.name} (requires Rep ≥ ${nextTier.reputationThreshold})`, + ); + } else { + lines.push(t(HUD_TOOLTIP_I18N_KEYS.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/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/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..b605d09 --- /dev/null +++ b/tests/main-street/hud-tooltips.test.ts @@ -0,0 +1,302 @@ +/** + * 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, + 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, + 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'); + }); + + 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 ──────────────────────────── + +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