diff --git a/__tests__/ai/llmGamePrompt.test.ts b/__tests__/ai/llmGamePrompt.test.ts new file mode 100644 index 0000000..022a58d --- /dev/null +++ b/__tests__/ai/llmGamePrompt.test.ts @@ -0,0 +1,268 @@ +import { buildLLMUserPrompt } from "../../src/ai/llm/llmGamePrompt"; +import { + Card, + JokerType, + PlayerId, + Rank, + Suit, + TrumpInfo, +} from "../../src/types"; +import { createGameState, givePlayerCards } from "../helpers/gameStates"; +import { createTrick } from "../helpers/tricks"; + +/** + * The LLM prompt is built from FACTS and DIAGNOSIS, never recommendations. + * These tests pin the contract: option-classes framed in point-flow, equivalent + * cards collapsed, and no "Rule Score" / "recommend" / prescriptive-rule strings. + */ + +const TRUMP: TrumpInfo = { trumpRank: Rank.Two, trumpSuit: Suit.Hearts }; + +const single = (suit: Suit, rank: Rank, deck: 0 | 1 = 0): Card => + Card.createCard(suit, rank, deck); + +describe("LLM prompt — facts & diagnosis, not rules", () => { + test("4th seat with a winning off-suit Ace: option is framed as capturing the points", () => { + // Human leads K♠ and is winning; 20 pts on the table; Bot3 is last to act and + // holds A♠ (beats K♠) plus low spades. The Ace play must read as a point capture. + const trick = createTrick( + PlayerId.Human, + [single(Suit.Spades, Rank.King)], + [ + { playerId: PlayerId.Bot1, cards: [single(Suit.Spades, Rank.Three)] }, + { playerId: PlayerId.Bot2, cards: [single(Suit.Spades, Rank.Ten)] }, + ], + 20, + PlayerId.Human, + ); + let state = createGameState({ + trumpInfo: TRUMP, + currentTrick: trick, + currentPlayerIndex: 3, + }); + const hand = [ + single(Suit.Spades, Rank.Ace), + single(Suit.Spades, Rank.Four), + single(Suit.Spades, Rank.Nine), + single(Suit.Hearts, Rank.Six), + single(Suit.Clubs, Rank.Nine), + ]; + state = givePlayerCards(state, 3, hand); + + const { user, system } = buildLLMUserPrompt(state, PlayerId.Bot3, hand); + + // The winning play is described by what it yields in POINTS, not "wins trick". + expect(user).toContain( + "A♠ → wins the trick → captures 20 pts for your team", + ); + // The two low spades are collapsed into one equivalent losing class. + expect(user).toContain("4♠ · 9♠ → loses; concedes nothing of yours"); + // No recommendation, no rule-based score, no transcribed strategy heuristics. + expect(user).not.toMatch(/Rule Score/); + expect(user).not.toMatch(/recommend/i); + expect(system).not.toMatch( + /Seat Guidance|Position Cues|duck low|Conserve Control/, + ); + }); + + test("pair lead with several non-winning pairs: pairs grouped and collapsed as losers", () => { + // Human leads K♦K♦ (20 pts). Bot1 (2nd) holds 3♦3♦ and 8♦8♦ — both legal, + // neither wins. They must be listed as bracketed pairs, collapsed as losers. + const trick = createTrick( + PlayerId.Human, + Card.createPair(Suit.Diamonds, Rank.King), + [], + 20, + PlayerId.Human, + ); + let state = createGameState({ + trumpInfo: TRUMP, + currentTrick: trick, + currentPlayerIndex: 1, + }); + const hand = [ + ...Card.createPair(Suit.Diamonds, Rank.Three), + ...Card.createPair(Suit.Diamonds, Rank.Eight), + single(Suit.Clubs, Rank.Six), + single(Suit.Hearts, Rank.Seven), + ]; + state = givePlayerCards(state, 1, hand); + + const { user } = buildLLMUserPrompt(state, PlayerId.Bot1, hand); + + expect(user).toContain("## Your Options"); + expect(user).toContain("[3♦ 3♦]"); + expect(user).toContain("[8♦ 8♦]"); + expect(user).toContain("loses; concedes nothing of yours"); + expect(user).not.toMatch(/recommend/i); + }); + + test("teammate winning safely: contributing point cards is framed as banking toward 80", () => { + // Bot1 (Bot3's teammate) is winning with A♣; Bot3 is last to act, so the win is + // locked. Bot3 is on the attacking team, so banked points count toward 80. + const trick = createTrick( + PlayerId.Human, + [single(Suit.Clubs, Rank.Three)], + [ + { playerId: PlayerId.Bot1, cards: [single(Suit.Clubs, Rank.Ace)] }, + { playerId: PlayerId.Bot2, cards: [single(Suit.Clubs, Rank.Four)] }, + ], + 0, + PlayerId.Bot1, + ); + let state = createGameState({ + trumpInfo: TRUMP, + currentTrick: trick, + currentPlayerIndex: 3, + }); + const hand = [ + single(Suit.Clubs, Rank.King), + single(Suit.Clubs, Rank.Five), + single(Suit.Hearts, Rank.Six), + single(Suit.Diamonds, Rank.Nine), + ]; + state = givePlayerCards(state, 3, hand); + + const { user } = buildLLMUserPrompt(state, PlayerId.Bot3, hand); + + expect(user).toContain("K♣ → loses; banks 10 pts toward your team's 80"); + expect(user).toContain("5♣ → loses; banks 5 pts toward your team's 80"); + }); + + test("defender conceding to an attacker: point card is framed as feeding the attackers' 80", () => { + // Bot1 (attacker, Team B) leads the boss A♣ and is winning. Bot2 (defender, + // Team A) must follow clubs with K♣ or 4♣ — neither wins. The point card must + // read as feeding the attackers' total, so dumping it is an obvious loss. + const trick = createTrick( + PlayerId.Bot1, + [single(Suit.Clubs, Rank.Ace)], + [], + 0, + PlayerId.Bot1, + ); + let state = createGameState({ + trumpInfo: TRUMP, + currentTrick: trick, + currentPlayerIndex: 2, + }); + const hand = [ + single(Suit.Clubs, Rank.King), + single(Suit.Clubs, Rank.Four), + single(Suit.Hearts, Rank.Six), + ]; + state = givePlayerCards(state, 2, hand); + + const { user } = buildLLMUserPrompt(state, PlayerId.Bot2, hand); + + // Defender role stated so the goal cannot be read backwards. + expect(user).toContain( + "Defending — you win by keeping the attackers under 80", + ); + // The King's cost is tied to the threshold; the low club concedes nothing. + expect(user).toContain( + "K♣ → loses; adds 10 pts to the attackers' total (toward their 80)", + ); + expect(user).toContain("4♣ → loses; concedes nothing of yours"); + }); + + test("leading an unbeatable pair: framed as a guaranteed win, no score", () => { + const state = createGameState({ + trumpInfo: TRUMP, + currentTrick: null, + currentPlayerIndex: 1, + }); + const hand = [ + ...Card.createPair(Suit.Spades, Rank.Ace), + single(Suit.Clubs, Rank.Three), + single(Suit.Hearts, Rank.Six), + ]; + const withHand = givePlayerCards(state, 1, hand); + + const { user, system } = buildLLMUserPrompt(withHand, PlayerId.Bot1, hand); + + expect(user).toContain("## Lead Options"); + expect(user).toContain( + "[A♠ A♠] (pair) → unbeatable in-suit → wins unless an opponent ruffs; keeps the lead (spends a boss, not trump)", + ); + expect(user).not.toMatch(/Rule Score/); + // System prompt keeps objective mechanics, drops prescriptive strategy. + expect(system).toContain("## 5. Reading the Options"); + expect(system).not.toMatch(/Leading Strategy|Seat Guidance/); + }); + + test("trump leads are stated as cost facts, not as a 'bleed trump' tactic", () => { + const state = createGameState({ + trumpInfo: TRUMP, + currentTrick: null, + currentPlayerIndex: 1, + }); + const hand = [ + ...Card.createPair(Suit.Hearts, Rank.Seven), // low trump pair (trump suit) + ...Card.createPair(Suit.Spades, Rank.Two), // scarce trump-rank pair + single(Suit.Diamonds, Rank.Five), + ]; + const withHand = givePlayerCards(state, 1, hand); + + const { user } = buildLLMUserPrompt(withHand, PlayerId.Bot1, hand); + + // No tactical nudge to lead trump early. + expect(user).not.toMatch(/bleeds|forces opponents/); + // Low trump pair: stated as a cost. + expect(user).toContain( + "[7♥ 7♥] (trump pair) → takes the trick + the next lead unless a higher trump pair is out; cost: spends trump — your ruff/control resource", + ); + // Trump-rank pair: flagged as scarce so it is not burned early. + expect(user).toContain( + "[2♠ 2♠] (trump pair) → takes the trick + the next lead unless a higher trump pair is out; cost: spends scarce high trump (jokers/trump-rank)", + ); + }); + + test("following options state the exact count and the two-copies rule for pairs", () => { + const trick = createTrick( + PlayerId.Human, + Card.createPair(Suit.Diamonds, Rank.King), + [], + 20, + PlayerId.Human, + ); + let state = createGameState({ + trumpInfo: TRUMP, + currentTrick: trick, + currentPlayerIndex: 1, + }); + const hand = [ + ...Card.createPair(Suit.Diamonds, Rank.Three), + single(Suit.Clubs, Rank.Six), + ]; + state = givePlayerCards(state, 1, hand); + + const { user } = buildLLMUserPrompt(state, PlayerId.Bot1, hand); + + expect(user).toContain( + "Play exactly 2 card(s). Copy cards verbatim from YOUR HAND — to repeat a card (a pair) you must hold two copies of it (shown ×2).", + ); + }); + + test("trump single leads: top live trump wins; a beatable high trump is flagged", () => { + const state = createGameState({ + trumpInfo: TRUMP, + currentTrick: null, + currentPlayerIndex: 1, + }); + const hand = [ + Card.createJoker(JokerType.Big, 0), // top trump → wins the lead + single(Suit.Hearts, Rank.Ace), // high trump, but jokers/2s still out → beatable + single(Suit.Diamonds, Rank.Five), + ]; + const withHand = givePlayerCards(state, 1, hand); + + const { user } = buildLLMUserPrompt(withHand, PlayerId.Bot1, hand); + + expect(user).toContain( + "BJ (trump) → no trump still out beats it: leading it takes the trick + the next lead", + ); + expect(user).toContain( + "trump singles (A♥) → a higher trump is still out, so these can be beaten", + ); + }); +}); diff --git a/docs/proposals/2026-05-31_llm_game_state_signals.md b/docs/proposals/2026-05-31_llm_game_state_signals.md deleted file mode 100644 index 1f3d05f..0000000 --- a/docs/proposals/2026-05-31_llm_game_state_signals.md +++ /dev/null @@ -1,80 +0,0 @@ -# Derived Game-State Signals for the LLM (future work) - -Shengji is, from one seat, a **near-perfect-information game**: with full card -accounting almost every strategic signal is *computable*, not guessed. This note -captures a shared, code-side analytical layer the LLM (and the rule-based AI) could -draw on. It is **general** — win-safety is only one consumer; the same accounting -feeds leading, ruffing, conservation, and point-pressure decisions alike. - -**Not scheduled for implementation** — captured so the reasoning isn't lost. Implement -selectively, cheapest-value-first. - -## The accounting identity everything builds on - -A Tractor deck is **108 cards** = two 52-card decks + 4 jokers. Dealt **25 × 4 = 100**; -the remaining **8 form the kitty**. Points total **200** = **50 per suit** -(2×5 + 2×10 + 2×K). When the trump *rank* is itself a point rank (5/10/K), those copies -promote into the trump group, so a side suit holds less. - -For any card or suit: **unseen = total − played − in-my-hand**, and the unseen cards -sit in the other three hands *or* the hidden kitty. - -> **The one irreducible unknown: the kitty.** With 8 cards hidden, you can *bound* but -> not *prove* a specific card sits in an opponent's hand. So most signals below are -> confidence improvements, not certainties — except late in the round, when few cards -> remain unseen and the kitty's share of the unknown shrinks toward exact. - -## The shared signal layer - -Compute in code (shared with the rule-based AI, which already derives much of this for -its scoring), surface compactly to the small LLM, and let the model *interpret* rather -than *count*. - -1. **Per-suit / per-rank card census** — which specific cards are still unseen, split by - "could be in a hand" vs "could be kitty". The substrate for everything else. -2. **Point accounting** — points played, in-hand, and still live *per suit*, down to - *which* point cards remain ("both Ks still out" vs "suit drained"). Today - `localFormatLiveOffSuitPoints` does the off-suit total only. -3. **Void model** — confirmed voids (already tracked in `memoryContext`) **plus** - *probabilistic* voids: a player who failed to win with points up, or who has shown - many of a suit, is likely short/void. Grade as a probability, not a flag. -4. **Trump census** — how much trump, and which high trumps (jokers, active ranks), - remain among the other seats. Drives when trump is safe to spend vs. must be hoarded. -5. **Stage** — early/mid/late from hand size; late game tightens every estimate toward - exact and shifts conservation toward cashing. -6. **Boss / unbeatable status** — memory-aware (the engine's `isComboUnbeatable` - already does this for singles; extend to pairs/tractors). -7. **Forced-play detection** — when an opponent *must* follow a suit and holds only - point cards there, your team is **guaranteed to extract points** (no kitty - ambiguity); flag too when the last relevant seat can instead dump high or ruff. -8. **Observed forced point-spill** — a player never volunteers a 10/K into a trick their - team won't win, so a point card seen played into a **lost** trick means they were - forced: a void/shortage tell that sharpens the void model (#3) — they couldn't follow - the led suit, or are down to only high cards in it. - -## Consumers — why this is general, not win-safety-specific - -- **Win-safety verdict** — grade `teammateWinSafe` (today a boolean tested only on the - winning card *as a single*) into SECURED / STRONG / SLIM using void probability, - pair/tractor unbeatability, and stage. -- **Leading** — attack point-rich suits; lead into a likely-void opponent to force - their trump; avoid drained suits; time boss-cashing for when all higher copies are - seen (a K is boss once both Aces are accounted for). -- **Following / contribution** — press hard when points are *forced* out; contribute - on a safe win; stop feeding a drained suit. -- **Ruffing** — ruff to capture when worthwhile and size the ruff to the trump still - outstanding (don't over-ruff a trick no one left can top). -- **Conservation & trump management** — spend freely once opponents' trump is - exhausted; hoard when trump is scarce (especially No-Trump rounds). -- **Point pressure / endgame** — how many points remain to contest sets how aggressive - to be on each side of the 80-point line. - -## Implementation principle - -Keep the division of labour the prompt already uses: **count in code** (shared with the -rule-based AI), surface **compact derived signals** to the LLM, and let the small model -spend its limited reasoning on *judgement among legal options* — not arithmetic. - -If picking one off first: **forced-points detection** is the highest-value, -lowest-uncertainty signal — fully computable, no kitty ambiguity, and useful to both -leading and following. diff --git a/docs/proposals/2026-06-01_suit_following_refinement.md b/docs/proposals/2026-06-01_suit_following_refinement.md deleted file mode 100644 index dc0c9c9..0000000 --- a/docs/proposals/2026-06-01_suit_following_refinement.md +++ /dev/null @@ -1,86 +0,0 @@ -# Proposal: Programmatic Pair-Constraint Injection for LLM Suit Following - -## Status -* **Author**: Antigravity -* **Date**: 2026-06-01 -* **Status**: Proposed - ---- - -## 1. Context & Motivation - -During unattended game simulations with LLMs active, we observed initial following failures on **Trick 2** where multiple bots made rule-invalid plays: -* **`bot1`**: Attempted to play `["2♣", "3♦", "3♦", "4♦"]` (one pair, two singles) against a led Tractor. It got rejected with: `Must follow tractor priority: you must play pairs to match the pairs of the leading tractor.` -* **`bot2`**: Attempted to play `["4♦", "2♠", "Q♦", "J♦"]` (four singles) against a led Tractor. It got rejected with the same error. - -Both bots recovered on Attempt 2 via the self-correction retry loop, but these failures reveal a critical structural gap in how general suit-following rules for structured leads are surfaced to the LLM. - ---- - -## 2. Root Cause Analysis - -In Shengji (Tractor), when a structured lead occurs—such as a `Pair` or a `Tractor` (consecutive pairs)—a player holding any pairs in that suit **must** follow with those pairs to match the leading structure (e.g., matching a 4-card tractor requires playing 2 pairs if held, or 1 pair and 2 singles if only 1 pair is held). - -In our current prompt design: -1. When the player doesn't hold the exact matching combination (like a matching 4-card consecutive tractor) but holds enough cards, the scenario is classified as `enough_remaining`. -2. The prompt displays a flat list of all available cards in the suit: - ``` - - Scenario: enough_remaining - - You must still play 4 card(s) from this suit. Available cards: - 3♦, 3♦, 8♦, 8♦, 2♣, 4♦ - ``` -3. Because the cards are presented as a flat pool, the LLM treats it as an unconstrained set. It splits pairs into singles (such as `bot2` playing a single `Q♦` and a single `J♦` instead of keeping its `Q♦` pair intact) because nothing in the active trick context explicitly warns it that splitting pairs is illegal. - -While a human or the rule-based AI immediately understands the structural constraints, smaller LLMs lack the situational counting capacity to infer these rules from a flat list. - ---- - -## 3. Proposed Solution: Programmatic Pair-Constraint Injection - -Instead of expecting the LLM to count duplicates and calculate structural requirements, we can solve this programmatically in code. The rule-based engine already classifies the lead. We can identify what pairs the player holds in the led suit and inject a direct, explicit directive into the prompt. - -### A. Code Changes in Prompt Builder (`llmGamePrompt.ts`) - -1. **Add Pair-Detection Helper**: - Implement a local helper `localFindPairsInCards(cards: Card[]): Card[][]` to group identical cards in a card list and identify pairs. - -2. **Inject Critical Constraints**: - In `localBuildFollowingPromptContext`, when `analysis.scenario === "enough_remaining"` and the led combo is a `Pair` or `Tractor`: - * Scan `analysis.remainingCards` for pairs. - * If any pairs are held, format and append a strict `CRITICAL CONSTRAINT` directive. - -#### Example Directive for `bot1` (held two pairs: `3♦, 3♦` and `8♦, 8♦`): -```markdown -- Led combo type: Tractor (4 cards) -- Led suit/group: Diamonds -- Your cards in that suit: 6 -- Scenario: enough_remaining -- You have enough cards in the led suit but NO matching Tractor combos. -- You must still play 4 card(s) from this suit. Available cards: - 3♦, 3♦, 8♦, 8♦, 2♣, 4♦ -- CRITICAL CONSTRAINT: You hold 2 pair(s) in this suit: [3♦, 3♦] and [8♦, 8♦]. Because a Tractor was led, you MUST match the led pair structure. You are required to play BOTH of these pairs in your selection! Do NOT split them into singles. -``` - -#### Example Directive for `bot2` (held one pair: `Q♦, Q♦`): -```markdown -- CRITICAL CONSTRAINT: You hold 1 pair(s) in this suit: [Q♦, Q♦]. Because a Tractor was led, you MUST match the led pair structure. You are required to play this pair in your selection! Do NOT split it into singles. -``` - ---- - -## 4. Token & Performance Impact - -* **Token Overhead**: Negligible (adds $\sim 40\text{--}70$ tokens only when the player holds pairs under a Pair/Tractor lead in `enough_remaining` scenarios). -* **Execution Overhead**: Zero (pair scanning on a maximum of 25 cards takes micro-seconds). -* **Benefits**: - * Eliminates $100\%$ of formatting and structural following violations during Pair/Tractor leads. - * Drives the Attempt-1 success rate closer to $100\%$, saving OpenRouter API costs and reducing latency. - ---- - -## 5. Next Steps - -1. **Approval**: Align on this proposal. -2. **Implementation**: Edit `llmGamePrompt.ts` to implement the pair-constraint logic. -3. **Prompt Refinement**: Refine `# 4. Following — fixed rules` in `llmPromptTemplates.ts` to reinforce the pair-preservation rule. -4. **Verification**: Run a full LLM simulation game and verify that all bots follow `Pair` and `Tractor` leads successfully on their very first attempt. diff --git a/src/ai/llm/llmAIStrategy.ts b/src/ai/llm/llmAIStrategy.ts index 9abecf0..15ad0d1 100644 --- a/src/ai/llm/llmAIStrategy.ts +++ b/src/ai/llm/llmAIStrategy.ts @@ -232,7 +232,7 @@ export async function callLLMForDecision( }); errorHint = - 'Some cards you selected are not in your hand. Select only cards shown in YOUR HAND, using their exact notation (e.g. "3♣", "10♥", "BJ").'; + 'Some cards you selected are not in your hand. Select only cards shown in YOUR HAND, using their exact notation (e.g. "3♣", "10♥", "BJ"). To play a pair you must hold two copies of that card (shown ×2) — do not repeat a card you hold only once.'; llmInvalidCardRetries++; continue; } diff --git a/src/ai/llm/llmGamePrompt.ts b/src/ai/llm/llmGamePrompt.ts index e9b3d20..d28d992 100644 --- a/src/ai/llm/llmGamePrompt.ts +++ b/src/ai/llm/llmGamePrompt.ts @@ -8,17 +8,13 @@ import { TrumpInfo, Suit, Rank, - ComboType, - JokerType, } from "../../types"; -import { isComboUnbeatable } from "../../game/multiComboValidation"; -import { compareCards } from "../../game/cardComparison"; import { sortCards } from "../../utils/cardSorting"; -import { analyzeSuitAvailability } from "../following/suitAvailabilityAnalysis"; -import { detectCandidateLeads } from "../leading/candidateLeadDetection"; -import { collectLeadingContext } from "../leading/leadingContext"; -import { scoreNonTrumpLead, scoreTrumpLead } from "../leading/leadingScoring"; import { createGameContext } from "../aiGameContext"; +import { + buildFollowingOptions, + buildLeadingOptions, +} from "./llmPositionDiagnosis"; import { STATIC_LLM_GAME_RULES, buildUserPromptTemplate, @@ -105,265 +101,39 @@ function localBuildHandDisplay( } /** - * Helper to build trick context and scoring candidates when leading. - */ -function localBuildLeadingPromptContext( - handCards: Card[], - gameState: GameState, - playerId: PlayerId, - trumpInfo: TrumpInfo, - cardToDisplay: (card: Card) => string, -): { - activeTrickStatusStr: string; - taskInstructionStr: string; - candidateOptionsStr: string; -} { - const activeTrickStatusStr = `- Status: You are leading this trick! -- Requirement: You must play exactly ONE valid combination from your hand (Single, Pair, Tractor, or unbeatable same-suit Multi-Combo). Mismatched combination types are strictly illegal.`; - const taskInstructionStr = - "Select exactly ONE valid combination of cards from your hand (Single, Pair, Tractor, or unbeatable same-suit Multi-Combo) to lead the trick."; - - const candidates = detectCandidateLeads( - handCards, - gameState, - playerId, - trumpInfo, - ); - const context = collectLeadingContext(gameState, playerId); - const nonTrumpCandidates = candidates - .filter((candidate) => !candidate.metadata.isTrump) - .map((candidate) => ({ - candidate, - result: scoreNonTrumpLead(candidate, trumpInfo, context), - })) - .sort((a, b) => b.result.score - a.result.score); - - const trumpCandidates = candidates - .filter((candidate) => candidate.metadata.isTrump) - .map((candidate) => ({ - candidate, - result: scoreTrumpLead(candidate, trumpInfo, context), - })) - .sort((a, b) => b.result.score - a.result.score); - - const scoredCandidates = [...nonTrumpCandidates, ...trumpCandidates].sort( - (a, b) => b.result.score - a.result.score, - ); - - const allOptions = scoredCandidates - .map((entry, idx) => { - const cardsStr = entry.candidate.cards.map(cardToDisplay).join(", "); - return `- Option L${idx + 1}: Play [${cardsStr}] (Rule Score: ${entry.result.score})`; - }) - .join("\n"); - - const candidateOptionsStr = `Here are the candidate combinations you can lead, along with a strategic rating from the rule-based engine: -${allOptions || "- No candidates found (using fallback)"} -`; - - return { - activeTrickStatusStr, - taskInstructionStr, - candidateOptionsStr, - }; -} - -/** - * Renders a short, situation-specific "how to play this seat" bullet for the - * following path. STATIC_LLM_GAME_RULES §5/§6 state the general principles; - * this picks the one that actually applies to THIS seat, names the concrete - * players, and spells out the beat-back inference a small model would otherwise - * have to derive on its own. It scaffolds the LLM's judgement among the legal - * options — it does not choose the card. + * Builds the raw trick-state facts for the following path: who led, the plays so + * far, your seat, who is still to act, who currently holds it, and the points at + * stake. Consequences of each legal play live in the `## Your Options` block. */ -function localBuildSeatGuidance(g: { - isLast: boolean; - isTeammateWinning: boolean; - teammateWinSafe: boolean; - canBeatWinnerInSuit: boolean; - winningPlayerId: string; - winningCardStr: string; - oppListStr: string; - trickPoints: number; - isTrumpLead: boolean; - isAnyOpponentVoid: boolean; - isVoidScenario: boolean; -}): string { - let bullet: string; - - if (g.isVoidScenario) { - // Void in the led suit → ruff or sluff (§6). - if (g.isTeammateWinning && g.teammateWinSafe) { - bullet = `${g.winningPlayerId} (teammate) is winning safely — don't ruff over them; sluff a spare point card (10/K) to bank points for your team, else your lowest off-suit non-point.`; - } else if (g.isTeammateWinning) { - bullet = `${g.winningPlayerId} (teammate) leads but it isn't safe — sluff a low off-suit non-point; don't ruff over your own teammate.`; - } else if (g.trickPoints >= 10) { - bullet = `${g.winningPlayerId} (opponent) holds ${g.trickPoints} pts — their card is only takeable by ruffing, so ruff to capture if you can survive ${g.isLast ? "the rest (you're last)" : g.oppListStr} (size it to top a later void player); can't secure it → sluff your lowest off-suit NON-point, never a 5/10/K into their trick.`; - } else { - bullet = `Only ${g.trickPoints} pts and ${g.winningPlayerId} (opponent) leads — sluff your lowest off-suit non-point and conserve trump.`; - } - } else if (g.isTeammateWinning) { - // Following the led suit, teammate currently winning (§5.1 / §5.2). - bullet = g.teammateWinSafe - ? `${g.winningPlayerId} (teammate)'s win is safe — bank your biggest spare points, giving 10s and Ks freely (card rank is moot once the win is locked); hold back only a live boss A/K you can cash on your own trick, and never out-rank your teammate.` - : `${g.winningPlayerId} (teammate) leads but ${g.oppListStr} can still steal it — play a low non-point card of the led suit; don't commit points yet.`; - } else if (g.isLast) { - // Opponent winning, you act last with full info (§5.3 / §9 4th). - bullet = `You play last with full info — beat ${g.winningPlayerId}'s ${g.winningCardStr} with your cheapest sufficient card if you can; otherwise dump your lowest non-point (never a 5/10/K into an opponent's trick).`; - } else if (g.trickPoints >= 10) { - // Opponent winning, rich trick, players still behind you (§5.3). - if (!g.canBeatWinnerInSuit) { - bullet = `${g.trickPoints} pts at stake but you hold nothing that beats ${g.winningPlayerId}'s ${g.winningCardStr} here — duck low and keep your points/high cards for a trick you can win.`; - } else { - const caveat = g.isTrumpLead - ? "a regular trump K/10 loses to active ranks/jokers, so commit only a truly unbeatable trump — else duck low" - : g.isAnyOpponentVoid - ? "a void opponent can ruff, so even the suit boss may be cut — weigh ducking" - : "only the suit boss survives the players behind you; a mid card can be over-taken — else duck"; - bullet = `${g.trickPoints} pts at stake and ${g.oppListStr} act after you — fight only with a card they can't beat back: ${caveat}.`; - } - } else { - // Opponent winning, thin trick (§5.4). - bullet = `Only ${g.trickPoints} pts and ${g.oppListStr} still to act — duck low and conserve; don't spend a boss or trump on a thin trick.`; - } - - return `- ${bullet}`; -} - -/** - * Helper to build trick context and scenario analysis when following. - */ -function localBuildFollowingPromptContext( - handCards: Card[], +function localBuildActiveTrickStatus( gameState: GameState, playerId: PlayerId, - trumpInfo: TrumpInfo, - cardToDisplay: (card: Card) => string, gameContext: GameContext, - voids: Record, -): { - activeTrickStatusStr: string; - taskInstructionStr: string; - suitAnalysisStr: string; - seatGuidanceStr: string; -} { +): { activeTrickStatusStr: string; taskInstructionStr: string } { const currentTrick = gameState.currentTrick; - const trickWinner = gameContext.trickWinnerAnalysis; - if (!currentTrick || currentTrick.plays.length === 0 || !trickWinner) { - return { - activeTrickStatusStr: "", - taskInstructionStr: "", - suitAnalysisStr: "", - seatGuidanceStr: "", - }; + const winnerAnalysis = gameContext.trickWinnerAnalysis; + if (!currentTrick || currentTrick.plays.length === 0 || !winnerAnalysis) { + return { activeTrickStatusStr: "", taskInstructionStr: "" }; } const plays = currentTrick.plays; const leadPlay = plays[0]; const requiredCount = leadPlay.cards.length; - const winningPlayerId = trickWinner.currentWinner; + const winnerId = winnerAnalysis.currentWinner; const partnerId = getPartnerId(playerId); - const isTeammateWinning = trickWinner.isTeammateWinning; - const trickPoints = trickWinner.trickPoints; - const leadingCardsStr = leadPlay.cards.map((c) => c.toString()).join(", "); const playsStr = plays .map( (p) => - `- ${p.playerId} played: ${p.cards.map((c) => c.toString()).join(", ")}${p.playerId === winningPlayerId ? " ⭐ (CURRENT LEADING PLAY)" : ""}`, + `- ${p.playerId} played: ${p.cards.map((c) => c.toString()).join(", ")}${p.playerId === winnerId ? " ⭐ (currently winning)" : ""}`, ) .join("\n"); - // Determine who is left to act in this trick - const playedPlayerIds = plays.map((p) => p.playerId); + const playedIds = plays.map((p) => p.playerId); const yetToPlay = gameState.players .map((p) => p.id) - .filter((id) => id !== playerId && !playedPlayerIds.includes(id)); - - const remainingOpponents = yetToPlay.filter((id) => id !== partnerId); - - // Confirmed voids come from the engine's MemoryContext (passed in). - const leadCard = leadPlay.cards[0]; - const isTrumpLead = trickWinner.isTrumpLead; - const ledSuit = isTrumpLead ? "Trump Group" : leadCard?.suit || ""; - const isAnyOpponentVoid = remainingOpponents.some((oppId) => { - const oppVoids = voids[oppId] || []; - return oppVoids.includes(ledSuit); - }); - - const winningPlay = plays.find((p) => p.playerId === winningPlayerId); - const winningCard = winningPlay?.cards[0] || null; - const winningCardStr = winningCard ? winningCard.toString() : "their card"; - - // "Boss" is an OFF-SUIT notion only: the highest card still live in a side - // suit, beatable only by a ruff. Memory-aware via isComboUnbeatable (fed the - // engine's played-card memory) — a K becomes boss once both Aces are gone. - // Only meaningful for teammate-win safety below, so skip it otherwise. - const winnerOffSuitBoss = - isTeammateWinning && - !!winningCard && - !winningCard.isTrump(trumpInfo) && - isComboUnbeatable( - { type: ComboType.Single, cards: [winningCard], value: 0 }, - winningCard.suit, - gameContext.memoryContext.playedCards, - handCards, - trumpInfo, - [], - ); - - // The trump group has no "boss"; only the Big Joker is guaranteed unbeatable - // (conservative — other high trumps are covered by the all-void check below). - const winnerTopTrump = !!winningCard && winningCard.joker === JokerType.Big; - - // Can THIS player beat the current winner with a same-group card in hand? - const canBeatWinnerInSuit = - !!winningCard && - handCards.some((c) => { - const sameGroup = winningCard.isTrump(trumpInfo) - ? c.isTrump(trumpInfo) - : !c.isTrump(trumpInfo) && c.suit === winningCard.suit; - return sameGroup && compareCards(c, winningCard, trumpInfo) > 0; - }); - - // All remaining opponents are confirmed void in the led suit/trump group. - const allRemainingOpponentsVoidLed = - remainingOpponents.length > 0 && - remainingOpponents.every((oppId) => (voids[oppId] || []).includes(ledSuit)); - - // Teammate's win is "safe" when no opponents remain; their card is an off-suit - // boss the remaining (non-void) opponents can't top; they hold the top trump; - // or it is a trump trick and every remaining opponent is out of trump. An - // opponent void in an OFF-suit lead is NOT safe — they can ruff. - const teammateWinSafe = - isTeammateWinning && - (remainingOpponents.length === 0 || - (winnerOffSuitBoss && !isAnyOpponentVoid) || - winnerTopTrump || - (isTrumpLead && allRemainingOpponentsVoidLed)); - - const oppListStr = remainingOpponents.join(" and "); - let winSecurityStr = ""; - if (isTeammateWinning) { - if (remainingOpponents.length === 0) { - winSecurityStr = `SECURED WIN: Your teammate (${winningPlayerId}) is winning, and there are NO opponents left to act. Your team is guaranteed to win this trick.`; - } else if (teammateWinSafe) { - const why = - winnerOffSuitBoss && !isAnyOpponentVoid - ? `with a card (${winningCardStr}) no remaining opponent can beat` - : winnerTopTrump - ? `with the top trump (${winningCardStr})` - : `a trump trick while the remaining opponents are out of trump`; - winSecurityStr = `LIKELY WIN: Your teammate (${winningPlayerId}) is winning ${why}. They are extremely likely to win this trick.`; - } else { - winSecurityStr = `UNCERTAIN: Your teammate (${winningPlayerId}) is winning, but opponent(s) [${oppListStr}] have yet to play — the card is beatable or an opponent may be void / hold higher, so the outcome is uncertain.`; - } - } else { - winSecurityStr = `UNCERTAIN: Opponent (${winningPlayerId}) is currently winning the trick.`; - } - - const isLast = yetToPlay.length === 0; + .filter((id) => id !== playerId && !playedIds.includes(id)); const seatLabel = ["1st (leader)", "2nd", "3rd", "4th"][plays.length] || `${plays.length + 1}th`; @@ -374,100 +144,19 @@ function localBuildFollowingPromptContext( .join(", ") : "none — you play last"; - const statusLines = [ + const activeTrickStatusStr = [ `- Led by: ${leadPlay.playerId} playing [${leadingCardsStr}]`, - `- Requirement: You must play exactly ${requiredCount} card(s). You must follow the led suit/trump group if you have any.`, + `- Requirement: play exactly ${requiredCount} card(s); follow the led suit/trump group if you hold any.`, `\nPlays in this trick so far:`, playsStr, `\n- Your seat: ${seatLabel} of 4; still to act after you: ${yetToPlayStr}`, - `- Current Leading Player: ${winningPlayerId} (Teammate: ${isTeammateWinning ? "YES" : "NO"})`, - `- Current Points in Trick: ${trickPoints} pts`, - `- Trick Win Security: ${winSecurityStr}`, - ]; - const activeTrickStatusStr = statusLines.join("\n"); - const taskInstructionStr = `Select exactly ${requiredCount} card(s) from your hand following the trick requirement and suit following rules.`; - - // Analyze suit following using the same engine as algorithmic AI - const analysis = analyzeSuitAvailability( - leadPlay.cards, - handCards, - trumpInfo, - ); - - const ledSuitDisplay = - analysis.evaluateSuit === Suit.None ? "Trump Group" : analysis.evaluateSuit; - const lines: string[] = [ - `- Led combo type: ${analysis.leadingComboType} (${analysis.requiredLength} cards)`, - `- Led suit/group: ${ledSuitDisplay}`, - `- Your cards in that suit: ${analysis.availableCount}`, - `- Scenario: ${analysis.scenario}`, - ]; - - switch (analysis.scenario) { - case "valid_combos": { - lines.push( - `- You have matching ${analysis.leadingComboType} combos to choose from:`, - ); - for (const combo of analysis.validCombos) { - const ids = combo.cards.map(cardToDisplay).join(", "); - lines.push(` • ${combo.type}: [${ids}]`); - } - break; - } - case "enough_remaining": { - lines.push( - `- You have enough cards in the led suit but NO matching ${analysis.leadingComboType} combos.`, - ); - lines.push( - `- You must still play ${analysis.requiredLength} card(s) from this suit. Available cards:`, - ); - lines.push( - ` ${analysis.remainingCards.map(cardToDisplay).join(", ")}`, - ); - break; - } - case "insufficient": { - lines.push( - `- You only have ${analysis.availableCount} card(s) but ${analysis.requiredLength} are required.`, - ); - lines.push( - `- You MUST play ALL your cards in that suit: ${analysis.remainingCards.map(cardToDisplay).join(", ")}`, - ); - lines.push( - `- Fill the remaining ${analysis.requiredLength - analysis.availableCount} slot(s) from other suits (discard or trump).`, - ); - break; - } - case "void": { - lines.push( - `- You are VOID in the led suit. You may trump (ruff) with trump cards or discard (sluff) from any suit.`, - ); - break; - } - } - - const suitAnalysisStr = lines.join("\n") + "\n"; + `- Currently winning: ${winnerId} (${winnerAnalysis.isTeammateWinning ? "your teammate" : "opponent"})`, + `- Points in this trick: ${winnerAnalysis.trickPoints} pts`, + ].join("\n"); - const seatGuidanceStr = localBuildSeatGuidance({ - isLast, - isTeammateWinning, - teammateWinSafe, - canBeatWinnerInSuit, - winningPlayerId, - winningCardStr, - oppListStr, - trickPoints, - isTrumpLead, - isAnyOpponentVoid, - isVoidScenario: analysis.scenario === "void", - }); + const taskInstructionStr = `Select exactly ${requiredCount} card(s) from your hand following the trick requirement and suit-following rules.`; - return { - activeTrickStatusStr, - taskInstructionStr, - suitAnalysisStr, - seatGuidanceStr, - }; + return { activeTrickStatusStr, taskInstructionStr }; } /** @@ -561,9 +250,6 @@ export function buildLLMUserPrompt( // Build the hand display const handChoicesStr = localBuildHandDisplay(sortedHand, trumpInfo); - // Render a card as the plain notation the LLM also replies with - const cardToDisplay = (card: Card): string => card.toString(); - // Build the engine's game context once — single source for trick-winner // analysis and per-player void memory (shared with the rule-based AI). const gameContext = createGameContext(gameState, playerId); @@ -575,35 +261,33 @@ export function buildLLMUserPrompt( let activeTrickStatusStr = ""; let taskInstructionStr = ""; - let candidateOptionsStr = ""; - let suitAnalysisStr = ""; - let seatGuidanceStr = ""; + let optionsStr = ""; if (isLeading) { - const context = localBuildLeadingPromptContext( - handCards, + optionsStr = buildLeadingOptions( gameState, playerId, + handCards, trumpInfo, - cardToDisplay, + gameContext, ); - activeTrickStatusStr = context.activeTrickStatusStr; - taskInstructionStr = context.taskInstructionStr; - candidateOptionsStr = context.candidateOptionsStr; + taskInstructionStr = + "Select exactly ONE valid combination of cards from your hand (Single, Pair, Tractor, or unbeatable same-suit Multi-Combo) to lead the trick."; } else { - const context = localBuildFollowingPromptContext( - handCards, + const status = localBuildActiveTrickStatus( gameState, playerId, - trumpInfo, - cardToDisplay, + gameContext, + ); + activeTrickStatusStr = status.activeTrickStatusStr; + taskInstructionStr = status.taskInstructionStr; + optionsStr = buildFollowingOptions( + gameState, + playerId, + handCards, gameContext, voids, ); - activeTrickStatusStr = context.activeTrickStatusStr; - taskInstructionStr = context.taskInstructionStr; - suitAnalysisStr = context.suitAnalysisStr; - seatGuidanceStr = context.seatGuidanceStr; } // Reconstruct round tricks history (limit to last 3 tricks to keep prompt light) @@ -633,9 +317,7 @@ export function buildLLMUserPrompt( activeTrickStatusStr, handChoicesStr, isLeading, - candidateOptionsStr, - suitAnalysisStr, - seatGuidanceStr, + optionsStr, taskInstructionStr, }); diff --git a/src/ai/llm/llmPositionDiagnosis.ts b/src/ai/llm/llmPositionDiagnosis.ts new file mode 100644 index 0000000..2a187b6 --- /dev/null +++ b/src/ai/llm/llmPositionDiagnosis.ts @@ -0,0 +1,571 @@ +import { + Card, + Combo, + ComboType, + GameContext, + GameState, + getPartnerId, + JokerType, + PlayerId, + Suit, + TrumpInfo, +} from "../../types"; +import { + calculateCardStrategicValue, + isBiggestInSuit, +} from "../../game/cardValue"; +import { + canBeatCombo, + compareCards, + getCurrentWinningCombo, +} from "../../game/cardComparison"; +import { isComboUnbeatable } from "../../game/multiComboValidation"; +import { getRemainingUnseenCards } from "../aiGameContext"; +import { analyzeSuitAvailability } from "../following/suitAvailabilityAnalysis"; +import { + CandidateLead, + detectCandidateLeads, +} from "../leading/candidateLeadDetection"; + +/** + * Position Diagnosis — facts and consequences, never recommendations. + * + * This module is the "analyst" half of the LLM bot: the TypeScript engine does + * the cognition a small model is bad at (counting unseen cards, lookahead, + * structure extraction, over-ruff survival) and lays out, for each legal play, + * what it would *cost and yield in POINTS* — then stops. It never ranks, scores, + * stars, or picks a card; the LLM makes the strategic call from these facts. + * + * Two disciplines hold this neutral: + * 1. Consequences are stated in point-flow (capture / concede / bank / protect / + * control), because the round is won on points, not tricks. + * 2. Equivalent plays are collapsed into one class (using unseen-card knowledge), + * and graded choices (how big to ruff, point-card or not) are spelled out — + * but every option gets the same neutral, symmetric framing. + */ + +const comboLabel = (cards: Card[]): string => + cards.map((c) => c.toString()).join(" "); + +/** A play label, bracketed when it is a multi-card combo so the unit reads clearly. */ +const playLabel = (cards: Card[]): string => + cards.length > 1 ? `[${comboLabel(cards)}]` : comboLabel(cards); + +const listLabel = (cards: Card[]): string => + cards.map((c) => c.toString()).join(", "); + +const sumPoints = (cards: Card[]): number => + cards.reduce((sum, c) => sum + c.points, 0); + +/** Number of pairs sitting inside a flat set of cards (by card identity). */ +function countHeldPairs(cards: Card[]): number { + const counts = new Map(); + for (const c of cards) + counts.set(c.commonId, (counts.get(c.commonId) ?? 0) + 1); + let pairs = 0; + for (const n of counts.values()) pairs += Math.floor(n / 2); + return pairs; +} + +/** State the outcome of taking the trick now in points — winning an empty trick + * is worth only tempo, so it is not dressed up as a gain. */ +function winYield(trickPoints: number): string { + return trickPoints > 0 + ? `wins the trick → captures ${trickPoints} pts for your team` + : `takes the trick, but it holds 0 pts → gains only the next lead`; +} + +// --------------------------------------------------------------------------- +// Following diagnosis +// --------------------------------------------------------------------------- + +/** + * Build the `## Your Options` content for the following path: the legal plays + * grouped into strategically-distinct classes, each with its point consequence. + * Returns the inner markdown (no header); empty string when there is no trick. + */ +export function buildFollowingOptions( + gameState: GameState, + playerId: PlayerId, + hand: Card[], + gameContext: GameContext, + voids: Record, +): string { + const trumpInfo = gameState.trumpInfo; + const currentTrick = gameState.currentTrick; + const winnerAnalysis = gameContext.trickWinnerAnalysis; + if (!currentTrick || currentTrick.plays.length === 0 || !winnerAnalysis) { + return ""; + } + + const plays = currentTrick.plays; + const leadCards = plays[0].cards; + const winningCombo = getCurrentWinningCombo(currentTrick); + const winningCard = winningCombo[0]; + const winnerId = winnerAnalysis.currentWinner; + const { isTeammateWinning, isTrumpLead, trickPoints } = winnerAnalysis; + const partnerId = getPartnerId(playerId); + + const playedIds = plays.map((p) => p.playerId); + const yetToPlay = gameState.players + .map((p) => p.id) + .filter((id) => id !== playerId && !playedIds.includes(id)); + const remainingOpponents = yetToPlay.filter((id) => id !== partnerId); + const ledSuit = isTrumpLead ? "Trump Group" : leadCards[0].suit; + const isAnyOpponentVoidLed = remainingOpponents.some((id) => + (voids[id] || []).includes(ledSuit), + ); + + const teammateWinSafe = computeTeammateWinSafe({ + isTeammateWinning, + isTrumpLead, + winningCard, + remainingOpponents, + isAnyOpponentVoidLed, + ledSuit, + voids, + hand, + gameContext, + trumpInfo, + }); + + // How a non-winning play moves points, anchored to the 80 threshold so the + // direction of a concession is unambiguous for either role: only the attacking + // team's total counts toward 80, so points an opponent wins either build the + // attackers' total (if you defend) or are lost from yours (if you attack). + const isAttacking = gameContext.isAttackingTeam; + const concedeNote = (points: number): string => { + if (points === 0) { + return isTeammateWinning && teammateWinSafe + ? `safe filler — your team keeps the trick` + : `loses; concedes nothing of yours`; + } + if (isTeammateWinning && teammateWinSafe) { + return isAttacking + ? `banks ${points} pts toward your team's 80` + : `banks ${points} pts for your team — denied from the attackers`; + } + if (isTeammateWinning) { + return `adds ${points} pts, but ${remainingOpponents.join("/")} can still take the trick`; + } + return isAttacking + ? `gives the defenders ${points} pts — lost from your 80` + : `adds ${points} pts to the attackers' total (toward their 80)`; + }; + + const analysis = analyzeSuitAvailability(leadCards, hand, trumpInfo); + const lines: string[] = [ + `Play exactly ${analysis.requiredLength} card(s). Copy cards verbatim from YOUR HAND — to repeat a card (a pair) you must hold two copies of it (shown ×2).`, + ]; + + switch (analysis.scenario) { + case "valid_combos": { + // You hold combos matching the led structure — those are your legal plays. + const winners = analysis.validCombos.filter((c) => + canBeatCombo(c.cards, winningCombo, trumpInfo), + ); + const losers = analysis.validCombos.filter( + (c) => !canBeatCombo(c.cards, winningCombo, trumpInfo), + ); + for (const combo of sortCombosAsc(winners, trumpInfo)) { + lines.push( + `- ${playLabel(combo.cards)} → ${winYield(trickPoints)}${pointCost(combo.cards)}`, + ); + } + lines.push(...renderLosers(losers, concedeNote, trumpInfo)); + break; + } + + case "enough_remaining": { + // Cards in suit but cannot match the led structure → you cannot win this + // trick; the only choice is which cards to give up. + lines.push( + `- You hold ${analysis.availableCount} ${suitName(ledSuit)} card(s) but no matching ${analysis.leadingComboType.toLowerCase()} — you cannot beat ${winnerId}. Play ${analysis.requiredLength} from this suit:`, + ); + const heldPairs = countHeldPairs(analysis.remainingCards); + if ( + (analysis.leadingComboType === ComboType.Pair || + analysis.leadingComboType === ComboType.Tractor) && + heldPairs > 0 + ) { + const requiredPairs = Math.min(heldPairs, analysis.requiredLength / 2); + lines.push( + ` FORCED: you hold ${heldPairs} pair(s) here — you must keep ${requiredPairs} of them intact (cannot split a pair while you hold one).`, + ); + } + lines.push( + ...renderDisposalClasses( + analysis.remainingCards, + concedeNote, + trumpInfo, + ), + ); + break; + } + + case "insufficient": { + // Must play every suit card you hold, then fill the rest from elsewhere. + lines.push( + `- Only ${analysis.availableCount} ${suitName(ledSuit)} card(s), ${analysis.requiredLength} required — you cannot win. FORCED to play all of: ${listLabel(analysis.remainingCards)}.`, + ); + const fillCount = analysis.requiredLength - analysis.availableCount; + lines.push( + ` Fill the remaining ${fillCount} from any suit (trump or off-suit). ${concedeNote(0)} via the fill choice:`, + ); + const fillPool = hand.filter( + (c) => !analysis.remainingCards.some((r) => r.id === c.id), + ); + lines.push(...renderDisposalClasses(fillPool, concedeNote, trumpInfo)); + break; + } + + case "void": { + lines.push( + ...renderVoidOptions({ + gameState, + leadCards, + winningCombo, + winnerId, + trickPoints, + isTeammateWinning, + teammateWinSafe, + remainingOpponents, + isAnyOpponentVoidLed, + ledSuit, + hand, + trumpInfo, + concedeNote, + }), + ); + break; + } + } + + return lines.join("\n"); +} + +interface TeammateSafeArgs { + isTeammateWinning: boolean; + isTrumpLead: boolean; + winningCard: Card | undefined; + remainingOpponents: PlayerId[]; + isAnyOpponentVoidLed: boolean; + ledSuit: string; + voids: Record; + hand: Card[]; + gameContext: GameContext; + trumpInfo: TrumpInfo; +} + +/** + * A teammate's win is "safe" when no remaining opponent can take it: none left + * to act, an off-suit boss no live card beats (and no opponent can ruff), the + * top trump, or a trump trick with every remaining opponent out of trump. + */ +function computeTeammateWinSafe(a: TeammateSafeArgs): boolean { + const winningCard = a.winningCard; + if (!a.isTeammateWinning || !winningCard) return false; + if (a.remainingOpponents.length === 0) return true; + + const winnerOffSuitBoss = + !winningCard.isTrump(a.trumpInfo) && + isComboUnbeatable( + { type: ComboType.Single, cards: [winningCard], value: 0 }, + winningCard.suit, + a.gameContext.memoryContext.playedCards, + a.hand, + a.trumpInfo, + [], + ); + if (winnerOffSuitBoss && !a.isAnyOpponentVoidLed) return true; + + if (winningCard.joker === JokerType.Big) return true; + + const allRemainingOpponentsVoidLed = a.remainingOpponents.every((id) => + (a.voids[id] || []).includes(a.ledSuit), + ); + return a.isTrumpLead && allRemainingOpponentsVoidLed; +} + +interface VoidArgs { + gameState: GameState; + leadCards: Card[]; + winningCombo: Card[]; + winnerId: PlayerId; + trickPoints: number; + isTeammateWinning: boolean; + teammateWinSafe: boolean; + remainingOpponents: PlayerId[]; + isAnyOpponentVoidLed: boolean; + ledSuit: string; + hand: Card[]; + trumpInfo: TrumpInfo; + concedeNote: (points: number) => string; +} + +/** Void in the led suit: lay out ruff-to-win grades and sluff classes. */ +function renderVoidOptions(a: VoidArgs): string[] { + const lines: string[] = []; + + // Ruff options that actually win: trump combos matching the led structure + // that beat the current winner. (analyzeSuitAvailability on the trump group + // returns exactly the trump combos of the led type.) + const trumpAnalysis = analyzeSuitAvailability( + a.leadCards, + a.hand, + a.trumpInfo, + Suit.None, + ); + const ruffWinners = trumpAnalysis.validCombos.filter((c) => + canBeatCombo(c.cards, a.winningCombo, a.trumpInfo), + ); + + const overRuffRisk = + a.isAnyOpponentVoidLed && a.remainingOpponents.length > 0 + ? ` (caution: ${a.remainingOpponents.join("/")} is void in ${suitName(a.ledSuit)} and could over-ruff a low trump)` + : ""; + + if (ruffWinners.length > 0 && !a.isTeammateWinning) { + for (const combo of sortCombosAsc(ruffWinners, a.trumpInfo)) { + const pts = sumPoints(combo.cards); + const costNote = + pts > 0 + ? `; spends a ${pts}-pt trump` + : `; spends trump ${playLabel(combo.cards)}`; + lines.push( + `- ruff with ${playLabel(combo.cards)} → ${winYield(a.trickPoints)}${costNote}${overRuffRisk}`, + ); + } + } else if (!a.isTeammateWinning) { + lines.push( + `- No trump you hold beats ${a.winnerId} — ruffing only spends trump for nothing.`, + ); + } + + // Sluff: discard off-suit (non-trump) cards, conceding the trick. + const offSuit = a.hand.filter((c) => !c.isTrump(a.trumpInfo)); + if (offSuit.length > 0) { + if (a.isTeammateWinning && a.teammateWinSafe) { + lines.push( + `- ${a.winnerId} (teammate) is winning safely — points you sluff are banked for your team:`, + ); + } else if (a.isTeammateWinning) { + lines.push( + `- ${a.winnerId} (teammate) leads but it isn't locked — sluffing:`, + ); + } else { + lines.push(`- Sluff off-suit (concede the trick, keep your trump):`); + } + lines.push( + ...renderDisposalClasses(offSuit, a.concedeNote, a.trumpInfo, " "), + ); + } + + return lines; +} + +/** + * Collapse a set of losing combos into classes: non-point combos share one line + * (they are interchangeable), point-bearing combos each get their own (the points + * conceded differ). Winners are handled by the caller. + */ +function renderLosers( + losers: Combo[], + concedeNote: (points: number) => string, + trumpInfo: TrumpInfo, +): string[] { + if (losers.length === 0) return []; + const lines: string[] = []; + const nonPoint = losers.filter((c) => sumPoints(c.cards) === 0); + const pointBearing = losers.filter((c) => sumPoints(c.cards) > 0); + + if (nonPoint.length > 0) { + const labels = sortCombosAsc(nonPoint, trumpInfo) + .map((c) => playLabel(c.cards)) + .join(" · "); + lines.push(`- ${labels} → ${concedeNote(0)}`); + } + for (const combo of sortCombosAsc(pointBearing, trumpInfo)) { + lines.push( + `- ${playLabel(combo.cards)} → loses; ${concedeNote(sumPoints(combo.cards))}`, + ); + } + return lines; +} + +/** + * Collapse a flat pool of disposable cards into point-flow classes: low non-point + * cards (interchangeable, one line) and each point card (distinct consequence). + */ +function renderDisposalClasses( + cards: Card[], + concedeNote: (points: number) => string, + trumpInfo: TrumpInfo, + indent = "", +): string[] { + if (cards.length === 0) return []; + const lines: string[] = []; + const nonPoint = cards.filter((c) => c.points === 0); + const pointCards = cards.filter((c) => c.points > 0); + + if (nonPoint.length > 0) { + const sorted = [...nonPoint].sort( + (x, y) => + calculateCardStrategicValue(x, trumpInfo, "basic") - + calculateCardStrategicValue(y, trumpInfo, "basic"), + ); + lines.push( + `${indent}- low cards (${listLabel(sorted)}) → ${concedeNote(0)}`, + ); + } + for (const card of pointCards.sort((x, y) => x.points - y.points)) { + lines.push(`${indent}- ${card.toString()} → ${concedeNote(card.points)}`); + } + return lines; +} + +// --------------------------------------------------------------------------- +// Leading diagnosis +// --------------------------------------------------------------------------- + +/** + * Build the `## Lead Options` content: the legal leads enumerated by structure, + * each with its point/control consequence. No score, no ranking. + */ +export function buildLeadingOptions( + gameState: GameState, + playerId: PlayerId, + hand: Card[], + trumpInfo: TrumpInfo, + gameContext: GameContext, +): string { + const candidates = detectCandidateLeads(hand, gameState, playerId, trumpInfo); + const lines: string[] = []; + + const offSuit = candidates.filter((c) => !c.metadata.isTrump); + const trump = candidates.filter((c) => c.metadata.isTrump); + + // Off-suit, structured (multi-combo / tractor / pair) — highest leverage. + const offStructured = offSuit.filter((c) => c.cards.length > 1); + for (const c of offStructured) { + const kind = + c.type === ComboType.Invalid ? "multi-combo" : c.type.toLowerCase(); + const pts = c.metadata.points > 0 ? `, ${c.metadata.points} pts` : ""; + const fate = c.metadata.isUnbeatable + ? `unbeatable in-suit → wins unless an opponent ruffs; keeps the lead (spends a boss, not trump)` + : `a higher ${c.metadata.suit} combo or a ruff can beat it`; + lines.push(`- ${playLabel(c.cards)} (${kind}${pts}) → ${fate}`); + } + + // Off-suit singles: bosses (likely win) and point cards stand alone; collapse + // the low rubbish per suit into one class. + const offSingles = offSuit.filter((c) => c.cards.length === 1); + const notableSingles = offSingles.filter( + (c) => + c.metadata.isUnbeatable || + isBiggestInSuit(c.cards[0], trumpInfo) || + c.metadata.points > 0, + ); + for (const c of sortCandidatesDesc(notableSingles, trumpInfo)) { + const card = c.cards[0]; + const pts = card.points > 0 ? `, ${card.points} pts` : ""; + const fate = c.metadata.isUnbeatable + ? `unbeatable in-suit → wins unless an opponent ruffs; keeps the lead (spends a boss, not trump)` + : isBiggestInSuit(card, trumpInfo) + ? `suit boss → wins unless ruffed` + : `a higher ${suitName(card.suit)} is still out — may be beaten or ruffed`; + lines.push(`- ${card.toString()} (${suitName(card.suit)}${pts}) → ${fate}`); + } + const rubbishSingles = offSingles.filter((c) => !notableSingles.includes(c)); + if (rubbishSingles.length > 0) { + const cards = rubbishSingles.map((c) => c.cards[0]); + lines.push( + `- low singles (${listLabel(cards)}) → low cards; give up the lead, carry no points`, + ); + } + + // Trump leads stated as facts (cost + what beats them), not as a tactic — a + // trump lead spends control you cannot then ruff with, and a trump-rank/joker + // combo is your scarcest resource. + const trumpStructured = trump.filter((c) => c.cards.length > 1); + for (const c of trumpStructured) { + const kind = + c.type === ComboType.Invalid ? "multi-combo" : c.type.toLowerCase(); + const scarce = + calculateCardStrategicValue(c.cards[0], trumpInfo, "basic") >= 170; + const cost = scarce + ? "spends scarce high trump (jokers/trump-rank)" + : "spends trump — your ruff/control resource"; + lines.push( + `- ${playLabel(c.cards)} (trump ${kind}) → takes the trick + the next lead unless a higher trump ${kind} is out; cost: ${cost}`, + ); + } + const trumpSingles = trump.filter((c) => c.cards.length === 1); + if (trumpSingles.length > 0) { + // A trump single wins the lead only if no higher trump is still unseen in + // another hand — code does that accounting so the model does not have to + // (and does not mistake a high-but-beatable trump like SJ for "the strongest"). + const unseenTrump = getRemainingUnseenCards( + Suit.None, + gameContext, + gameState, + ); + const isBeaten = (card: Card): boolean => + unseenTrump.some((u) => compareCards(u, card, trumpInfo) > 0); + const winners = trumpSingles.filter((c) => !isBeaten(c.cards[0])); + const beaten = trumpSingles.filter((c) => isBeaten(c.cards[0])); + + for (const c of sortCandidatesDesc(winners, trumpInfo)) { + lines.push( + `- ${c.cards[0].toString()} (trump) → no trump still out beats it: leading it takes the trick + the next lead; cost: you spend the trump (cannot keep it to ruff or block the final trick)`, + ); + } + if (beaten.length > 0) { + const cards = beaten + .map((c) => c.cards[0]) + .sort( + (x, y) => + calculateCardStrategicValue(x, trumpInfo, "basic") - + calculateCardStrategicValue(y, trumpInfo, "basic"), + ); + lines.push( + `- trump singles (${listLabel(cards)}) → a higher trump is still out, so these can be beaten; spend trump (ruff/control)`, + ); + } + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function suitName(suit: string): string { + return suit === Suit.None || suit === "Trump Group" ? "trump" : suit; +} + +/** Point cost suffix for a winning play that spends point cards (e.g. a K). */ +function pointCost(cards: Card[]): string { + const pts = sumPoints(cards); + return pts > 0 ? ` (note: this play itself carries ${pts} pts)` : ""; +} + +function sortCombosAsc(combos: Combo[], trumpInfo: TrumpInfo): Combo[] { + return [...combos].sort( + (a, b) => + calculateCardStrategicValue(a.cards[0], trumpInfo, "basic") - + calculateCardStrategicValue(b.cards[0], trumpInfo, "basic"), + ); +} + +function sortCandidatesDesc( + candidates: CandidateLead[], + trumpInfo: TrumpInfo, +): CandidateLead[] { + return [...candidates].sort( + (a, b) => + calculateCardStrategicValue(b.cards[0], trumpInfo, "basic") - + calculateCardStrategicValue(a.cards[0], trumpInfo, "basic"), + ); +} diff --git a/src/ai/llm/llmPromptTemplates.ts b/src/ai/llm/llmPromptTemplates.ts index 76d48d5..fa710de 100644 --- a/src/ai/llm/llmPromptTemplates.ts +++ b/src/ai/llm/llmPromptTemplates.ts @@ -1,39 +1,31 @@ -export const STATIC_LLM_GAME_RULES = `# Shengji / Tractor — Trick-Play Decision Guide +export const STATIC_LLM_GAME_RULES = `# Shengji / Tractor — Game Reference -## 1. Objectives & Points -- **Team Roles**: Attacking vs defending roles are specified in the **## Current State** block (injected in the prompt below). -- **Victory Condition**: Attackers win the round by capturing 80+ points; defenders win by keeping attackers under 80 points. -- **Point Cards**: Points exist only on 5 (5pts), 10 (10pts), and K (10pts) cards. +## 1. Objective (points win the round, not tricks) +- The round is decided by POINTS. Attackers win by capturing 80+ points; defenders win by holding attackers under 80. +- Points exist only on 5 (5 pts), 10 (10 pts), and K (10 pts). A trick matters only for the points it carries and for the lead it hands the winner. +- Your role (attacking/defending) and the running score are in **## Current State**. +- Kitty multiplier: if the attackers win the FINAL trick of the round, the hidden kitty's points are scored back, multiplied by the final lead's structure — single x2, one pair x4, two-pair tractor x8 (2^(pairs+1)). The last trick can swing the round. -## 2. Card Strength (High → Low) -- **Trump Group**: Treated as ONE combined suit: Big Joker > Small Joker > trump-rank in trump suit > trump-rank in other suits (equal; first played wins) > trump regulars (A > K > ... > 3). -- **Active Ranks**: The trump-rank cards in all suits belong to the Trump Group, not their printed suit. They beat any Ace. Protect them and never waste them cheaply. -- **Off-Suit**: The highest unplayed card of a suit (A, or K if A is trump rank) is "boss" — unbeatable unless ruffed. Cross-suit cards cannot beat each other. -- **No-Trump Round**: No trump suit declared. The Trump Group consists ONLY of Jokers and the four active ranks (no other cards are trump). Trump is extremely scarce — hoard it, and off-suit bosses are near-untouchable. +## 2. Card Strength (High -> Low) +- Trump Group (one combined suit): Big Joker > Small Joker > trump-rank in trump suit > trump-rank in other suits (equal; first played wins) > trump-suit regulars (A > K > ... > 3). +- Trump-rank cards in every suit belong to the Trump Group, not their printed suit; they beat any off-suit Ace. +- Off-Suit: the highest unplayed card of a suit (A, or K if A is trump rank) is the "boss". Cross-suit cards cannot beat each other. +- No-Trump Round: only Jokers and the four trump-rank cards are trump; everything else is plain. Off-suit bosses cannot be ruffed. ## 3. Combos & Tractors -- **Combo Types**: Single (1 card); Pair (2 identical cards); Tractor (2+ consecutive pairs in one suit/trump group). -- **Trump Tractor Sequence**: Trump A → off-suit rank → trump rank → SJ → BJ. Two off-suit rank pairs are equal (not consecutive). -- **Off-Suit Tractor Sequence**: Consecutive ranks skip the active rank (e.g. 6-8 is consecutive when 7 is trump rank). Do not break a pair/tractor to fill a smaller combo if a standalone option exists. - -## 4. Leading Strategy -Trust the **Rule Score** ordering as your baseline, cashing bosses first and bleeding opponents' trumps only with low trump pairs if you have excess trump. -- **Boss Cash-out**: Lead off-suit boss A/K to score points safely. Never lead high trump combos or lone active ranks/jokers cheaply. -- **Probe Voids / Discard**: Lead low off-suit singles/rubbish to probe voids, or feed points to a void teammate to ruff. -- **Multi-Combo Leads**: Leading a multi-combo (multiple combos of the same suit played at once, non-trump only) is legal ONLY when every component is unbeatable (boss) or all three other players are void in that suit. - -## 5. Following Strategy & Constraints -- **Absolute Laws**: Follow the led suit/trump group if you hold it. You must match the led combo structure — obey the requirements in **## Suit-Following Analysis** (provided in the prompt below). If you do not hold matching combos (e.g. you hold only singles under a pair lead), follow with singles instead. NEVER split pairs when matching combos are held, and NEVER play or duplicate cards that are not explicitly shown in your hand list — you cannot play a pair or repeat a card's notation unless you hold multiple copies of that card (shown multiple times in your hand/available cards). If a multi-combo is led, match the combo structure and total length. -- **Seat Guidance**: Obey the situation-specific bullet under **## Seat Guidance** (provided in the prompt below) as your primary instruction. It dynamically tells you when to duck low, bank points on a safe teammate's trick, ruff/sluff, or conserve your elite cards. -- **Ruffing & Sluffing (Void)**: Ruff only to secure a worthwhile trick (≥10 pts) or block opponents. Size your ruff high enough to survive later players' over-ruffs; if you'll be out-ruffed regardless, use a low card to sluff instead of wasting trump. Never ruff over a teammate who is winning safely. -- **Conserve Control**: Keep your high trumps and off-suit bosses for tricks you lead or can absolutely win. Spend your cheapest non-point cards on lost tricks. - -## 6. Position Cues -- **2nd Seat**: Commit early only with a clear boss when points are up. Teammate (4th) can still cover. -- **3rd Seat**: Back a strong teammate or block an opponent, but ensure your play survives the 4th seat. Never over-trump your teammate. -- **4th Seat**: Perfect information. Act precisely to win the trick or dump trash with zero waste. - -Conservation through-line: Keep top trumps and off-suit bosses for moments that matter (winning big tricks, blocking, guaranteed leads). Dump cheap non-point cards when you cannot win. +- Single (1 card); Pair (2 identical cards); Tractor (2+ consecutive pairs in one suit/trump group). +- Trump tractor order: trump-suit A -> off-suit rank -> trump rank -> SJ -> BJ; two off-suit-rank pairs are equal (not consecutive). +- Off-suit tractors skip the trump rank (6-8 is consecutive when 7 is the trump rank). +- A multi-combo is two or more combos of one non-trump suit led together; legal only when every component is unbeatable, or all three other players are void in that suit. + +## 4. Following — the Absolute Laws (legality, not strategy) +- If you hold the led suit/trump group, you MUST follow it, matching the led combo structure and total length. +- NEVER split a pair you hold while a matching combo is required, and NEVER play a card you do not hold — copy notations from YOUR HAND, repeating a notation only if you hold two copies. +- If you cannot match the structure, follow with whatever cards of that suit you have. Only when void in the led suit may you trump (ruff) or discard another suit. + +## 5. Reading the Options +- **## Lead Options** (when leading) and **## Your Options** (when following) list EVERY legal play and what it does in points — which plays win, what they capture or concede, and what they cost you. These are facts, not advice. +- Choose the play that is best for your team's point total this round. The engine has done the counting and the lookahead; the strategic judgement is yours. `; export interface UserPromptTemplateArgs { @@ -50,9 +42,7 @@ export interface UserPromptTemplateArgs { activeTrickStatusStr: string; handChoicesStr: string; isLeading: boolean; - candidateOptionsStr: string; - suitAnalysisStr: string; - seatGuidanceStr: string; + optionsStr: string; taskInstructionStr: string; } @@ -60,7 +50,7 @@ export interface UserPromptTemplateArgs { function buildCurrentStateBlock(args: UserPromptTemplateArgs): string { return `## Current State - Player: ${args.playerId} (Team ${args.teamId}, partner: ${args.partnerId}) -- Role: ${args.isAttacking ? "Attacking (capture 80+ pts)" : "Defending (limit to <80 pts)"} +- Role: ${args.isAttacking ? "Attacking — your team must capture 80+ pts this round; points the opponents take are lost from that total" : "Defending — you win by keeping the attackers under 80; every point the attackers capture counts against you"} - Attacking team points: ${args.attackingPoints} / 80 - Trump: rank ${args.trumpRank}, suit ${args.trumpSuit} - Live off-suit points (unseen): ${args.liveSuitPointsStr}`; @@ -90,26 +80,19 @@ function buildHandBlock(args: UserPromptTemplateArgs): string { ${args.handChoicesStr}`; } -// 6. Lead Options Block +// 6. Lead Options Block (leading) — point-framed consequences, no recommendation function buildLeadOptionsBlock(args: UserPromptTemplateArgs): string { return `## Lead Options -${args.candidateOptionsStr.trim()}`; +${args.optionsStr.trim()}`; } -// 7. Suit Following Analysis Block -function buildSuitAnalysisBlock(args: UserPromptTemplateArgs): string { - return `## Suit-Following Analysis -${args.suitAnalysisStr.trim()}`; +// 7. Your Options Block (following) — point-framed consequences, no recommendation +function buildFollowingOptionsBlock(args: UserPromptTemplateArgs): string { + return `## Your Options +${args.optionsStr.trim()}`; } -// 8. Seat Guidance Block -function buildSeatGuidanceBlock(args: UserPromptTemplateArgs): string { - if (!args.seatGuidanceStr) return ""; - return `## Seat Guidance -${args.seatGuidanceStr}`; -} - -// 9. Task Block +// 8. Task Block function buildTaskBlock(args: UserPromptTemplateArgs): string { return `## Task ${args.taskInstructionStr} @@ -135,8 +118,7 @@ export function buildUserPromptTemplate(args: UserPromptTemplateArgs): string { buildVoidsBlock(args), buildActiveTrickBlock(args), buildHandBlock(args), - buildSeatGuidanceBlock(args), - buildSuitAnalysisBlock(args), + buildFollowingOptionsBlock(args), buildTaskBlock(args), ];