From d0a44d295a22856c5e973ea38426e702a679b735 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Wed, 8 Apr 2026 21:50:40 +0200 Subject: [PATCH] feat(SCOPONE-0009): complete iteration 0 dealer AI --- docs/ARCHITECTURE.md | 189 ++++++++++----------- docs/FINDINGS.md | 74 +++++---- src/game/ai.ts | 343 ++++++++++++++++++++++++++++++++++++--- src/game/card-tracker.ts | 113 ++++++++++--- src/game/engine.ts | 38 ++++- src/game/types.ts | 3 + src/scenes/GameScene.ts | 11 +- 7 files changed, 597 insertions(+), 174 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 20c138e..bdf2a3a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,21 +1,21 @@ # Architecture -> Last Updated: 2026-04-02T19:05:00.000Z +> Last Updated: 2026-04-08T19:48:08.000Z ## Overview | Attribute | Value | |-----------|-------| | Primary language | TypeScript | -| Secondary language | Java (Capacitor Android shell) | -| Project type | Browser card game with Android packaging | +| Secondary language | Java | +| Project type | Phaser browser game packaged for Android with Capacitor | | Framework | Phaser 3.87.0 | | Tooling | Vite 5, TypeScript 5.x, Capacitor 8.3 | | Runtime layout | 1280 x 720, `Phaser.Scale.FIT`, centered in `#game` | | Build command | `npm run build` | | Test command | `npx tsc --noEmit` | -The repository is a TypeScript-first Scopone Scientifico implementation. The web client contains the real game logic and presentation. The Android tree is a Capacitor wrapper with a custom immersive `MainActivity`. +The repository is a TypeScript-first implementation of Scopone Scientifico. Gameplay rules, scoring, inference, and AI live in framework-independent modules under `src/game/`. Phaser scenes under `src/scenes/` own rendering, input, UI, animation, and scene transitions. The `android/` tree is a Capacitor wrapper with a small custom `MainActivity` for immersive full-screen behavior. ## Project Structure @@ -37,6 +37,8 @@ scopone-phaser/ | `- GameScene.ts |- public/ |- android/ +| |- app/ +| `- variables.gradle |- docs/ |- prompts/ |- package.json @@ -49,102 +51,102 @@ scopone-phaser/ | Directory | Purpose | |-----------|---------| -| `src/game/` | Framework-independent rules, scoring, imperfect-information tracking, and AI search | -| `src/scenes/` | Phaser scene lifecycle, UI, animation, effects, and round orchestration | -| `public/` | Static web assets consumed by Phaser loaders | -| `android/` | Capacitor Android project, Gradle config, and immersive activity wrapper | +| `src/game/` | Rules engine, score calculation, imperfect-information tracking, AI heuristics, and master search | +| `src/scenes/` | Phaser scene lifecycle, menus, board rendering, interaction, HUD, audio, and FX | +| `public/` | Atlas metadata and other static assets loaded by Phaser | +| `android/` | Capacitor Android project, Gradle configuration, generated wrapper assets, and the native activity | | `docs/` | Architecture, code style, findings, and cache metadata | -| `prompts/` | JIRA pipeline artifacts and iteration state | +| `prompts/` | JIRA workflow artifacts and iteration state | ## Design Patterns No explicit GoF patterns were detected in the source or by semantic search. -Observed architectural patterns in the current codebase: +Observed architectural patterns: | Pattern | Where it appears | |---------|------------------| | Scene-based flow | `BootScene -> MenuScene -> GameScene` via Phaser scene registration | -| Functional core / imperative shell | `src/game/` stays free of Phaser imports, while `src/scenes/` owns rendering and input | -| Clone-before-mutate state transitions | `applyMove()` clones `GameState` before applying move effects | -| Worker offload with fallback | `AIWorkerClient` runs heavy AI inside `ai.worker.ts` and falls back to in-thread `chooseMove()` if worker startup or messaging fails | -| Determinization search | Master AI samples hidden hands before alpha-beta evaluation | -| Message-based progress reporting | Worker and main thread exchange typed request/result/progress messages through `ai-worker-protocol.ts` | +| Functional core / imperative shell | `src/game/` avoids Phaser imports while `src/scenes/` owns runtime side effects | +| Immutable state transitions | `applyMove()` clones `GameState` before mutating round state | +| Worker offload with fallback | `AIWorkerClient` uses `ai.worker.ts` when available and falls back to direct `chooseMove()` otherwise | +| Typed message protocol | `ai-worker-protocol.ts` defines worker request, progress, result, and error shapes | +| Imperfect-information search | `CardTracker` plus determinization sampling support the `master` AI tier | ## Key Components ### `src/main.ts` -- Bootstraps `Phaser.Game`. +- Creates the `Phaser.Game` instance. - Registers `BootScene`, `MenuScene`, and `GameScene`. -- Installs a one-shot fullscreen request handler on first user interaction. +- Installs a one-shot fullscreen request on first user input when supported. ### `src/game/types.ts` -- Defines the game model: `Card`, `Capture`, `Player`, `GameState`, `TeamScore`, `ScoreBreakdown`. -- Encodes difficulty tiers as `'beginner' | 'advanced' | 'master'`. -- Stores the `PRIMIERA_VALUES` lookup table. +- Defines the core game model: `Card`, `Capture`, `Player`, `GameState`, `TeamScore`, and `ScoreBreakdown`. +- Models constrained domains with unions such as `PlayerIndex` and `Difficulty`. +- Stores `PRIMIERA_VALUES` for end-of-round scoring. -### `src/game/engine.ts` (371 lines) +### `src/game/engine.ts` - Builds and shuffles the 40-card deck. -- Creates the initial round state for four players. -- Implements capture selection rules: single direct matches take priority; subset sums are considered only when no direct match exists. -- Applies moves immutably, detects scopas, assigns leftover table cards, and computes round scores. +- Creates a round state for four players with dealer-relative opening order. +- Implements capture rules where direct value matches take priority over subset-sum captures. +- Applies moves immutably, awards scope, assigns leftover table cards, and computes round and match scoring. -### `src/game/card-tracker.ts` (89 lines) +### `src/game/card-tracker.ts` -- Tracks seen cards across a round without exposing hidden hands directly. -- Computes unseen cards from `played + myHand + table`. -- Supplies probability helpers used by the AI for value-based inference. +- Tracks cards visible through play and capture events without exposing hidden hands. +- Reconstructs unseen cards from `played + myHand + table`. +- Supplies value and suit residue helpers used by AI inference and probability estimates. ### `src/game/ai.ts` -- Exposes `chooseMove()` as an async entry point. -- Implements three difficulty levels: - - `beginner`: noisy heuristic play. - - `advanced`: stronger heuristics with race awareness, partner setup, and card-tracker inference. - - `master`: determinization plus alpha-beta search with dynamic time budgets, batching, and progress callbacks. -- Uses `yieldToBrowser()` between master-search batches so Phaser can repaint the think bar. +- Exposes `chooseMove()` as the async AI entry point. +- Implements three difficulty tiers: `beginner`, `advanced`, and `master`. +- Uses table-driven search profiles, role-aware heuristics, tracker-based inference, and determinization plus alpha-beta search. +- Configures the current master profile with a 4600 ms budget, 10 samples, depth 6, and batch size 2. ### `src/game/ai-worker-protocol.ts` -- Defines the typed message contract between the main thread and the worker. -- Serializes requests around `GameState`, `Difficulty`, `PlayerIndex`, tracker snapshots, progress, results, and worker-safe errors. +- Defines a single `choose-move` worker request type. +- Defines typed progress, result, and serialized error responses. +- Keeps worker communication schema isolated from UI code. ### `src/game/ai-worker-client.ts` -- Wraps the worker lifecycle behind the same `chooseMove()` API the scene needs. -- Creates module workers with `new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' })`. -- Streams progress callbacks back into `GameScene` and degrades to direct `chooseMove()` execution when workers are unavailable. +- Wraps worker lifecycle and pending-request tracking behind the same `chooseMove()` API that scenes consume. +- Creates the worker as an ES module with `new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' })`. +- Fails over pending requests to in-thread AI execution if worker creation, messaging, or deserialization fails. ### `src/game/ai.worker.ts` -- Rehydrates `CardTracker` from a snapshot, delegates move selection to `chooseMove()`, and posts progress/result/error messages back to the scene thread. -- Keeps the expensive `master` search off the main rendering thread when worker support is available. +- Rehydrates `CardTracker` from a serialized snapshot. +- Delegates move selection to `chooseMove()`. +- Posts progress, result, or serialized error messages back to the main thread. ### `src/scenes/BootScene.ts` -- Loads the card atlas and card-back texture. -- Shows a simple progress bar and transitions into the menu. +- Loads the card atlas and card back. +- Displays a simple loading bar. +- Transitions into `MenuScene` after asset load. ### `src/scenes/MenuScene.ts` -- Renders the title screen and rules summary. -- Lets the player choose `beginner`, `advanced`, or `master` difficulty. -- Starts `GameScene` with the selected difficulty in scene data. +- Renders the title, rules summary, and difficulty selection. +- Starts `GameScene` with the chosen difficulty. ### `src/scenes/GameScene.ts` -- Owns the match loop, HUD, think bar, card interaction, animation, FX, audio, and round transitions. -- Uses `CardTracker` to record played and captured cards after each move. -- Instantiates `AIWorkerClient`, bridges async AI progress into a visible top-of-screen think bar, and disposes worker resources on scene shutdown. -- Handles end-of-round overlays and full-match restart flow. +- Owns match flow, dealing, selection, capture resolution, AI turn orchestration, score HUD, status UI, think bar, particles, and procedural audio. +- Instantiates and disposes `AIWorkerClient` on scene lifecycle events. +- Updates `CardTracker` after play and capture events so AI inference remains derived from visible information. ### `android/app/src/main/java/com/phaser/scopa/MainActivity.java` - Extends `BridgeActivity`. -- Forces immersive mode by hiding status and navigation bars whenever the window gains focus. +- Applies immersive mode during `onCreate()` and whenever window focus returns. +- Hides status and navigation bars with transient swipe behavior. ## Dependencies @@ -152,74 +154,77 @@ Observed architectural patterns in the current codebase: | Package | Version | Purpose | |---------|---------|---------| -| `phaser` | `^3.87.0` | Game engine | -| `@capacitor/core` | `^8.3.0` | Capacitor runtime | -| `@capacitor/cli` | `^8.3.0` | Capacitor tooling | +| `phaser` | `^3.87.0` | Game engine runtime | +| `@capacitor/core` | `^8.3.0` | Capacitor runtime bridge | +| `@capacitor/cli` | `^8.3.0` | Capacitor project tooling | | `@capacitor/android` | `^8.3.0` | Android platform integration | ### JavaScript development dependencies | Package | Version | Purpose | |---------|---------|---------| -| `typescript` | `^5.0.0` | Type-checking and TS compilation for builds | -| `vite` | `^5.0.0` | Dev server and bundling | +| `typescript` | `^5.0.0` | Static type checking and TS compilation step | +| `vite` | `^5.0.0` | Dev server and production bundler | ### Android / Gradle dependencies -| Dependency | Source | Purpose | -|------------|--------|---------| -| `com.android.tools.build:gradle:8.13.0` | `android/build.gradle` | Android build plugin | -| `com.google.gms:google-services:4.4.4` | `android/build.gradle` | Optional Google services integration | -| `androidx.appcompat:appcompat` | `android/app/build.gradle` | Android UI compatibility | -| `androidx.coordinatorlayout:coordinatorlayout` | `android/app/build.gradle` | Android layout support | -| `androidx.core:core-splashscreen` | `android/app/build.gradle` | Splash screen support | -| `junit:junit` | `android/app/build.gradle` | JVM-side Android tests | -| `androidx.test.ext:junit` | `android/app/build.gradle` | Instrumented Android testing | -| `androidx.test.espresso:espresso-core` | `android/app/build.gradle` | Android UI testing | +| Dependency | Version | Source | Purpose | +|------------|---------|--------|---------| +| `com.android.tools.build:gradle` | `8.13.0` | `android/build.gradle` | Android Gradle plugin | +| `com.google.gms:google-services` | `4.4.4` | `android/build.gradle` | Optional Google services integration | +| `androidx.appcompat:appcompat` | `1.7.1` | `android/variables.gradle` | Android app compatibility | +| `androidx.coordinatorlayout:coordinatorlayout` | `1.3.0` | `android/variables.gradle` | Layout coordination helpers | +| `androidx.core:core-splashscreen` | `1.2.0` | `android/variables.gradle` | Splash screen support | +| `junit:junit` | `4.13.2` | `android/variables.gradle` | JVM Android tests | +| `androidx.test.ext:junit` | `1.3.0` | `android/variables.gradle` | Instrumented test runner | +| `androidx.test.espresso:espresso-core` | `3.7.0` | `android/variables.gradle` | Instrumented UI testing | + +### Platform configuration + +- `compileSdkVersion`: 36 +- `targetSdkVersion`: 36 +- `minSdkVersion`: 24 ## Module Organization ```text main.ts - -> BootScene - -> MenuScene - -> GameScene - -> engine.ts - -> ai.ts - -> ai-worker-client.ts - -> ai-worker-protocol.ts - -> ai.worker.ts - -> ai.ts - -> card-tracker.ts - -> types.ts + -> BootScene + -> MenuScene + -> GameScene + -> engine.ts + -> types.ts + -> card-tracker.ts + -> ai-worker-client.ts + -> ai-worker-protocol.ts + -> ai.worker.ts + -> ai.ts ``` -Dependencies are one-directional at the application level: +Application-level dependency direction is one-way: - `src/game/` imports only from sibling game modules. -- `src/scenes/` imports from `src/game/`. +- `src/scenes/` imports from `src/game/` and Phaser. - `src/game/` never imports Phaser. ## Data Flow 1. `main.ts` creates the Phaser app and registers all scenes. -2. `BootScene` loads assets, then starts `MenuScene`. -3. `MenuScene` passes the chosen difficulty into `GameScene`. -4. `GameScene.create()` initializes a new `CardTracker`, creates the initial `GameState`, and animates the opening deal. -5. On each turn: - - Human turns use click-driven selection and capture highlighting. - - AI turns call `AIWorkerClient.chooseMove(state, playerIdx, difficulty, tracker, onProgress)`. -6. `AIWorkerClient` posts a typed request into `ai.worker.ts`; if worker setup fails, it falls back to in-thread `chooseMove()`. -7. `chooseMove()` either returns immediately for heuristic tiers or performs batched master search while reporting `AIDecisionProgress`. -8. Worker progress messages drive `GameScene.updateThinkBar()` until a result is posted back. -9. `GameScene.executeMove()` applies the move, updates the tracker, animates the result, refreshes the HUD, and advances the round. -10. When all hands are empty, `engine.ts` finalizes scoring and `GameScene` displays the round summary or final match screen. +2. `BootScene` loads textures and starts `MenuScene`. +3. `MenuScene` passes the chosen difficulty to `GameScene`. +4. `GameScene.create()` creates a fresh `CardTracker`, constructs a new `GameState`, and starts the opening deal. +5. Human turns use pointer-driven card selection and `findCaptures()` output to choose legal captures. +6. AI turns call `AIWorkerClient.chooseMove(state, playerIdx, difficulty, tracker, onProgress)`. +7. `AIWorkerClient` posts a typed request to `ai.worker.ts`; if workers are unavailable, it reruns the same request in-thread. +8. `chooseMove()` returns a heuristic move for lower tiers or performs batched master search while emitting `AIDecisionProgress`. +9. `GameScene` updates the think bar from progress callbacks, executes the returned move, records tracker state, and advances turn order. +10. When every hand is empty, `engine.ts` finalizes scoring and `GameScene` presents the round or match outcome. ## Build System | Command | Source | Purpose | |---------|--------|---------| -| `npm run dev` | `package.json` | Starts the Vite development server on port 3000 and opens the browser | +| `npm run dev` | `package.json` | Starts the Vite dev server | | `npm run build` | user-provided build command | Runs `tsc && vite build` and writes web output to `dist/` | -| `npm run preview` | `package.json` | Serves the built app locally via Vite preview | +| `npm run preview` | `package.json` | Serves the built app with Vite preview | | `npx tsc --noEmit` | user-provided test command | Type-checks the TypeScript codebase without emitting files | diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index d14c099..caddc4a 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -1,34 +1,35 @@ # Findings -> Last Updated: 2026-04-02T19:05:00.000Z +> Last Updated: 2026-04-08T19:48:08.000Z ## Summary -Initializer refresh for the current Scopone Scientifico codebase. The existing findings were stale relative to the current worker-backed AI execution path, so the observations below reflect the live source tree. +Initializer refresh for SCOPONE-0009. The cached findings were stale relative to the live source tree, so the observations below reflect the current Phaser, worker, and AI implementation. ## Codebase Observations -- Primary gameplay code lives in 8 TypeScript source files under `src/`; the Android wrapper adds 3 Java files. -- The gameplay runtime now includes three AI transport files in addition to the rules engine: `ai-worker-protocol.ts`, `ai-worker-client.ts`, and `ai.worker.ts`. -- The largest concentration of logic still sits in `src/scenes/GameScene.ts` and `src/game/ai.ts`. -- `src/game/` remains framework-independent and contains the rules engine, score calculation, card tracker, and AI logic. -- The AI now has three distinct difficulty levels: `beginner`, `advanced`, and `master`. -- The `advanced` and `master` tiers use `CardTracker` to reason about unseen cards instead of reading hidden hands directly. -- The `master` tier performs determinization plus alpha-beta search and reports progress back through `AIDecisionProgress`. -- `GameScene` displays AI progress through a top think bar and updates it from worker-forwarded progress messages. -- `AIWorkerClient` degrades cleanly to in-thread `chooseMove()` execution when workers are unavailable or fail. -- Audio remains fully procedural via Web Audio; no audio asset pipeline is present. -- No ESLint or Prettier config is present. +- Primary gameplay code currently lives in 10 TypeScript source files under `src/`; the Android wrapper adds 3 Java files. +- The project is structurally split between framework-free gameplay modules in `src/game/` and Phaser scene code in `src/scenes/`. +- `src/scenes/GameScene.ts` and `src/game/ai.ts` remain the two largest concentrations of application logic. +- The AI transport layer is now a stable three-file path: `ai-worker-protocol.ts`, `ai-worker-client.ts`, and `ai.worker.ts`. +- The AI exposes three difficulty levels: `beginner`, `advanced`, and `master`. +- `advanced` and `master` both use `CardTracker` to reason about unseen cards without directly reading hidden hands. +- The current `master` search profile is `timeBudgetMs: 4600`, `sampleCount: 10`, `maxDepth: 6`, `batchSize: 2`. +- `GameScene` consumes AI progress callbacks to update an on-screen think bar while a worker request is running. +- `AIWorkerClient` fails over pending work to in-thread `chooseMove()` if worker creation, posting, or deserialization fails. +- The Android wrapper targets SDK 36 with `minSdkVersion` 24 and applies immersive mode from the native activity. +- Audio remains procedural via Web Audio; no dedicated audio asset pipeline is present in the source tree. +- No ESLint or Prettier configuration is present. - The only repository-wide verification command supplied is `npx tsc --noEmit`. ## Potential Improvement Areas -- `GameScene.ts` still centralizes scene layout, input, effects, audio, HUD, and round transitions in one file, which raises maintenance cost. -- `ai.ts` mixes heuristic tiers, inference helpers, determinization, and alpha-beta evaluation in one module. -- Worker message types and fallback behavior are separated cleanly, but the UI still knows about AI progress presentation details directly. -- The `master` profile allows up to 9800 ms of search budget, which may be expensive on slower devices even with batch yielding. -- There is still no dedicated automated test suite for rules or AI behavior beyond type-checking. -- Formatting rules are enforced socially rather than by a linter/formatter toolchain. +- `GameScene.ts` still centralizes layout, turn flow, HUD updates, effects, and audio in one scene class, which raises maintenance cost. +- `ai.ts` still combines heuristic tiers, inference helpers, determinization, and alpha-beta evaluation in one module. +- Worker transport is isolated cleanly, but progress rendering remains coupled to scene-level UI concerns. +- A 4600 ms master search budget may still be noticeable on slower mobile devices even with batch yielding. +- There is no dedicated automated rules or AI test suite beyond type-checking. +- Formatting and style are enforced socially rather than by automated linting or formatting tools. ## Current Rule / Implementation Notes @@ -36,30 +37,32 @@ Initializer refresh for the current Scopone Scientifico codebase. The existing f - Direct-match capture has priority over subset-sum capture. - When multiple direct matches exist, `findCaptures()` returns one single-card option per matching card. -- Subset-sum captures are explored only when no direct match exists. +- Subset-sum captures are considered only when no direct match exists. - `applyMove()` defaults to the first legal capture if no explicit capture choice is supplied. +- Scope is awarded only when a capture clears the table before the final play of the round. ### AI implementation snapshot -- `beginner` adds randomness around a basic heuristic to remain beatable. +- `beginner` uses a simpler heuristic with noise to remain beatable. - `advanced` adds race awareness, anti-scopa logic, partner setup, anchor play, and tracker-based probability estimates. -- `master` orders legal moves with a quick evaluator, samples hidden hands, then scores moves with alpha-beta search under a deadline. -- `masterMove()` yields back to the browser between batches so Phaser can repaint the progress UI. +- `master` orders legal moves with a quick evaluator, samples hidden hands, and scores them with alpha-beta search under the active deadline. +- Progress is reported through `AIDecisionProgress` so the scene can keep the think bar responsive. ### Worker execution snapshot -- `GameScene` creates a fresh `AIWorkerClient` on scene creation and disposes it on shutdown. -- `AIWorkerClient` serializes a tracker snapshot instead of sending a live `CardTracker` instance across the worker boundary. -- `ai.worker.ts` reconstructs tracker state with `CardTracker.fromSnapshot()` before calling `chooseMove()`. -- Progress, results, and serialized worker errors all travel through `ai-worker-protocol.ts`. -- If worker initialization, posting, or message deserialization fails, pending requests are rerun with the in-thread AI path. +- `GameScene` creates `AIWorkerClient` during `create()` and disposes it on both `shutdown` and `destroy`. +- `AIWorkerClient` serializes `CardTracker` state through `toSnapshot()` instead of attempting to transfer the class instance. +- `ai.worker.ts` rebuilds tracker state with `CardTracker.fromSnapshot()` before calling `chooseMove()`. +- Progress, result, and serialized error payloads all travel through `ai-worker-protocol.ts`. +- If worker execution becomes unavailable, pending requests are rerun with the in-thread AI path rather than being dropped. ### Scene / UI implementation snapshot +- `BootScene` loads atlas assets and presents a simple loading bar. - `MenuScene` exposes difficulty selection before match start. -- `GameScene` records every played card and captured table card in `CardTracker`. -- The HUD continuously displays cards, denari, settebello, primiera, scope, and total points for both teams. -- Round-end and game-over flows are managed in-scene rather than through separate overlay components. +- `GameScene` tracks played and captured cards in `CardTracker` as the round evolves. +- The scene owns score HUD rendering, player labels, status text, think-bar rendering, and procedural particle effects. +- Round-end and match-end flows remain managed inside the scene instead of separate overlay components. ## Research Performed @@ -106,4 +109,11 @@ Initializer refresh for the current Scopone Scientifico codebase. The existing f - The current implementation does not use Phaser `TimerEvent` progress helpers. - Instead, `chooseMove()` emits its own normalized progress payload through `AIDecisionProgress`. - `GameScene.updateThinkBar()` renders remaining time from that callback. -- The yielding behavior in `masterMove()` is necessary so the browser can repaint while search batches continue. +- The yielding behavior in the master search path is necessary so the browser can repaint while search batches continue. + +### SCOPONE-0009: Phaser scene lifecycle notes (2026-04-08) + +- Source: Context7 `/websites/phaser_io_api-documentation`, query `Phaser 3.87 Scene lifecycle create restart shutdown destroy event listeners scene restart preserving external state`. +- Phaser dispatches `shutdown` when a scene stops being active but may be re-used later; resource cleanup that should also cover final teardown can additionally listen to `destroy`. +- The current `GameScene` pattern of registering one-shot shutdown and destroy handlers is aligned with Phaser guidance for worker disposal and UI cleanup. +- Dealer rotation and next-round state changes can stay inside the existing in-scene orchestration without requiring a different Phaser lifecycle primitive. diff --git a/src/game/ai.ts b/src/game/ai.ts index 161eb4a..a39598c 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -1,5 +1,5 @@ -import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS } from './types'; -import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState } from './engine'; +import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS, DealerRelativeRole } from './types'; +import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState, getDealerRelativeRole } from './engine'; import { CardTracker } from './card-tracker'; export interface AIMove { @@ -22,10 +22,59 @@ interface SearchProfile { batchSize: number; } +interface DealerRoleContext { + role: DealerRelativeRole; + onDealerSide: boolean; + defendingDealerAdvantage: boolean; + attackingDealerAdvantage: boolean; + aggressionBias: number; + controlBias: number; + pairPreservingBias: number; + parityBreakingBias: number; + tablePressureBias: number; +} + +interface ParitySnapshot { + unseenCounts: number[]; + oddResidue: boolean[]; + evenResidue: boolean[]; +} + +const DEALER_ROLE_WEIGHTS: Record> = { + 'first-hand': { + aggressionBias: 1.28, + controlBias: 0.9, + pairPreservingBias: 0.88, + parityBreakingBias: 1.26, + tablePressureBias: 1.3, + }, + 'second-hand': { + aggressionBias: 1, + controlBias: 1.08, + pairPreservingBias: 1.12, + parityBreakingBias: 0.96, + tablePressureBias: 1, + }, + 'third-hand': { + aggressionBias: 1.16, + controlBias: 0.94, + pairPreservingBias: 0.94, + parityBreakingBias: 1.16, + tablePressureBias: 1.12, + }, + dealer: { + aggressionBias: 0.84, + controlBias: 1.32, + pairPreservingBias: 1.34, + parityBreakingBias: 0.82, + tablePressureBias: 0.78, + }, +}; + const SEARCH_PROFILES: Record = { beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 }, advanced: { timeBudgetMs: 650, sampleCount: 0, maxDepth: 0, batchSize: 0 }, - master: { timeBudgetMs: 9800, sampleCount: 12, maxDepth: 6, batchSize: 2 }, + master: { timeBudgetMs: 4600, sampleCount: 10, maxDepth: 6, batchSize: 2 }, }; // --------------------------------------------------------------------------- @@ -72,18 +121,192 @@ function countValueInHand(hand: Card[], value: number): number { return n; } +function getDealerRoleContext(state: GameState, playerIdx: PlayerIndex): DealerRoleContext { + const role = getDealerRelativeRole(state.dealer, playerIdx); + const onDealerSide = role === 'dealer' || role === 'second-hand'; + return { + role, + onDealerSide, + defendingDealerAdvantage: onDealerSide, + attackingDealerAdvantage: !onDealerSide, + ...DEALER_ROLE_WEIGHTS[role], + }; +} + +function getParitySnapshot( + tracker: CardTracker | undefined, + myHand: Card[], + table: Card[], +): ParitySnapshot | null { + if (!tracker) return null; + + const unseenCounts = Array.from({ length: 11 }, () => 0); + const oddResidue = Array.from({ length: 11 }, () => false); + const evenResidue = Array.from({ length: 11 }, () => false); + const summary = tracker.getValueParityResidueSummary(myHand, table); + + for (const residue of summary) { + unseenCounts[residue.value] = residue.unseenCount; + oddResidue[residue.value] = residue.hasOddUnseenResidue; + evenResidue[residue.value] = residue.hasEvenUnseenResidue; + } + + return { unseenCounts, oddResidue, evenResidue }; +} + +function countParityValuesOnTable(afterTable: Card[], parity: ParitySnapshot | null): { oddValues: number; evenValues: number } { + if (!parity || afterTable.length === 0) { + return { oddValues: 0, evenValues: 0 }; + } + + let oddValues = 0; + let evenValues = 0; + const seenValues = new Set(); + + for (const card of afterTable) { + if (seenValues.has(card.value)) continue; + seenValues.add(card.value); + if (parity.oddResidue[card.value]) oddValues++; + else if (parity.evenResidue[card.value]) evenValues++; + } + + return { oddValues, evenValues }; +} + +function scoreRoleTablePlan( + afterTable: Card[], + roleContext: DealerRoleContext, + nextIsOpp: boolean, +): number { + if (afterTable.length === 0) return 0; + + const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); + let score = 0; + + if (roleContext.role === 'first-hand') { + if (afterTable.length >= 2) score += 22 * roleContext.tablePressureBias; + if (tableSum >= 8 && tableSum <= 15) score += 18 * roleContext.aggressionBias; + } + + if (roleContext.role === 'third-hand') { + if (afterTable.length >= 2) score += 14 * roleContext.tablePressureBias; + if (tableSum >= 10) score += 10 * roleContext.aggressionBias; + } + + if (roleContext.role === 'second-hand') { + if (nextIsOpp && tableSum >= 11) score += 16 * roleContext.controlBias; + if (!nextIsOpp && tableSum <= 10) score += 10 * roleContext.tablePressureBias; + } + + if (roleContext.role === 'dealer') { + if (tableSum >= 11) score += 28 * roleContext.controlBias; + if (tableSum <= 10 && nextIsOpp) score -= 24 * roleContext.controlBias; + if (afterTable.length === 1 && nextIsOpp) score -= 16 * roleContext.controlBias; + } + + return Math.round(score); +} + +function scoreParityTableState( + afterTable: Card[], + parity: ParitySnapshot | null, + roleContext: DealerRoleContext, + nextIsOpp: boolean, +): number { + const { oddValues, evenValues } = countParityValuesOnTable(afterTable, parity); + if (oddValues === 0 && evenValues === 0) return 0; + + let score = 0; + if (roleContext.defendingDealerAdvantage) { + score += evenValues * 18 * roleContext.controlBias; + score -= oddValues * 22 * roleContext.controlBias; + if (nextIsOpp) score += evenValues * 8 - oddValues * 10; + } else { + score += oddValues * 20 * roleContext.tablePressureBias; + score -= evenValues * 10; + if (nextIsOpp) score += oddValues * 12; + } + + return Math.round(score); +} + +function scoreCaptureParityPlan( + played: Card, + captured: Card[], + afterTable: Card[], + parity: ParitySnapshot | null, + roleContext: DealerRoleContext, + nextIsOpp: boolean, +): number { + if (!parity || captured.length === 0) return 0; + + let score = 0; + const directCapture = captured.length === 1 && captured[0].value === played.value; + + if (directCapture) { + const unseenCount = parity.unseenCounts[played.value] ?? 0; + const base = parity.oddResidue[played.value] ? 58 : 30; + score += base * roleContext.pairPreservingBias; + if (roleContext.defendingDealerAdvantage && unseenCount > 0) score += 18 * roleContext.controlBias; + } else { + let parityBreaks = 0; + let oddTargets = 0; + const seenValues = new Set(); + + for (const card of captured) { + if (seenValues.has(card.value)) continue; + seenValues.add(card.value); + if ((parity.unseenCounts[card.value] ?? 0) > 0) parityBreaks++; + if (parity.oddResidue[card.value]) oddTargets++; + } + + const disruption = parityBreaks * 20 + oddTargets * 18 + Math.max(0, captured.length - 1) * 12; + score += disruption * roleContext.parityBreakingBias; + if (roleContext.defendingDealerAdvantage) score -= 18 * roleContext.controlBias; + } + + score += scoreParityTableState(afterTable, parity, roleContext, nextIsOpp); + return Math.round(score); +} + +function scoreDumpParityPlan( + card: Card, + afterTable: Card[], + parity: ParitySnapshot | null, + roleContext: DealerRoleContext, + nextIsOpp: boolean, +): number { + if (!parity) return 0; + + let score = scoreParityTableState(afterTable, parity, roleContext, nextIsOpp); + if (parity.oddResidue[card.value]) { + score += roleContext.attackingDealerAdvantage ? 18 * roleContext.tablePressureBias : -20 * roleContext.controlBias; + } + if (parity.evenResidue[card.value]) { + score += roleContext.defendingDealerAdvantage ? 14 * roleContext.pairPreservingBias : 6; + } + + return Math.round(score); +} + function getSearchProfile(state: GameState, difficulty: Difficulty): SearchProfile { if (difficulty !== 'master') return SEARCH_PROFILES[difficulty]; const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); + if (cardsRemaining <= 4) { + return { timeBudgetMs: 3200, sampleCount: 4, maxDepth: cardsRemaining, batchSize: 1 }; + } if (cardsRemaining <= 6) { - return { timeBudgetMs: 9800, sampleCount: 18, maxDepth: Math.min(cardsRemaining, 8), batchSize: 1 }; + return { timeBudgetMs: 3600, sampleCount: 6, maxDepth: cardsRemaining, batchSize: 1 }; + } + if (cardsRemaining <= 8) { + return { timeBudgetMs: 3900, sampleCount: 8, maxDepth: cardsRemaining, batchSize: 1 }; } if (cardsRemaining <= 12) { - return { timeBudgetMs: 9000, sampleCount: 16, maxDepth: 8, batchSize: 2 }; + return { timeBudgetMs: 4200, sampleCount: 8, maxDepth: 8, batchSize: 1 }; } if (cardsRemaining <= 20) { - return { timeBudgetMs: 8200, sampleCount: 14, maxDepth: 7, batchSize: 2 }; + return { timeBudgetMs: 4400, sampleCount: 9, maxDepth: 7, batchSize: 2 }; } return SEARCH_PROFILES.master; } @@ -432,6 +655,8 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr const table = state.table; const phase = gamePhase(state); const race = getRaceState(state, playerIdx); + const roleContext = getDealerRoleContext(state, playerIdx); + const parity = getParitySnapshot(tracker, player.hand, table); const next = nextPlayer(playerIdx); const nextIsOpp = isOpponent(playerIdx, next); const partner = partnerOf(playerIdx); @@ -447,14 +672,14 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr for (const captureSet of captures) { const score = scoreCaptureAdv( card, captureSet, table, state, playerIdx, race, - tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, parity, ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { const score = scoreDumpAdv( card, table, state, playerIdx, race, - tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, parity, ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } } @@ -467,7 +692,7 @@ function scoreCaptureAdv( played: Card, captured: Card[], table: Card[], state: GameState, playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, - lastPlay: boolean, + lastPlay: boolean, roleContext: DealerRoleContext, parity: ParitySnapshot | null, ): number { let score = 100; const allCaptured = [played, ...captured]; @@ -508,6 +733,8 @@ function scoreCaptureAdv( } } + score += scoreCaptureParityPlan(played, captured, afterTable, parity, roleContext, nextIsOpp); + // --- ANCHOR STRATEGY --- // Prefer captures that leave table cards matching values we hold (we can recapture) if (!isScopa) { @@ -543,6 +770,8 @@ function scoreCaptureAdv( } } + score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp); + // --- PARTNER COOPERATION --- const next = nextPlayer(playerIdx); if (!isScopa && !isOpponent(playerIdx, next)) { @@ -620,6 +849,14 @@ function scoreCaptureAdv( if (tableSum <= 5 && nextIsOpp) score -= 60; } + if (roleContext.role === 'first-hand' && !isScopa && afterTable.length >= 2) { + score += Math.round(24 * roleContext.tablePressureBias); + } + if (roleContext.role === 'dealer' && !isScopa) { + const tableSum = afterTable.reduce((s, c) => s + c.value, 0); + if (tableSum >= 11) score += Math.round(30 * roleContext.controlBias); + } + return score; } @@ -627,7 +864,7 @@ function scoreDumpAdv( card: Card, table: Card[], state: GameState, playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, - lastPlay: boolean, + lastPlay: boolean, roleContext: DealerRoleContext, parity: ParitySnapshot | null, ): number { let score = 0; const afterTable = [...table, card]; @@ -653,6 +890,8 @@ function scoreDumpAdv( const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table); if (partnerProb > 0.4) score += 55; // partner can recapture what we dump + score += scoreDumpParityPlan(card, afterTable, parity, roleContext, nextIsOpp); + // --- ANTI-SCOPA --- if (tableSum >= 11) { score += 150; @@ -694,6 +933,8 @@ function scoreDumpAdv( score += 20; // safe dump before partner's turn, signals we don't need this suit } + score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp); + // --- CARD TRACKING --- if (tracker) { const unseen = tracker.getUnseenCards(myHand, afterTable); @@ -729,6 +970,13 @@ function scoreDumpAdv( if (card.value >= 8) score += 15; } + if (roleContext.role === 'first-hand' && afterTable.length >= 2 && tableSum >= 8) { + score += Math.round(18 * roleContext.tablePressureBias); + } + if (roleContext.role === 'dealer' && nextIsOpp && tableSum <= 10) { + score -= Math.round(24 * roleContext.controlBias); + } + return score; } @@ -744,6 +992,8 @@ function tableControlPressure( tracker: CardTracker | undefined, myHand: Card[], race: RaceState, + roleContext: DealerRoleContext, + parity: ParitySnapshot | null, ): number { if (afterTable.length === 0) return 0; @@ -790,6 +1040,8 @@ function tableControlPressure( } if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60; + score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp); + score += scoreParityTableState(afterTable, parity, roleContext, nextIsOpp); return score; } @@ -814,9 +1066,11 @@ async function masterMove( // Quick-eval move ordering for better pruning const lastPlay = isLastPlay(state, playerIdx); const race = getRaceState(state, playerIdx); + const roleContext = getDealerRoleContext(state, playerIdx); + const parity = getParitySnapshot(tracker, state.players[playerIdx].hand, state.table); const quickScored = legalMoves.map(m => ({ move: m, - quick: quickEval(m, state, playerIdx, tracker, lastPlay, race), + quick: quickEval(m, state, playerIdx, tracker, lastPlay, race, roleContext, parity), })); quickScored.sort((a, b) => b.quick - a.quick); const sortedMoves = quickScored.map(qs => qs.move); @@ -891,15 +1145,19 @@ function quickEval( move: AIMove, state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, lastPlay: boolean, race: RaceState, + roleContext: DealerRoleContext, + parity: ParitySnapshot | null, ): number { let score = 0; const table = state.table; - const afterTable = table.filter(c => !move.capture.some(cc => cc.id === c.id)); + const afterCaptureTable = table.filter(c => !move.capture.some(cc => cc.id === c.id)); + const projectedTable = move.capture.length > 0 ? afterCaptureTable : [...afterCaptureTable, move.card]; + const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== move.card.id); const allCaptured = [move.card, ...move.capture]; const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); // Scopa (not on last play!) - if (move.capture.length > 0 && afterTable.length === 0) { + if (move.capture.length > 0 && projectedTable.length === 0) { score += lastPlay ? 50 : 1200; } @@ -924,21 +1182,35 @@ function quickEval( } // Anti-scopa - if (afterTable.length > 0) { - const sum = afterTable.reduce((s, c) => s + c.value, 0); + if (projectedTable.length > 0) { + const sum = projectedTable.reduce((s, c) => s + c.value, 0); if (sum <= 10 && nextIsOpp) score -= 180; if (sum >= 11) score += 60; - if (afterTable.length === 1 && nextIsOpp) score -= 120; + if (projectedTable.length === 1 && nextIsOpp) score -= 120; } // Partner awareness const next = nextPlayer(playerIdx); - if (!isOpponent(playerIdx, next) && afterTable.length > 0) { - const sum = afterTable.reduce((s, c) => s + c.value, 0); + if (!isOpponent(playerIdx, next) && projectedTable.length > 0) { + const sum = projectedTable.reduce((s, c) => s + c.value, 0); if (sum >= 1 && sum <= 10) score += 40; // partner might scopa } - score += tableControlPressure(afterTable, state, playerIdx, tracker, state.players[playerIdx].hand, race); + score += scoreCaptureParityPlan(move.card, move.capture, projectedTable, parity, roleContext, nextIsOpp); + if (move.capture.length === 0) { + score += scoreDumpParityPlan(move.card, projectedTable, parity, roleContext, nextIsOpp); + } + + score += tableControlPressure( + projectedTable, + state, + playerIdx, + tracker, + projectedHand, + race, + roleContext, + parity, + ); return score; } @@ -1036,20 +1308,22 @@ function alphaBeta( tracker: CardTracker | undefined, ): number { if (depth === 0 || state.roundOver || Date.now() > deadline) { - return evaluateFast(state, myTeam, phase); + return evaluateFast(state, myTeam, phase, tracker, rootPlayer); } const cur = state.currentPlayer; const isMyTeam = teamOf(cur) === myTeam; const moves = getLegalMoves(state, cur); - if (moves.length === 0) return evaluateFast(state, myTeam, phase); + if (moves.length === 0) return evaluateFast(state, myTeam, phase, tracker, rootPlayer); // Move ordering: settebello captures first, then scopa, then captures by size, then dumps if (moves.length > 2) { const race = getRaceState(state, cur); const lastPlay = isLastPlay(state, cur); - moves.sort((a, b) => quickEval(b, state, cur, tracker, lastPlay, race) - quickEval(a, state, cur, tracker, lastPlay, race)); + const roleContext = getDealerRoleContext(state, cur); + const parity = getParitySnapshot(tracker, state.players[rootPlayer].hand, state.table); + moves.sort((a, b) => quickEval(b, state, cur, tracker, lastPlay, race, roleContext, parity) - quickEval(a, state, cur, tracker, lastPlay, race, roleContext, parity)); } if (isMyTeam) { @@ -1076,12 +1350,20 @@ function alphaBeta( } /** Fast evaluation: avoids flatMap/filter at every leaf node */ -function evaluateFast(state: GameState, myTeam: 0 | 1, phase: number): number { +function evaluateFast( + state: GameState, + myTeam: 0 | 1, + phase: number, + tracker: CardTracker | undefined, + rootPlayer: PlayerIndex, +): number { const p0 = state.players[0], p1 = state.players[1], p2 = state.players[2], p3 = state.players[3]; const myA = myTeam === 0 ? p0 : p1; const myB = myTeam === 0 ? p2 : p3; const oppA = myTeam === 0 ? p1 : p0; const oppB = myTeam === 0 ? p3 : p2; + const roleContext = getDealerRoleContext(state, state.currentPlayer); + const parity = getParitySnapshot(tracker, state.players[rootPlayer].hand, state.table); // Single-pass pile scan — no flatMap/filter allocations let myCards = 0, oppCards = 0; @@ -1204,6 +1486,21 @@ function evaluateFast(state: GameState, myTeam: 0 | 1, phase: number): number { // Good: table has cards we can capture score += state.table.length * 5; } + + const parityPressure = scoreParityTableState(state.table, parity, roleContext, !myTurn); + score += myTurn ? parityPressure : -parityPressure; + + const rolePlan = scoreRoleTablePlan(state.table, roleContext, !myTurn); + score += myTurn ? rolePlan : -rolePlan; + + if (parity) { + const { oddValues, evenValues } = countParityValuesOnTable(state.table, parity); + if (roleContext.defendingDealerAdvantage) { + score += (myTurn ? evenValues : -oddValues) * 14; + } else { + score += (myTurn ? oddValues : -evenValues) * 14; + } + } } return score; diff --git a/src/game/card-tracker.ts b/src/game/card-tracker.ts index a74525b..ba8dbd1 100644 --- a/src/game/card-tracker.ts +++ b/src/game/card-tracker.ts @@ -4,12 +4,35 @@ export interface CardTrackerSnapshot { playedCardIds: string[]; } +export interface CardTrackerValueParityResidue { + value: number; + knownCount: number; + unseenCount: number; + hasOddUnseenResidue: boolean; + hasEvenUnseenResidue: boolean; +} + +interface VisibleValueResidueKnowledge { + unseenCards: Card[]; + unseenCountBySuit: Record; + unseenCountByValue: number[]; + valueParityResidues: CardTrackerValueParityResidue[]; +} + function normalizeSnapshot(snapshot: CardTrackerSnapshot): CardTrackerSnapshot { return { playedCardIds: Array.from(new Set(snapshot.playedCardIds)), }; } +function createEmptySuitCounts(): Record { + const counts = {} as Record; + for (const suit of SUITS) { + counts[suit] = 0; + } + return counts; +} + /** * Tracks which cards have been played/captured during a round. * Used by AI to infer opponent hands WITHOUT cheating. @@ -65,44 +88,96 @@ export class CardTracker { return !this.played.has('denara_7'); } + private buildVisibleValueResidueKnowledge(myHand: Card[], table: Card[]): VisibleValueResidueKnowledge { + const knownCardIds = new Set(this.played); + for (const card of myHand) { + knownCardIds.add(card.id); + } + for (const card of table) { + knownCardIds.add(card.id); + } + + const knownCountByValue = Array.from({ length: 11 }, () => 0); + const unseenCountByValue = Array.from({ length: 11 }, () => 0); + const unseenCountBySuit = createEmptySuitCounts(); + const unseenCards: Card[] = []; + + for (const suit of SUITS) { + for (let value = 1; value <= 10; value++) { + const id = `${suit}_${value}`; + if (knownCardIds.has(id)) { + knownCountByValue[value] += 1; + continue; + } + + unseenCountByValue[value] += 1; + unseenCountBySuit[suit] += 1; + unseenCards.push({ suit, value, id }); + } + } + + const valueParityResidues: CardTrackerValueParityResidue[] = []; + for (let value = 1; value <= 10; value++) { + const unseenCount = unseenCountByValue[value]; + valueParityResidues.push({ + value, + knownCount: knownCountByValue[value], + unseenCount, + hasOddUnseenResidue: unseenCount % 2 === 1, + hasEvenUnseenResidue: unseenCount % 2 === 0, + }); + } + + return { + unseenCards, + unseenCountBySuit, + unseenCountByValue, + valueParityResidues, + }; + } + /** * Get cards that could be in opponent hands. * = full 40-card deck minus: already played, my hand, currently on table */ getUnseenCards(myHand: Card[], table: Card[]): Card[] { - const known = new Set(); - for (const id of this.played) known.add(id); - for (const c of myHand) known.add(c.id); - for (const c of table) known.add(c.id); - - const unseen: Card[] = []; - for (const suit of SUITS) { - for (let v = 1; v <= 10; v++) { - const id = `${suit}_${v}`; - if (!known.has(id)) { - unseen.push({ suit, value: v, id }); - } - } - } - return unseen; + return this.buildVisibleValueResidueKnowledge(myHand, table).unseenCards; } /** Count how many cards of a suit are still unseen */ countRemainingSuit(suit: Suit, myHand: Card[], table: Card[]): number { - return this.getUnseenCards(myHand, table).filter(c => c.suit === suit).length; + return this.buildVisibleValueResidueKnowledge(myHand, table).unseenCountBySuit[suit]; } /** Count how many unseen cards share a value */ countRemainingValue(value: number, myHand: Card[], table: Card[]): number { - return this.getUnseenCards(myHand, table).filter(c => c.value === value).length; + return this.getValueParityResidue(value, myHand, table).unseenCount; + } + + /** Get visible known-count, unseen-count, and parity residue for a single value */ + getValueParityResidue(value: number, myHand: Card[], table: Card[]): CardTrackerValueParityResidue { + const valueParityResidues = this.buildVisibleValueResidueKnowledge(myHand, table).valueParityResidues; + return valueParityResidues[value - 1] ?? { + value, + knownCount: 0, + unseenCount: 0, + hasOddUnseenResidue: false, + hasEvenUnseenResidue: true, + }; + } + + /** Get visible known-count, unseen-count, and parity residue for all card values */ + getValueParityResidueSummary(myHand: Card[], table: Card[]): CardTrackerValueParityResidue[] { + return this.buildVisibleValueResidueKnowledge(myHand, table).valueParityResidues; } /** Probability that a hidden hand contains at least one card with the requested value */ probabilityHandHasValue(value: number, handSize: number, myHand: Card[], table: Card[]): number { if (handSize <= 0) return 0; - const unseen = this.getUnseenCards(myHand, table); - const matching = unseen.filter(c => c.value === value).length; + const visibleValueResidueKnowledge = this.buildVisibleValueResidueKnowledge(myHand, table); + const unseen = visibleValueResidueKnowledge.unseenCards; + const matching = visibleValueResidueKnowledge.unseenCountByValue[value] ?? 0; if (matching === 0) return 0; if (handSize >= unseen.length) return 1; diff --git a/src/game/engine.ts b/src/game/engine.ts index c446f9c..9d77c75 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -1,6 +1,6 @@ import { Card, Suit, SUITS, Player, PlayerIndex, GameState, - TeamScore, ScoreBreakdown, PRIMIERA_VALUES, Capture + TeamScore, ScoreBreakdown, PRIMIERA_VALUES, Capture, DealerRelativeRole } from './types'; // --------------------------------------------------------------------------- @@ -76,8 +76,38 @@ export function canCapture(played: Card, table: Card[]): boolean { // Game state initialisation // --------------------------------------------------------------------------- -export function createInitialState(startingPlayer: PlayerIndex = 0): GameState { +export function nextPlayer(playerIdx: PlayerIndex, steps = 1): PlayerIndex { + return ((playerIdx + steps) % 4) as PlayerIndex; +} + +export function getOpeningPlayerForDealer(dealer: PlayerIndex): PlayerIndex { + return nextPlayer(dealer); +} + +export function getDealerRelativeOrder( + dealer: PlayerIndex +): [PlayerIndex, PlayerIndex, PlayerIndex, PlayerIndex] { + const firstHand = getOpeningPlayerForDealer(dealer); + const secondHand = nextPlayer(firstHand); + const thirdHand = nextPlayer(secondHand); + return [firstHand, secondHand, thirdHand, dealer]; +} + +export function getDealerRelativeRole( + dealer: PlayerIndex, + playerIdx: PlayerIndex +): DealerRelativeRole { + const [firstHand, secondHand, thirdHand] = getDealerRelativeOrder(dealer); + + if (playerIdx === firstHand) return 'first-hand'; + if (playerIdx === secondHand) return 'second-hand'; + if (playerIdx === thirdHand) return 'third-hand'; + return 'dealer'; +} + +export function createInitialState(dealer: PlayerIndex = 3): GameState { const deck = shuffle(buildDeck()); + const startingPlayer = getOpeningPlayerForDealer(dealer); const players: [Player, Player, Player, Player] = [ { index: 0, hand: [], pile: [], scope: 0, isHuman: true, name: 'Tu' }, @@ -102,6 +132,7 @@ export function createInitialState(startingPlayer: PlayerIndex = 0): GameState { players, table, matchStartingPlayer: startingPlayer, + dealer, currentPlayer: startingPlayer, roundOver: false, gameOver: false, @@ -164,7 +195,7 @@ export function applyMove( } // Advance turn - state2.currentPlayer = ((playerIdx + 1) % 4) as PlayerIndex; + state2.currentPlayer = nextPlayer(playerIdx); // Check if round is over (all hands empty) const allHandsEmpty = state2.players.every(p => p.hand.length === 0); @@ -353,6 +384,7 @@ export function cloneState(state: GameState): GameState { ], table: state.table.map(cloneCard), matchStartingPlayer: state.matchStartingPlayer, + dealer: state.dealer, currentPlayer: state.currentPlayer, roundOver: state.roundOver, gameOver: state.gameOver, diff --git a/src/game/types.ts b/src/game/types.ts index 430b5bb..d3d6d15 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -14,6 +14,8 @@ export interface Capture { export type PlayerIndex = 0 | 1 | 2 | 3; +export type DealerRelativeRole = 'first-hand' | 'second-hand' | 'third-hand' | 'dealer'; + export type Difficulty = 'beginner' | 'advanced' | 'master'; export interface Player { @@ -29,6 +31,7 @@ export interface GameState { players: [Player, Player, Player, Player]; table: Card[]; matchStartingPlayer: PlayerIndex; + dealer: PlayerIndex; currentPlayer: PlayerIndex; roundOver: boolean; gameOver: boolean; diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index d489629..65822ef 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -1,7 +1,8 @@ import Phaser from 'phaser'; import { Card, PlayerIndex, GameState, Difficulty } from '../game/types'; import { - createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome + createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome, + nextPlayer } from '../game/engine'; import { AIDecisionProgress } from '../game/ai'; import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client'; @@ -130,8 +131,8 @@ export class GameScene extends Phaser.Scene { this.input.once('pointerdown', () => this.startMusic()); - const startingPlayer = Phaser.Math.Between(0, 3) as PlayerIndex; - this.state = createInitialState(startingPlayer); + const initialDealer = Phaser.Math.Between(0, 3) as PlayerIndex; + this.state = createInitialState(initialDealer); this.dealAnimation(() => { this.updateScoreBar(); this.nextTurn(); @@ -1434,11 +1435,11 @@ export class GameScene extends Phaser.Scene { const totals = this.state.teamScores.map(t => t.totalPoints); const nextRound = (this.state.roundNumber ?? 1) + 1; const matchStartingPlayer = this.state.matchStartingPlayer; - const startingPlayer = ((matchStartingPlayer + nextRound - 1) % 4) as PlayerIndex; + const nextDealer = nextPlayer(this.state.dealer); for (const img of this.cardImages.values()) img.destroy(); this.cardImages.clear(); this.tracker.reset(); - this.state = createInitialState(startingPlayer); + this.state = createInitialState(nextDealer); this.state.matchStartingPlayer = matchStartingPlayer; this.state.teamScores[0].totalPoints = totals[0]; this.state.teamScores[1].totalPoints = totals[1];