From 2c8538183b4d40e72a8d68ad0cf1c480b85e0bb1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:10:01 +0000 Subject: [PATCH 1/3] Initial plan From 8ea0c127d98e25b4b23dbbe295accabbfbbc020e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:21:46 +0000 Subject: [PATCH 2/3] feat: Add Main Street Hint System (M3 US-3) Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- example-games/main-street/MainStreetHint.ts | 142 +++++++++ .../main-street/scenes/MainStreetScene.ts | 147 ++++++++- tests/main-street/hint.test.ts | 288 ++++++++++++++++++ 3 files changed, 567 insertions(+), 10 deletions(-) create mode 100644 example-games/main-street/MainStreetHint.ts create mode 100644 tests/main-street/hint.test.ts diff --git a/example-games/main-street/MainStreetHint.ts b/example-games/main-street/MainStreetHint.ts new file mode 100644 index 0000000..dc5582b --- /dev/null +++ b/example-games/main-street/MainStreetHint.ts @@ -0,0 +1,142 @@ +/** + * Main Street: Hint System + * + * Provides the HintGenerator: queries the Greedy AI strategy to produce a + * one-line recommended action with a human-readable rationale. + * + * Usage: + * - Call `generateHint(state)` during MarketPhase to get a HintResult. + * - Returns null if called outside MarketPhase. + * - Per-turn limit (hintUsedThisTurn) is enforced by the caller (scene). + * + * @module + */ + +import { GreedyStrategy, scoreAction } from './MainStreetAiStrategy'; +import type { MainStreetState } from './MainStreetState'; +import type { PlayerAction, BuyBusinessAction, BuyUpgradeAction, BuyEventAction } from './MainStreetEngine'; +import { computeSynergyBonus } from './MainStreetAdjacency'; +import type { BusinessCard, UpgradeCard, EventCard } from './MainStreetCards'; + +// ── Types ──────────────────────────────────────────────────── + +/** + * The result of a hint request: the recommended action, a human-readable + * one-line rationale, and the heuristic score used to rank it. + */ +export interface HintResult { + /** The recommended PlayerAction (not auto-executed; player must act manually). */ + action: PlayerAction; + /** One-line human-readable rationale for the recommendation. */ + rationale: string; + /** Heuristic score (for debugging; not shown to the player). */ + score: number; +} + +// ── generateHint ───────────────────────────────────────────── + +/** + * Generates a hint by querying the Greedy strategy for its recommended action. + * + * Creates a temporary GreedyStrategy evaluation: calls `GreedyStrategy.chooseAction` + * to get the recommended action (following the same priority chain used during + * AI auto-play), then scores that action for rationale context. + * + * Returns null if the game is not in MarketPhase (hints are only valid + * during the player's market/action turn). + * + * @param state Current game state (read-only by convention). + * @returns HintResult with the recommended action and rationale, or null. + */ +export function generateHint(state: Readonly): HintResult | null { + if (state.phase !== 'MarketPhase') return null; + + // Delegate to GreedyStrategy so the hint is always consistent with AI play. + const action = GreedyStrategy.chooseAction(state as MainStreetState, state.rng); + const score = scoreAction(state as MainStreetState, action); + const rationale = buildRationale(action, score, state); + + return { action, rationale, score }; +} + +// ── buildRationale ──────────────────────────────────────────── + +/** + * Builds a one-line human-readable rationale for a recommended action. + * + * Templates follow the PRD Appendix A spec: + * - buy-business: "Buy {cardName} at slot {slot} for +{synergyBonus} synergy bonus" + * - buy-upgrade: "Upgrade {businessName} for +{incomeBonus}/turn income" + * - buy-event: "Buy {eventName} for {coinDelta} coins and {repDelta} reputation" + * - play-event: "Play {eventName} now for immediate benefit" + * - end-turn: "No good buys available -- end your turn" + * + * @param action The recommended action. + * @param score The heuristic score (used to detect zero-value end-turn). + * @param state Current game state (for card lookups). + * @returns One-line rationale string. + */ +export function buildRationale( + action: PlayerAction, + _score: number, + state: Readonly, +): string { + switch (action.type) { + case 'buy-business': { + const a = action as BuyBusinessAction; + const card = state.market.business.find(c => c.id === a.cardId) as BusinessCard | undefined; + const cardName = card?.name ?? a.cardId; + + // Compute projected synergy bonus at the candidate slot + const simulatedGrid = [...state.streetGrid]; + if (card) simulatedGrid[a.slotIndex] = card; + const synergyBonus = computeSynergyBonus( + simulatedGrid, + a.slotIndex, + state.config.synergyBonusPerNeighbor, + ); + + if (synergyBonus > 0) { + return `Buy ${cardName} at slot ${a.slotIndex} for +${synergyBonus} synergy bonus`; + } + return `Buy ${cardName} at slot ${a.slotIndex}`; + } + + case 'buy-upgrade': { + const a = action as BuyUpgradeAction; + const card = state.market.investments.find(c => c.id === a.cardId) as UpgradeCard | undefined; + const targetBusiness = card?.targetBusiness ?? 'business'; + const incomeBonus = card?.incomeBonus ?? 0; + + if (a.targetSlot !== undefined) { + const biz = state.streetGrid[a.targetSlot] as BusinessCard | null; + const bizName = biz?.name ?? targetBusiness; + return `Upgrade ${bizName} at slot ${a.targetSlot} for +${incomeBonus}/turn income`; + } + return `Upgrade ${targetBusiness} for +${incomeBonus}/turn income`; + } + + case 'buy-event': { + const a = action as BuyEventAction; + const card = state.market.investments.find(c => c.id === a.cardId) as EventCard | undefined; + const cardName = card?.name ?? a.cardId; + const coinDelta = card?.coinDelta ?? 0; + const repDelta = card?.reputationDelta ?? 0; + + const parts: string[] = []; + if (coinDelta !== 0) parts.push(`${coinDelta > 0 ? '+' : ''}${coinDelta} coins`); + if (repDelta !== 0) parts.push(`${repDelta > 0 ? '+' : ''}${repDelta} reputation`); + const effects = parts.length > 0 ? parts.join(' and ') : 'positive effect'; + + return `Buy ${cardName} for ${effects}`; + } + + case 'play-event': { + const cardName = state.heldEvent?.name ?? 'held event'; + return `Play ${cardName} now for immediate benefit`; + } + + case 'end-turn': + return 'No good buys available -- end your turn'; + } +} diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index 4447e01..b50c79d 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -8,6 +8,7 @@ * - Turn / phase indicator * - Click-to-buy flow (select card -> select empty slot for businesses) * - End Turn button to advance through remaining phases + * - Hint button (1 use per turn) that highlights the Greedy AI's recommended move * - Game-over overlay with score and replay/menu buttons * - Help panel and settings integration */ @@ -64,6 +65,10 @@ import { ORDERED_TIER_DEFINITIONS, highestUnlockedTier, } from '../MainStreetTiers'; +import { + generateHint, + type HintResult, +} from '../MainStreetHint'; // ── Constants ─────────────────────────────────────────────── @@ -201,6 +206,14 @@ export class MainStreetScene extends CardGameScene { // Overlay objects private overlayObjects: Phaser.GameObjects.GameObject[] = []; + // Hint system + /** True after the player has used their one hint for this turn. */ + private hintUsedThisTurn = false; + /** Card ID of the card highlighted by the current hint (null = none). */ + private hintedCardId: string | null = null; + /** Grid slot index highlighted by the current hint (null = none). */ + private hintedSlotIndex: number | null = null; + constructor() { super({ key: 'MainStreetScene' }); } @@ -215,6 +228,11 @@ export class MainStreetScene extends CardGameScene { this.pendingBusinessCard = null; this.overlayObjects = []; + // Reset hint state + this.hintUsedThisTurn = false; + this.hintedCardId = null; + this.hintedSlotIndex = null; + // Reset activity-log panel state in case this scene instance is restarted. this.logScrollOffset = 0; this.logMaxScroll = 0; @@ -419,6 +437,12 @@ export class MainStreetScene extends CardGameScene { // Execute DayStart (refills market, transitions to MarketPhase) executeDayStart(this.state); this.uiPhase = 'market'; + + // Reset hint state for the new turn + this.hintUsedThisTurn = false; + this.hintedCardId = null; + this.hintedSlotIndex = null; + this.refreshAll(); this.instructionText.setText( `Turn ${this.state.turn} / ${this.state.config.maxTurns} -- Buy cards from the market or End Turn`, @@ -646,13 +670,15 @@ export class MainStreetScene extends CardGameScene { private drawBusinessSlot(x: number, y: number, _index: number, biz: BusinessCard): void { const primaryColor = synergyColor(biz.synergyTypes[0]); + const isHinted = this.hintedSlotIndex === _index; // Card background const bg = this.add.rectangle( x + SLOT_W / 2, y + SLOT_H / 2, SLOT_W, SLOT_H, primaryColor, 0.7, ); - bg.setStrokeStyle(2, 0xffffff, 0.4); + // Highlight the slot if it is the hint target (e.g., upgrade target) + bg.setStrokeStyle(isHinted ? 3 : 2, isHinted ? 0x44ffff : 0xffffff, isHinted ? 1.0 : 0.4); this.streetContainer.add(bg); // Name @@ -694,19 +720,21 @@ export class MainStreetScene extends CardGameScene { private drawEmptySlot(x: number, y: number, index: number): void { const isSelectable = this.uiPhase === 'placing-business'; - const fillAlpha = isSelectable ? 0.4 : 0.2; - const strokeColor = isSelectable ? 0xffdd44 : 0x555544; + const isHinted = this.hintedSlotIndex === index && !isSelectable; + const fillAlpha = isSelectable ? 0.4 : isHinted ? 0.35 : 0.2; + const strokeColor = isSelectable ? 0xffdd44 : isHinted ? 0x44ffff : 0x555544; + const strokeWidth = (isSelectable || isHinted) ? 2 : 1; const bg = this.add.rectangle( x + SLOT_W / 2, y + SLOT_H / 2, SLOT_W, SLOT_H, 0x333322, fillAlpha, ); - bg.setStrokeStyle(isSelectable ? 2 : 1, strokeColor); + bg.setStrokeStyle(strokeWidth, strokeColor); this.streetContainer.add(bg); // Slot number const idxText = this.add.text(x + SLOT_W / 2, y + SLOT_H / 2, `${index}`, { - fontSize: '18px', color: isSelectable ? '#ffdd44' : '#666655', + fontSize: '18px', color: (isSelectable || isHinted) ? '#ffdd44' : '#666655', fontFamily: FONT_FAMILY, }).setOrigin(0.5); this.streetContainer.add(idxText); @@ -829,6 +857,9 @@ export class MainStreetScene extends CardGameScene { // Determine if this is a non-purchasable Incident event const isIncidentEvent = card.family === 'event' && (card as EventCard).trigger === 'Incident'; + // Determine if this card is the hint recommendation + const isHinted = this.hintedCardId !== null && card.id === this.hintedCardId; + // Determine card color let fillColor = 0x333322; if (card.family === 'business') { @@ -842,7 +873,10 @@ export class MainStreetScene extends CardGameScene { // Background const fillAlpha = isIncidentEvent ? 0.5 : 0.7; const bg = this.add.rectangle(0, 0, MARKET_CARD_W, MARKET_CARD_H, fillColor, fillAlpha); - bg.setStrokeStyle(1, isIncidentEvent ? 0x556688 : 0x888877); + // Hinted cards get a bright cyan border; incident events use their normal border + const strokeColor = isHinted ? 0x44ffff : (isIncidentEvent ? 0x556688 : 0x888877); + const strokeWidth = isHinted ? 3 : 1; + bg.setStrokeStyle(strokeWidth, strokeColor); container.add(bg); // Card label (name + cost for business/upgrade) @@ -901,7 +935,8 @@ export class MainStreetScene extends CardGameScene { container.setScale(1.05); }); bg.on('pointerout', () => { - bg.setStrokeStyle(1, 0x888877); + // Restore hint border if this card is hinted; otherwise use normal border + bg.setStrokeStyle(isHinted ? 3 : 1, isHinted ? 0x44ffff : 0x888877); container.setScale(1.0); }); } @@ -1040,10 +1075,11 @@ export class MainStreetScene extends CardGameScene { card: EventCard, ): Phaser.GameObjects.Container { const container = this.add.container(x + HAND_CARD_W / 2, y + HAND_CARD_H / 2); + const isHinted = this.hintedCardId !== null && card.id === this.hintedCardId; // Warm brown background (Investment) const bg = this.add.rectangle(0, 0, HAND_CARD_W, HAND_CARD_H, 0x8B4513, 0.7); - bg.setStrokeStyle(2, 0xcc9944); + bg.setStrokeStyle(isHinted ? 3 : 2, isHinted ? 0x44ffff : 0xcc9944); container.add(bg); // Card name @@ -1085,7 +1121,7 @@ export class MainStreetScene extends CardGameScene { container.setScale(1.05); }); bg.on('pointerout', () => { - bg.setStrokeStyle(2, 0xcc9944); + bg.setStrokeStyle(isHinted ? 3 : 2, isHinted ? 0x44ffff : 0xcc9944); container.setScale(1.0); }); } @@ -1145,6 +1181,11 @@ export class MainStreetScene extends CardGameScene { }); this.actionContainer.add(endBtn); + // Hint button (to the left of End Turn) + const hintBtnW = 130; + const hintBtn = this.createHintButton(rightX - btnW - 12 - hintBtnW, by + 8, hintBtnW); + this.actionContainer.add(hintBtn); + } else if (this.uiPhase === 'placing-business') { const rightX = GAME_W - 40; const by = ACTION_Y; @@ -1202,7 +1243,93 @@ export class MainStreetScene extends CardGameScene { return container; } - // ── Click handlers ────────────────────────────────────── + /** + * Creates a "Hint" button that is disabled after first use per turn. + * When clicked, queries the Greedy strategy and highlights the recommended + * card/slot with a one-line rationale in the instruction text area. + */ + private createHintButton( + x: number, + y: number, + width: number, + ): Phaser.GameObjects.Container { + const btnH = 40; + const isDisabled = this.hintUsedThisTurn; + + const container = this.add.container(x + width / 2, y + btnH / 2); + + const fillColor = isDisabled ? 0x2a2a2a : 0x224455; + const strokeColor = isDisabled ? 0x444444 : 0x4488aa; + const textColor = isDisabled ? '#666666' : '#88ccff'; + + const bg = this.add.rectangle(0, 0, width, btnH, fillColor, 0.8); + bg.setStrokeStyle(1, strokeColor); + container.add(bg); + + const label = this.add.text(0, 0, isDisabled ? 'Hint ✓' : 'Hint', { + fontSize: '16px', fontStyle: 'bold', color: textColor, fontFamily: FONT_FAMILY, + }).setOrigin(0.5); + container.add(label); + + if (!isDisabled) { + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => this.onHintClick()); + bg.on('pointerover', () => { + bg.setStrokeStyle(2, 0x88ddff); + container.setScale(1.05); + }); + bg.on('pointerout', () => { + bg.setStrokeStyle(1, strokeColor); + container.setScale(1.0); + }); + } + + return container; + } + + /** Handles the Hint button click: generates and displays the hint. */ + private onHintClick(): void { + if (this.hintUsedThisTurn) return; + if (this.uiPhase !== 'market') return; + + const hint: HintResult | null = generateHint(this.state); + if (!hint) { + this.instructionText.setText('Hint not available right now.'); + return; + } + + // Record usage and store highlight targets + this.hintUsedThisTurn = true; + + // Determine which card and slot to highlight based on the action type + if (hint.action.type === 'buy-business') { + this.hintedCardId = hint.action.cardId; + this.hintedSlotIndex = hint.action.slotIndex; + } else if (hint.action.type === 'buy-upgrade') { + this.hintedCardId = hint.action.cardId; + this.hintedSlotIndex = hint.action.targetSlot ?? null; + } else if (hint.action.type === 'buy-event') { + this.hintedCardId = hint.action.cardId; + this.hintedSlotIndex = null; + } else if (hint.action.type === 'play-event') { + this.hintedCardId = this.state.heldEvent?.id ?? null; + this.hintedSlotIndex = null; + } else { + this.hintedCardId = null; + this.hintedSlotIndex = null; + } + + // Show rationale in instruction text + this.instructionText.setText(`Hint: ${hint.rationale}`); + + // Refresh buttons (to disable the hint button) and visual highlights + this.refreshActionButtons(); + this.refreshStreetGrid(); + this.refreshMarket(); + this.refreshPlayerHand(); + } + + private onBusinessCardClick(card: BusinessCard): void { if (this.uiPhase !== 'market') return; diff --git a/tests/main-street/hint.test.ts b/tests/main-street/hint.test.ts new file mode 100644 index 0000000..f5b5f35 --- /dev/null +++ b/tests/main-street/hint.test.ts @@ -0,0 +1,288 @@ +/** + * Main Street: Hint System Tests + * + * Tests for generateHint() and buildRationale() from MainStreetHint. + * Covers: correct recommendation, per-turn limit (caller enforced), rationale + * text, and MarketPhase-only guard. + * + * Appendix B.2 test scenarios from prd-milestone-3.md. + */ +import { describe, it, expect } from 'vitest'; + +import { + setupMainStreetGame, + type MainStreetState, +} from '../../example-games/main-street/MainStreetState'; +import { executeDayStart } from '../../example-games/main-street/MainStreetEngine'; +import type { PlayerAction, BuyBusinessAction } from '../../example-games/main-street/MainStreetEngine'; +import { + generateHint, + buildRationale, +} from '../../example-games/main-street/MainStreetHint'; +import { + enumerateAndScoreActions, + GreedyStrategy, +} from '../../example-games/main-street/MainStreetAiStrategy'; +import type { BusinessCard, UpgradeCard, EventCard } from '../../example-games/main-street/MainStreetCards'; + +// ── Helpers ────────────────────────────────────────────────── + +/** Create a state in MarketPhase. */ +function makeMarketState(seed: string = 'hint-test'): MainStreetState { + const state = setupMainStreetGame({ seed }); + executeDayStart(state); + return state; +} + +/** Check if two PlayerActions are equal by comparing their fields. */ +function actionsEqual(a: PlayerAction, b: PlayerAction): boolean { + if (a.type !== b.type) return false; + if (a.type === 'buy-business' && b.type === 'buy-business') { + return a.cardId === b.cardId && a.slotIndex === b.slotIndex; + } + if (a.type === 'buy-upgrade' && b.type === 'buy-upgrade') { + return a.cardId === b.cardId && a.targetSlot === b.targetSlot; + } + if (a.type === 'buy-event' && b.type === 'buy-event') { + return a.cardId === b.cardId; + } + return true; // play-event and end-turn have no extra fields +} + +// ── generateHint tests ─────────────────────────────────────── + +describe('generateHint', () => { + it('returns null outside MarketPhase (DayStart)', () => { + const state = setupMainStreetGame({ seed: 'phase-guard' }); + // Phase is DayStart; do not call executeDayStart + expect(state.phase).toBe('DayStart'); + const result = generateHint(state); + expect(result).toBeNull(); + }); + + it('returns null when phase is not MarketPhase (InvestmentResolution)', () => { + const state = makeMarketState('phase-guard-2'); + state.phase = 'InvestmentResolution'; + const result = generateHint(state); + expect(result).toBeNull(); + }); + + it('returns a HintResult during MarketPhase', () => { + const state = makeMarketState(); + const result = generateHint(state); + expect(result).not.toBeNull(); + expect(result).toHaveProperty('action'); + expect(result).toHaveProperty('rationale'); + expect(result).toHaveProperty('score'); + }); + + it('hint action matches standalone Greedy evaluation (Appendix B.2)', () => { + // PRD scenario: hint action matches standalone Greedy evaluation. + // Use two independent state instances from the same seed to avoid RNG + // contamination: both start at the same RNG position so tie-breaking agrees. + const stateForHint = makeMarketState('Scenario-FoodFocus'); + const stateForGreedy = makeMarketState('Scenario-FoodFocus'); + + const hint = generateHint(stateForHint); + expect(hint).not.toBeNull(); + + const greedyAction = GreedyStrategy.chooseAction(stateForGreedy, stateForGreedy.rng); + expect(actionsEqual(hint!.action, greedyAction)).toBe(true); + }); + + it('hint score matches the best enumerateAndScoreActions entry', () => { + const state = makeMarketState('hint-score'); + const hint = generateHint(state); + expect(hint).not.toBeNull(); + + const scored = enumerateAndScoreActions(state); + const maxScore = Math.max(...scored.map(s => s.score)); + expect(hint!.score).toBe(maxScore); + }); + + it('hint action is always a legal action type', () => { + const legalTypes = ['buy-business', 'buy-upgrade', 'buy-event', 'play-event', 'end-turn']; + for (const seed of ['seed1', 'seed2', 'seed3', 'seed4', 'seed5']) { + const state = makeMarketState(seed); + const result = generateHint(state); + expect(result).not.toBeNull(); + expect(legalTypes).toContain(result!.action.type); + } + }); + + it('returns end-turn hint when no affordable cards exist', () => { + const state = makeMarketState('no-coins'); + // Drain all coins so no purchase is affordable + state.resourceBank.coins = 0; + const result = generateHint(state); + expect(result).not.toBeNull(); + expect(result!.action.type).toBe('end-turn'); + expect(result!.rationale).toBe('No good buys available -- end your turn'); + }); +}); + +// ── Per-turn limit (caller-enforced) ───────────────────────── + +describe('generateHint per-turn limit (caller responsibility)', () => { + it('generateHint itself does not enforce the per-turn limit', () => { + // The per-turn limit is enforced by the scene via hintUsedThisTurn. + // generateHint() itself always returns a result in MarketPhase. + const state = makeMarketState('limit-test'); + const first = generateHint(state); + const second = generateHint(state); + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + // Both calls return a result; scene is responsible for limiting to 1/turn. + }); +}); + +// ── buildRationale tests ────────────────────────────────────── + +describe('buildRationale', () => { + it('rationale for end-turn contains expected text', () => { + const state = makeMarketState(); + const rationale = buildRationale({ type: 'end-turn' }, 0, state); + expect(rationale).toBe('No good buys available -- end your turn'); + }); + + it('rationale for buy-business includes card name and slot (Appendix B.2)', () => { + const state = makeMarketState('rationale-biz'); + const businessCards = state.market.business as BusinessCard[]; + if (businessCards.length === 0) return; // skip if no business cards + + const card = businessCards[0]; + const action: BuyBusinessAction = { type: 'buy-business', cardId: card.id, slotIndex: 0 }; + const rationale = buildRationale(action, 10, state); + + expect(rationale).toContain(card.name); + expect(rationale).toContain('0'); // slot index + }); + + it('rationale for buy-business with synergy mentions synergy bonus', () => { + const state = makeMarketState('rationale-synergy'); + // Place a business to create potential synergy + const businessCards = state.market.business as BusinessCard[]; + if (businessCards.length < 2) return; + + // Place first card at slot 0 + state.streetGrid[0] = { ...businessCards[0] }; + + // Try placing second card at slot 1 (adjacent) + const card = businessCards[1]; + const action: BuyBusinessAction = { type: 'buy-business', cardId: card.id, slotIndex: 1 }; + + // Only check that rationale contains card name; synergy may or may not apply + const rationale = buildRationale(action, 10, state); + expect(rationale).toContain(card.name); + }); + + it('rationale for buy-upgrade includes business name and income bonus', () => { + const state = makeMarketState('rationale-upgrade'); + const upgradeCards = state.market.investments.filter( + c => c.family === 'upgrade', + ) as UpgradeCard[]; + if (upgradeCards.length === 0) return; + + const card = upgradeCards[0]; + const action = { type: 'buy-upgrade' as const, cardId: card.id, targetSlot: undefined }; + const rationale = buildRationale(action, 5, state); + + expect(rationale).toContain(card.targetBusiness); + expect(rationale).toContain(`${card.incomeBonus}`); + }); + + it('rationale for buy-upgrade with target slot includes business name from grid', () => { + const state = makeMarketState('rationale-upgrade-slot'); + const upgradeCards = state.market.investments.filter( + c => c.family === 'upgrade', + ) as UpgradeCard[]; + if (upgradeCards.length === 0) return; + + const card = upgradeCards[0]; + // Place the target business on the grid + const fakeBiz: BusinessCard = { + id: 'fake-biz', name: card.targetBusiness, family: 'business', + description: 'Test business', cost: 3, baseIncome: 2, incomeBonus: 0, + synergyRangeBonus: 0, synergyTypes: [], level: 0, maxLevel: 2, appliedUpgrades: [], + }; + state.streetGrid[2] = fakeBiz; + + const action = { type: 'buy-upgrade' as const, cardId: card.id, targetSlot: 2 }; + const rationale = buildRationale(action, 5, state); + + expect(rationale).toContain(card.targetBusiness); + expect(rationale).toContain('2'); + }); + + it('rationale for buy-event includes event name', () => { + const state = makeMarketState('rationale-event'); + const eventCards = state.market.investments.filter( + c => c.family === 'event' && (c as EventCard).trigger === 'Investment', + ) as EventCard[]; + if (eventCards.length === 0) return; + + const card = eventCards[0]; + const action = { type: 'buy-event' as const, cardId: card.id }; + const rationale = buildRationale(action, 3, state); + + expect(rationale).toContain(card.name); + }); + + it('rationale for play-event includes held event name', () => { + const state = makeMarketState('rationale-play'); + const fakeEvent: EventCard = { + family: 'event', id: 'test-evt', name: 'Trade Fair', + trigger: 'Investment', target: 'All', cost: 0, effect: 'Test', + coinDelta: 5, reputationDelta: 0, + }; + state.heldEvent = fakeEvent; + + const rationale = buildRationale({ type: 'play-event' }, 5, state); + expect(rationale).toContain('Trade Fair'); + }); + + it('rationale for play-event with no held event uses fallback text', () => { + const state = makeMarketState('rationale-play-null'); + state.heldEvent = null; + + const rationale = buildRationale({ type: 'play-event' }, 5, state); + expect(rationale).toContain('Play'); + expect(rationale).toContain('immediate benefit'); + }); + + it('rationale always returns a non-empty string', () => { + const state = makeMarketState(); + const types: PlayerAction[] = [ + { type: 'end-turn' }, + { type: 'play-event' }, + ]; + for (const action of types) { + const r = buildRationale(action, 0, state); + expect(typeof r).toBe('string'); + expect(r.length).toBeGreaterThan(0); + } + }); +}); + +// ── Integration: hint matches Greedy across multiple seeds ─── + +describe('generateHint integration', () => { + it('hint action matches Greedy recommendation across multiple seeds', () => { + const seeds = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; + for (const seed of seeds) { + // Use separate state instances to avoid RNG contamination between + // generateHint and GreedyStrategy.chooseAction (both consume state.rng). + const stateForHint = makeMarketState(seed); + const stateForGreedy = makeMarketState(seed); + + const hint = generateHint(stateForHint); + expect(hint).not.toBeNull(); + + const greedyAction = GreedyStrategy.chooseAction(stateForGreedy, stateForGreedy.rng); + expect( + actionsEqual(hint!.action, greedyAction), + `Seed "${seed}": hint=${hint!.action.type}, greedy=${greedyAction.type}`, + ).toBe(true); + } + }); +}); From fd37a36fee82e400ddc92aa00429f037f2336f7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:25:09 +0000 Subject: [PATCH 3/3] fix: Address code review - honest signature, explicit actionsEqual cases Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- example-games/main-street/MainStreetHint.ts | 13 ++++++++----- tests/main-street/hint.test.ts | 5 ++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/example-games/main-street/MainStreetHint.ts b/example-games/main-street/MainStreetHint.ts index dc5582b..5aab42e 100644 --- a/example-games/main-street/MainStreetHint.ts +++ b/example-games/main-street/MainStreetHint.ts @@ -45,15 +45,18 @@ export interface HintResult { * Returns null if the game is not in MarketPhase (hints are only valid * during the player's market/action turn). * - * @param state Current game state (read-only by convention). + * Note: This function advances `state.rng` for tie-breaking (via GreedyStrategy). + * Callers should be aware that the RNG position changes after calling this function. + * + * @param state Current game state. * @returns HintResult with the recommended action and rationale, or null. */ -export function generateHint(state: Readonly): HintResult | null { +export function generateHint(state: MainStreetState): HintResult | null { if (state.phase !== 'MarketPhase') return null; // Delegate to GreedyStrategy so the hint is always consistent with AI play. - const action = GreedyStrategy.chooseAction(state as MainStreetState, state.rng); - const score = scoreAction(state as MainStreetState, action); + const action = GreedyStrategy.chooseAction(state, state.rng); + const score = scoreAction(state, action); const rationale = buildRationale(action, score, state); return { action, rationale, score }; @@ -79,7 +82,7 @@ export function generateHint(state: Readonly): HintResult | nul export function buildRationale( action: PlayerAction, _score: number, - state: Readonly, + state: MainStreetState, ): string { switch (action.type) { case 'buy-business': { diff --git a/tests/main-street/hint.test.ts b/tests/main-street/hint.test.ts index f5b5f35..4dc77b8 100644 --- a/tests/main-street/hint.test.ts +++ b/tests/main-street/hint.test.ts @@ -46,7 +46,10 @@ function actionsEqual(a: PlayerAction, b: PlayerAction): boolean { if (a.type === 'buy-event' && b.type === 'buy-event') { return a.cardId === b.cardId; } - return true; // play-event and end-turn have no extra fields + // play-event and end-turn have no additional fields; matching type is sufficient + if (a.type === 'play-event' && b.type === 'play-event') return true; + if (a.type === 'end-turn' && b.type === 'end-turn') return true; + return false; } // ── generateHint tests ───────────────────────────────────────