diff --git a/package-lock.json b/package-lock.json index abc1b66..b8c26a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3783,24 +3783,6 @@ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", diff --git a/src/lib/engine/ai.test.ts b/src/lib/engine/ai.test.ts index 9eeac7d..8c7d8a2 100644 --- a/src/lib/engine/ai.test.ts +++ b/src/lib/engine/ai.test.ts @@ -325,4 +325,409 @@ describe('AI Engine Tests', () => { } }); }); + + describe('AI card knowledge (Priest memory)', () => { + it('should use Guard to eliminate a target whose card was learned via Priest', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI holds Guard + Handmaid and KNOWS the human has a Princess + aiPlayer.hand = ['guard', 'handmaid']; + aiPlayer.knownCards = { p1: 'princess' }; + human.hand = ['princess']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + + // Should play Guard with exact knowledge of the target's card + expect(move?.cardId).toBe('guard'); + expect(move?.targetPlayerId).toBe('p1'); + expect(move?.targetCardGuess).toBe('princess'); + }); + + it('should play Prince on self when a high-value card (King) is exposed', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI holds Prince + King (value 6); King is exposed (human Priested the AI). + // King is high-value (≥5) so self-Princing to escape makes sense. + aiPlayer.hand = ['prince', 'king']; + aiPlayer.exposedToPlayerIds = ['p1']; + human.hand = ['guard']; + state.deck = ['spy', 'priest', 'baron']; // ensure a card to draw + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + + // Should play Prince targeting self to escape exposure of the high-value King + expect(move?.cardId).toBe('prince'); + expect(move?.targetPlayerId).toBe('ai1'); + }); + + it('should NOT self-target with Prince when the exposed card is low-value (Baron)', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI holds Prince + Baron (value 3); Baron is exposed. + // Baron is low-value so self-Princing is not worth it. + aiPlayer.hand = ['prince', 'baron']; + aiPlayer.exposedToPlayerIds = ['p1']; + human.hand = ['guard']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + + // Should NOT self-target — Baron is not worth escaping + expect(move?.targetPlayerId).not.toBe('ai1'); + }); + + it('should play Handmaid (not self-Prince) when exposed and holding both cards', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI holds Prince + Handmaid; Handmaid is exposed. + // Playing Handmaid is better than self-Princing to discard it. + aiPlayer.hand = ['prince', 'handmaid']; + aiPlayer.exposedToPlayerIds = ['p1']; + human.hand = ['guard']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + + // Should play Handmaid for protection — never self-Prince to discard Handmaid + expect(move?.cardId).toBe('handmaid'); + }); + + it('should NOT self-target with Prince when the card to be discarded is Princess', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI holds Prince + Princess; Princess is exposed + // Playing Prince on self would discard Princess → instant loss → should NOT do it + aiPlayer.hand = ['prince', 'princess']; + aiPlayer.exposedToPlayerIds = ['p1']; + human.hand = ['guard']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + + // Must NOT self-target (would discard Princess and lose) + expect(move?.targetPlayerId).not.toBe('ai1'); + }); + + it('should play Baron against an opponent known to have a lower card', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI has Baron + King (value 6). Human is known to hold Guard (value 1). + // Playing Baron → AI keeps King (6) vs Guard (1) → AI wins + aiPlayer.hand = ['baron', 'king']; + aiPlayer.knownCards = { p1: 'guard' }; + human.hand = ['guard']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + + expect(move?.cardId).toBe('baron'); + expect(move?.targetPlayerId).toBe('p1'); + }); + + it('should play King to steal a known higher-value card', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI has King + Baron (value 3). Human is known to hold Countess (value 7). + // Priority 1 (Guard) doesn't apply — no Guard in hand. + // Priority 2 (Baron): AI's other card is Baron (3); Countess (7) > Baron (3) → not a Baron win. + // Priority 4 (King): Countess (7) > Baron (3) → steal the better card. + aiPlayer.hand = ['king', 'baron']; + aiPlayer.knownCards = { p1: 'countess' }; + human.hand = ['countess']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + + expect(move?.cardId).toBe('king'); + expect(move?.targetPlayerId).toBe('p1'); + }); + + it('should clear Priest knowledge when target draws a new card via Prince', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI knew p1 had a Princess, then plays Prince on them to force a redraw + aiPlayer.hand = ['prince', 'guard']; + aiPlayer.knownCards = { p1: 'princess' }; + human.hand = ['priest']; // Not Princess, so Prince won't eliminate them + state.deck = ['spy']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + expect(move?.cardId).toBeDefined(); // any valid move + + // Apply Prince on the human + const forceMove = { + type: 'PLAY_CARD' as const, + playerId: 'ai1', + cardId: 'prince', + targetPlayerId: 'p1', + }; + game.applyMove(forceMove); + + const newState = game.getState(); + const aiAfter = newState.players.find((p) => p.id === 'ai1')!; + + // Knowledge about p1 should be cleared (p1 now has a different card) + expect(aiAfter.knownCards?.['p1']).toBeUndefined(); + }); + + it('should clear exposure when target draws a new card via Prince', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // Human's card is exposed (someone Priested them), then AI forces a redraw + aiPlayer.hand = ['prince', 'guard']; + human.hand = ['priest']; + human.exposedToPlayerIds = ['ai1']; + state.deck = ['spy']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const forceMove = { + type: 'PLAY_CARD' as const, + playerId: 'ai1', + cardId: 'prince', + targetPlayerId: 'p1', + }; + game.applyMove(forceMove); + + const newState = game.getState(); + const humanAfter = newState.players.find((p) => p.id === 'p1')!; + + // Exposure should be cleared since p1 now has a fresh unknown card + expect(humanAfter.exposedToPlayerIds).toEqual([]); + }); + + it('should record Priest knowledge in game state when Priest is applied', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI plays Priest on the human + aiPlayer.hand = ['priest', 'guard']; + human.hand = ['princess']; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const priestMove = { + type: 'PLAY_CARD' as const, + playerId: 'ai1', + cardId: 'priest', + targetPlayerId: 'p1', + }; + game.applyMove(priestMove); + + const newState = game.getState(); + const aiAfter = newState.players.find((p) => p.id === 'ai1')!; + const humanAfter = newState.players.find((p) => p.id === 'p1')!; + + // AI should now know p1's card + expect(aiAfter.knownCards?.['p1']).toBe('princess'); + // p1 should be marked as exposed to ai1 + expect(humanAfter.exposedToPlayerIds).toContain('ai1'); + }); + + it('should clear knowledge about both players when King trades hands', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // Someone else knew both players' cards before the King swap + aiPlayer.hand = ['king', 'guard']; + aiPlayer.exposedToPlayerIds = ['p1']; + human.hand = ['princess']; + human.exposedToPlayerIds = ['ai1']; + // Simulate a bystander knowing both + aiPlayer.knownCards = { p1: 'princess' }; + state.phase = 'WAITING_FOR_ACTION'; + game.setState(state); + + const kingMove = { + type: 'PLAY_CARD' as const, + playerId: 'ai1', + cardId: 'king', + targetPlayerId: 'p1', + }; + game.applyMove(kingMove); + + const newState = game.getState(); + const aiAfter = newState.players.find((p) => p.id === 'ai1')!; + const humanAfter = newState.players.find((p) => p.id === 'p1')!; + + // Both players have new cards; exposure is cleared + expect(aiAfter.exposedToPlayerIds).toEqual([]); + expect(humanAfter.exposedToPlayerIds).toEqual([]); + // Knowledge about both is now stale and cleared + expect(aiAfter.knownCards?.['p1']).toBeUndefined(); + }); + + it('should use known card for Chancellor return — keep Guard when opponent card is known', () => { + const config: GameConfig = { + players: [ + { id: 'ai1', name: 'AI 1', isAI: true }, + { id: 'p1', name: 'Human', isHost: true }, + ], + }; + + game.init(config); + game.startRound(); + + const state = game.getState(); + const aiPlayer = state.players.find((p) => p.id === 'ai1')!; + const human = state.players.find((p) => p.id === 'p1')!; + + // AI has [guard, spy, baron] and knows p1 holds Princess + // Should keep Guard (to eliminate p1 next turn), not the highest-value baron + aiPlayer.hand = ['guard', 'spy', 'baron']; + aiPlayer.knownCards = { p1: 'princess' }; + human.hand = ['princess']; + state.phase = 'CHANCELLOR_RESOLVING'; + state.chancellorCards = ['spy', 'baron']; + game.setState(state); + + const move = decideAIMove(game.getState(), 'ai1'); + + expect(move?.type).toBe('CHANCELLOR_RETURN'); + expect(move?.cardsToReturn).toHaveLength(2); + // Should keep Guard (to use the knowledge of Princess next turn) + expect(move?.cardsToReturn).not.toContain('guard'); + }); + }); }); diff --git a/src/lib/engine/ai.ts b/src/lib/engine/ai.ts index f6aa5fb..97b01db 100644 --- a/src/lib/engine/ai.ts +++ b/src/lib/engine/ai.ts @@ -2,6 +2,7 @@ import type { GameState, GameAction, PlayerState, Ruleset } from '../types'; import { getCardDefinition, getCardValue, createDeck } from './deck'; import { getValidTargets } from './validation'; import { + GUARD, PRIEST, BARON, HANDMAID, @@ -15,7 +16,8 @@ import { /** * AI player decision-making engine - * Implements a basic strategy for playing Love Letter + * Implements a strategy for playing Love Letter that uses card knowledge + * gained from Priest plays and other public information. */ /** Module-level cache: deck composition never changes for a given ruleset */ @@ -53,15 +55,23 @@ function getPossibleGuesses(ruleset: Ruleset): string[] { } /** - * Choose a card to guess for Guard based on simple probability - * Considers what cards have been played (in discard piles) and burned cards + * Choose a card to guess for Guard/revenge based on probability and known information. + * If the actor already knows the target's card (via Priest), use that directly. */ function chooseGuardGuess( state: GameState, - _targetPlayer: PlayerState, + targetPlayer: PlayerState, + actorPlayer: PlayerState, ): string { const possibleGuesses = getPossibleGuesses(state.ruleset); + // If we KNOW the target's card (from a previous Priest play), use it — guaranteed kill + const knownCard = actorPlayer.knownCards?.[targetPlayer.id]; + if (knownCard && possibleGuesses.includes(knownCard)) { + return knownCard; + } + + // Fall back to probability-based guessing // Count cards that have been discarded or burned const seenCards: Record = {}; @@ -116,12 +126,31 @@ function chooseGuardGuess( } /** - * Choose which card to play from hand + * Get targetable opponents (not self, not protected, not eliminated). + */ +function getTargetableOpponents( + state: GameState, + playerId: string, +): PlayerState[] { + return getValidTargets(state, playerId, false).filter( + (p) => p.id !== playerId, + ); +} + +/** + * Choose which card to play from hand, using strategic priorities: + * 1. Mandatory Countess rule + * 2. Play Guard when we know a target's card (guaranteed kill) + * 3. Play Baron when we know a target has a lower card (guaranteed win) + * 4. Play Handmaid when exposed — protection prevents opponents acting on their knowledge + * 5. Play Prince on self only for high-value exposed cards (≥5, i.e. King+) + * 6. Play King to steal a known higher card + * 7. Default: avoid Princess; prefer lower-value cards */ function chooseCardToPlay(player: PlayerState, state: GameState): string { const hand = [...player.hand]; - // Countess rule: If player has Countess + (King or Prince), must play Countess + // Countess rule: must play Countess when holding King or Prince const hasCountess = hand.includes(COUNTESS); const hasKing = hand.includes(KING); const hasPrince = hand.includes(PRINCE); @@ -130,25 +159,98 @@ function chooseCardToPlay(player: PlayerState, state: GameState): string { return COUNTESS; } - // Basic strategy: avoid playing Princess (auto-lose) - const nonPrincessCards = hand.filter((c) => c !== PRINCESS); - if (nonPrincessCards.length > 0) { - // Prefer to play lower value cards first to avoid elimination in Baron comparisons - nonPrincessCards.sort((a, b) => { - const aValue = getCardValue(a, state.ruleset); - const bValue = getCardValue(b, state.ruleset); - return aValue - bValue; + // Never deliberately play Princess (auto-lose) + const playableCards = hand.filter((c) => c !== PRINCESS); + if (playableCards.length === 0) { + return PRINCESS; // No choice + } + + const opponents = getTargetableOpponents(state, player.id); + const isExposed = (player.exposedToPlayerIds?.length ?? 0) > 0; + + // Priority 1: Play Guard if we KNOW an opponent's card — guaranteed elimination + if (playableCards.includes(GUARD) && opponents.length > 0) { + const possibleGuesses = getPossibleGuesses(state.ruleset); + const hasKnownTarget = opponents.some((p) => { + const known = player.knownCards?.[p.id]; + return known !== undefined && possibleGuesses.includes(known); }); - return nonPrincessCards[0]; + if (hasKnownTarget) { + return GUARD; + } + } + + // Priority 2: Play Baron if we know an opponent holds a LOWER card than ours + if (playableCards.includes(BARON) && opponents.length > 0) { + const myOtherCard = playableCards.find((c) => c !== BARON); + if (myOtherCard) { + const myValue = getCardValue(myOtherCard, state.ruleset); + const hasWeakerTarget = opponents.some((p) => { + const known = player.knownCards?.[p.id]; + return known !== undefined && getCardValue(known, state.ruleset) < myValue; + }); + if (hasWeakerTarget) { + return BARON; + } + } + } + + // Priority 3: Play Handmaid when exposed — gains full-round protection, nullifying opponent knowledge. + // This is always better than self-Princing to discard the Handmaid. + if (playableCards.includes(HANDMAID) && isExposed) { + return HANDMAID; + } + + // Priority 4: Play Prince on self when a HIGH-VALUE card is exposed (value ≥ 5). + // Only worthwhile for King-level cards that an opponent would specifically Guard-guess. + // Low-value cards (Guard, Priest, Handmaid, Baron) are not worth discarding to escape. + if (playableCards.includes(PRINCE) && isExposed) { + const cardToDiscard = playableCards.find((c) => c !== PRINCE); + const discardValue = cardToDiscard + ? getCardValue(cardToDiscard, state.ruleset) + : 0; + if ( + cardToDiscard && + cardToDiscard !== PRINCESS && + cardToDiscard !== HANDMAID && + discardValue >= 5 + ) { + return PRINCE; + } + } + + // Priority 5: Play King to steal a known card that is better than ours + if (playableCards.includes(KING) && opponents.length > 0) { + const myOtherCard = playableCards.find((c) => c !== KING); + if (myOtherCard) { + const myValue = getCardValue(myOtherCard, state.ruleset); + const hasBetterTarget = opponents.some((p) => { + const known = player.knownCards?.[p.id]; + return known !== undefined && getCardValue(known, state.ruleset) > myValue; + }); + if (hasBetterTarget) { + return KING; + } + } } - // If only Princess left, we have to play it - return hand[0]; + // Default: play the lowest-value non-Princess card + playableCards.sort((a, b) => { + const aValue = getCardValue(a, state.ruleset); + const bValue = getCardValue(b, state.ruleset); + return aValue - bValue; + }); + return playableCards[0]; } /** - * Choose a target player for targeted cards - * Prioritizes players with the most tokens (they're closest to winning) + * Choose a target player for the given card, using card knowledge where available. + * + * - Guard: target the player whose card we know (for a certain kill) + * - Baron: target the player we know has a lower card than ours + * - Prince: target self when card is exposed; otherwise target highest-token player + * - King: target the player whose card we know is higher than ours + * - Others: target the player closest to winning (most tokens) */ function chooseTarget( state: GameState, @@ -162,28 +264,80 @@ function chooseTarget( const canTargetSelf = cardDef.effect.canTargetSelf || false; const validTargets = getValidTargets(state, playerId, canTargetSelf); + const player = state.players.find((p) => p.id === playerId)!; + const opponents = validTargets.filter((p) => p.id !== playerId); + + // --- Guard: prefer the opponent whose card we know (certain elimination) --- + if (cardId === GUARD && opponents.length > 0) { + const possibleGuesses = getPossibleGuesses(state.ruleset); + const knownTarget = opponents.find((p) => { + const known = player.knownCards?.[p.id]; + return known !== undefined && possibleGuesses.includes(known); + }); + if (knownTarget) return knownTarget.id; + } + + // --- Baron: prefer an opponent we know has a lower card than ours --- + if (cardId === BARON && opponents.length > 0) { + const myOtherCard = player.hand.find((c) => c !== BARON); + if (myOtherCard) { + const myValue = getCardValue(myOtherCard, state.ruleset); + const weakTarget = opponents + .filter((p) => { + const known = player.knownCards?.[p.id]; + return known !== undefined && getCardValue(known, state.ruleset) < myValue; + }) + .sort((a, b) => b.tokens - a.tokens)[0]; // Among weak targets, pick highest-token + if (weakTarget) return weakTarget.id; + } + } - // Filter out self for most cards unless it's Prince and self is only option - const otherTargets = validTargets.filter((p) => p.id !== playerId); + // --- Prince: self-target only when a HIGH-VALUE card (≥5) has been exposed --- + if (cardId === PRINCE && canTargetSelf) { + const isExposed = (player.exposedToPlayerIds?.length ?? 0) > 0; + if (isExposed) { + const cardToDiscard = player.hand.find((c) => c !== PRINCE); + const discardValue = cardToDiscard + ? getCardValue(cardToDiscard, state.ruleset) + : 0; + if ( + cardToDiscard && + cardToDiscard !== PRINCESS && + cardToDiscard !== HANDMAID && + discardValue >= 5 + ) { + return player.id; + } + } + } - if (otherTargets.length > 0) { - // Prioritize targeting the player with the most tokens (closest to winning) - // Sort by tokens descending, then take the first one - const sortedByTokens = [...otherTargets].sort( - (a, b) => b.tokens - a.tokens, - ); + // --- King: prefer an opponent whose card we know is better than ours --- + if (cardId === KING && opponents.length > 0) { + const myOtherCard = player.hand.find((c) => c !== KING); + if (myOtherCard) { + const myValue = getCardValue(myOtherCard, state.ruleset); + const richTarget = opponents + .filter((p) => { + const known = player.knownCards?.[p.id]; + return known !== undefined && getCardValue(known, state.ruleset) > myValue; + }) + .sort((a, b) => b.tokens - a.tokens)[0]; // Among better targets, pick highest-token + if (richTarget) return richTarget.id; + } + } + + // --- Default: target the opponent with the most tokens (closest to winning) --- + if (opponents.length > 0) { + const sortedByTokens = [...opponents].sort((a, b) => b.tokens - a.tokens); return sortedByTokens[0].id; } - // If Prince and no other targets, can target self + // Prince can target self as last resort if (cardId === PRINCE && canTargetSelf) { const self = validTargets.find((p) => p.id === playerId); - if (self) { - return self.id; - } + if (self) return self.id; } - // No valid targets - card will be played with no effect return undefined; } @@ -230,7 +384,7 @@ export function decideAIMove( if (cardDef?.effect.requiresTargetCardType && targetPlayerId) { const targetPlayer = state.players.find((p) => p.id === targetPlayerId); if (targetPlayer) { - targetCardGuess = chooseGuardGuess(state, targetPlayer); + targetCardGuess = chooseGuardGuess(state, targetPlayer, player); } } @@ -244,7 +398,11 @@ export function decideAIMove( } /** - * Decide which cards to return for Chancellor effect + * Decide which cards to return for Chancellor effect. + * Strategy: keep the card that is most useful given current knowledge. + * - If we know an opponent's card, prefer keeping Guard (to eliminate them) or + * a card that beats them in a Baron comparison. + * - Otherwise keep the highest-value card for round-end comparison. */ function decideChancellorReturn( state: GameState, @@ -255,19 +413,45 @@ function decideChancellorReturn( return null; } - // Number of cards to return = hand size - 1 (keep exactly 1 card) const cardsToReturnCount = player.hand.length - 1; + const opponents = getTargetableOpponents(state, playerId); - // Strategy: keep the highest value card (for round-end comparison) - // Sort hand by value descending - const sortedHand = [...player.hand].sort((a, b) => { - const aValue = getCardValue(a, state.ruleset); - const bValue = getCardValue(b, state.ruleset); - return bValue - aValue; - }); + // Check if we know any opponent's card + const knownOpponent = opponents.find( + (p) => player.knownCards?.[p.id] !== undefined, + ); + + let cardToKeep: string; + + if (knownOpponent) { + const knownCard = player.knownCards![knownOpponent.id]!; + const knownValue = getCardValue(knownCard, state.ruleset); + const possibleGuesses = getPossibleGuesses(state.ruleset); + + // If opponent holds a guessable card, prefer keeping Guard for a certain kill + if (player.hand.includes(GUARD) && possibleGuesses.includes(knownCard)) { + cardToKeep = GUARD; + } else { + // Keep the card that beats the known opponent card in Baron, or highest otherwise + const beatingCard = player.hand + .filter((c) => c !== PRINCESS) // don't keep princess if alternatives exist + .find((c) => getCardValue(c, state.ruleset) > knownValue); + cardToKeep = beatingCard ?? player.hand.reduce((best, c) => + getCardValue(c, state.ruleset) >= getCardValue(best, state.ruleset) ? c : best, + ); + } + } else { + // No knowledge — keep the highest-value card (maximises round-end win chance) + cardToKeep = player.hand.reduce((best, c) => + getCardValue(c, state.ruleset) >= getCardValue(best, state.ruleset) ? c : best, + ); + } - // Keep the highest value card, return the rest (up to cardsToReturnCount) - const cardsToReturn = sortedHand.slice(1, 1 + cardsToReturnCount); + // Return all cards except the one we want to keep + const remaining = [...player.hand]; + const keepIndex = remaining.indexOf(cardToKeep); + remaining.splice(keepIndex, 1); + const cardsToReturn = remaining.slice(0, cardsToReturnCount); return { type: 'CHANCELLOR_RETURN', @@ -288,6 +472,7 @@ function decideRevengeGuess( return null; } + const player = state.players.find((p) => p.id === playerId)!; const targetPlayer = state.players.find( (p) => p.id === state.revengeGuess!.targetId, ); @@ -295,8 +480,8 @@ function decideRevengeGuess( return null; } - // Use the same logic as Guard guess - const guess = chooseGuardGuess(state, targetPlayer); + // Use the same logic as Guard guess, including any knowledge we have + const guess = chooseGuardGuess(state, targetPlayer, player); return { type: 'REVENGE_GUESS', diff --git a/src/lib/engine/effects/king.ts b/src/lib/engine/effects/king.ts index cb53a77..8560575 100644 --- a/src/lib/engine/effects/king.ts +++ b/src/lib/engine/effects/king.ts @@ -21,6 +21,16 @@ export function applyTradeHands(context: EffectContext): EffectResult { activePlayer.hand = targetPlayer.hand; targetPlayer.hand = temp; + // Both players now hold different cards — invalidate all prior knowledge about them + for (const player of state.players) { + if (player.knownCards) { + delete player.knownCards[activePlayer.id]; + delete player.knownCards[targetPlayer.id]; + } + } + activePlayer.exposedToPlayerIds = []; + targetPlayer.exposedToPlayerIds = []; + addLog( `${activePlayer.name} and ${targetPlayer.name} traded hands`, state, @@ -61,6 +71,14 @@ export function applyTradeWithBurnedCard( activePlayer.hand[0] = burnedCard; state.burnedCard = playerCard; + // Player now has a new card — clear stale knowledge about them + for (const player of state.players) { + if (player.knownCards) { + delete player.knownCards[activePlayer.id]; + } + } + activePlayer.exposedToPlayerIds = []; + addLog( `${activePlayer.name} swapped their card with the burned card`, state, diff --git a/src/lib/engine/effects/priest.ts b/src/lib/engine/effects/priest.ts index 10bb1ed..58a9cae 100644 --- a/src/lib/engine/effects/priest.ts +++ b/src/lib/engine/effects/priest.ts @@ -13,6 +13,16 @@ export function applySeeHand(context: EffectContext): EffectResult { )!; const revealedCard = targetPlayer.hand[0] || ''; + // Record this knowledge so AI players can use it in future decisions + if (!activePlayer.knownCards) activePlayer.knownCards = {}; + activePlayer.knownCards[targetPlayer.id] = revealedCard; + + // Mark the target as exposed to the actor + if (!targetPlayer.exposedToPlayerIds) targetPlayer.exposedToPlayerIds = []; + if (!targetPlayer.exposedToPlayerIds.includes(activePlayer.id)) { + targetPlayer.exposedToPlayerIds.push(activePlayer.id); + } + addLog( `${activePlayer.name} saw ${targetPlayer.name}'s hand`, state, diff --git a/src/lib/engine/effects/prince.ts b/src/lib/engine/effects/prince.ts index b902d05..13dcd1e 100644 --- a/src/lib/engine/effects/prince.ts +++ b/src/lib/engine/effects/prince.ts @@ -47,6 +47,14 @@ export function applyForceDiscard(context: EffectContext): EffectResult { targetPlayer.hand.push(newCard); addLog(`${targetPlayer.name} drew a new card`, state, targetPlayer.id); } + + // The target now has a new (unknown) card — clear stale knowledge about them + for (const player of state.players) { + if (player.knownCards) { + delete player.knownCards[targetPlayer.id]; + } + } + targetPlayer.exposedToPlayerIds = []; } return { diff --git a/src/lib/engine/player.ts b/src/lib/engine/player.ts index 6611bf5..443123f 100644 --- a/src/lib/engine/player.ts +++ b/src/lib/engine/player.ts @@ -51,6 +51,8 @@ export function resetPlayersForRound(players: PlayerState[]): PlayerState[] { discardPile: [], status: 'PLAYING' as const, eliminationReason: undefined, + knownCards: undefined, + exposedToPlayerIds: undefined, })); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 598503c..75f7dd8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -45,6 +45,10 @@ export interface PlayerState { isHost: boolean; isAI?: boolean; // Whether this player is controlled by AI eliminationReason?: string; // Reason why the player was eliminated + /** Cards this player has seen via Priest: maps opponentId -> their current card ID */ + knownCards?: Record; + /** Player IDs who currently know this player's hand card (e.g. after being Priested) */ + exposedToPlayerIds?: string[]; } export type GamePhase =