feat(SCOPONE-0009): complete iteration 0 dealer AI

This commit is contained in:
Giancarmine Salucci
2026-04-08 21:50:40 +02:00
parent c9accb7ae4
commit d0a44d295a
7 changed files with 597 additions and 174 deletions

View File

@@ -1,21 +1,21 @@
# Architecture # Architecture
> Last Updated: 2026-04-02T19:05:00.000Z > Last Updated: 2026-04-08T19:48:08.000Z
## Overview ## Overview
| Attribute | Value | | Attribute | Value |
|-----------|-------| |-----------|-------|
| Primary language | TypeScript | | Primary language | TypeScript |
| Secondary language | Java (Capacitor Android shell) | | Secondary language | Java |
| Project type | Browser card game with Android packaging | | Project type | Phaser browser game packaged for Android with Capacitor |
| Framework | Phaser 3.87.0 | | Framework | Phaser 3.87.0 |
| Tooling | Vite 5, TypeScript 5.x, Capacitor 8.3 | | Tooling | Vite 5, TypeScript 5.x, Capacitor 8.3 |
| Runtime layout | 1280 x 720, `Phaser.Scale.FIT`, centered in `#game` | | Runtime layout | 1280 x 720, `Phaser.Scale.FIT`, centered in `#game` |
| Build command | `npm run build` | | Build command | `npm run build` |
| Test command | `npx tsc --noEmit` | | 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 ## Project Structure
@@ -37,6 +37,8 @@ scopone-phaser/
| `- GameScene.ts | `- GameScene.ts
|- public/ |- public/
|- android/ |- android/
| |- app/
| `- variables.gradle
|- docs/ |- docs/
|- prompts/ |- prompts/
|- package.json |- package.json
@@ -49,102 +51,102 @@ scopone-phaser/
| Directory | Purpose | | Directory | Purpose |
|-----------|---------| |-----------|---------|
| `src/game/` | Framework-independent rules, scoring, imperfect-information tracking, and AI search | | `src/game/` | Rules engine, score calculation, imperfect-information tracking, AI heuristics, and master search |
| `src/scenes/` | Phaser scene lifecycle, UI, animation, effects, and round orchestration | | `src/scenes/` | Phaser scene lifecycle, menus, board rendering, interaction, HUD, audio, and FX |
| `public/` | Static web assets consumed by Phaser loaders | | `public/` | Atlas metadata and other static assets loaded by Phaser |
| `android/` | Capacitor Android project, Gradle config, and immersive activity wrapper | | `android/` | Capacitor Android project, Gradle configuration, generated wrapper assets, and the native activity |
| `docs/` | Architecture, code style, findings, and cache metadata | | `docs/` | Architecture, code style, findings, and cache metadata |
| `prompts/` | JIRA pipeline artifacts and iteration state | | `prompts/` | JIRA workflow artifacts and iteration state |
## Design Patterns ## Design Patterns
No explicit GoF patterns were detected in the source or by semantic search. 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 | | Pattern | Where it appears |
|---------|------------------| |---------|------------------|
| Scene-based flow | `BootScene -> MenuScene -> GameScene` via Phaser scene registration | | 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 | | Functional core / imperative shell | `src/game/` avoids Phaser imports while `src/scenes/` owns runtime side effects |
| Clone-before-mutate state transitions | `applyMove()` clones `GameState` before applying move effects | | Immutable state transitions | `applyMove()` clones `GameState` before mutating round state |
| 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 | | Worker offload with fallback | `AIWorkerClient` uses `ai.worker.ts` when available and falls back to direct `chooseMove()` otherwise |
| Determinization search | Master AI samples hidden hands before alpha-beta evaluation | | Typed message protocol | `ai-worker-protocol.ts` defines worker request, progress, result, and error shapes |
| Message-based progress reporting | Worker and main thread exchange typed request/result/progress messages through `ai-worker-protocol.ts` | | Imperfect-information search | `CardTracker` plus determinization sampling support the `master` AI tier |
## Key Components ## Key Components
### `src/main.ts` ### `src/main.ts`
- Bootstraps `Phaser.Game`. - Creates the `Phaser.Game` instance.
- Registers `BootScene`, `MenuScene`, and `GameScene`. - 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` ### `src/game/types.ts`
- Defines the game model: `Card`, `Capture`, `Player`, `GameState`, `TeamScore`, `ScoreBreakdown`. - Defines the core game model: `Card`, `Capture`, `Player`, `GameState`, `TeamScore`, and `ScoreBreakdown`.
- Encodes difficulty tiers as `'beginner' | 'advanced' | 'master'`. - Models constrained domains with unions such as `PlayerIndex` and `Difficulty`.
- Stores the `PRIMIERA_VALUES` lookup table. - 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. - Builds and shuffles the 40-card deck.
- Creates the initial round state for four players. - Creates a round state for four players with dealer-relative opening order.
- Implements capture selection rules: single direct matches take priority; subset sums are considered only when no direct match exists. - Implements capture rules where direct value matches take priority over subset-sum captures.
- Applies moves immutably, detects scopas, assigns leftover table cards, and computes round scores. - 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. - Tracks cards visible through play and capture events without exposing hidden hands.
- Computes unseen cards from `played + myHand + table`. - Reconstructs unseen cards from `played + myHand + table`.
- Supplies probability helpers used by the AI for value-based inference. - Supplies value and suit residue helpers used by AI inference and probability estimates.
### `src/game/ai.ts` ### `src/game/ai.ts`
- Exposes `chooseMove()` as an async entry point. - Exposes `chooseMove()` as the async AI entry point.
- Implements three difficulty levels: - Implements three difficulty tiers: `beginner`, `advanced`, and `master`.
- `beginner`: noisy heuristic play. - Uses table-driven search profiles, role-aware heuristics, tracker-based inference, and determinization plus alpha-beta search.
- `advanced`: stronger heuristics with race awareness, partner setup, and card-tracker inference. - Configures the current master profile with a 4600 ms budget, 10 samples, depth 6, and batch size 2.
- `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.
### `src/game/ai-worker-protocol.ts` ### `src/game/ai-worker-protocol.ts`
- Defines the typed message contract between the main thread and the worker. - Defines a single `choose-move` worker request type.
- Serializes requests around `GameState`, `Difficulty`, `PlayerIndex`, tracker snapshots, progress, results, and worker-safe errors. - Defines typed progress, result, and serialized error responses.
- Keeps worker communication schema isolated from UI code.
### `src/game/ai-worker-client.ts` ### `src/game/ai-worker-client.ts`
- Wraps the worker lifecycle behind the same `chooseMove()` API the scene needs. - Wraps worker lifecycle and pending-request tracking behind the same `chooseMove()` API that scenes consume.
- Creates module workers with `new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' })`. - Creates the worker as an ES module 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. - Fails over pending requests to in-thread AI execution if worker creation, messaging, or deserialization fails.
### `src/game/ai.worker.ts` ### `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. - Rehydrates `CardTracker` from a serialized snapshot.
- Keeps the expensive `master` search off the main rendering thread when worker support is available. - Delegates move selection to `chooseMove()`.
- Posts progress, result, or serialized error messages back to the main thread.
### `src/scenes/BootScene.ts` ### `src/scenes/BootScene.ts`
- Loads the card atlas and card-back texture. - Loads the card atlas and card back.
- Shows a simple progress bar and transitions into the menu. - Displays a simple loading bar.
- Transitions into `MenuScene` after asset load.
### `src/scenes/MenuScene.ts` ### `src/scenes/MenuScene.ts`
- Renders the title screen and rules summary. - Renders the title, rules summary, and difficulty selection.
- Lets the player choose `beginner`, `advanced`, or `master` difficulty. - Starts `GameScene` with the chosen difficulty.
- Starts `GameScene` with the selected difficulty in scene data.
### `src/scenes/GameScene.ts` ### `src/scenes/GameScene.ts`
- Owns the match loop, HUD, think bar, card interaction, animation, FX, audio, and round transitions. - Owns match flow, dealing, selection, capture resolution, AI turn orchestration, score HUD, status UI, think bar, particles, and procedural audio.
- Uses `CardTracker` to record played and captured cards after each move. - Instantiates and disposes `AIWorkerClient` on scene lifecycle events.
- Instantiates `AIWorkerClient`, bridges async AI progress into a visible top-of-screen think bar, and disposes worker resources on scene shutdown. - Updates `CardTracker` after play and capture events so AI inference remains derived from visible information.
- Handles end-of-round overlays and full-match restart flow.
### `android/app/src/main/java/com/phaser/scopa/MainActivity.java` ### `android/app/src/main/java/com/phaser/scopa/MainActivity.java`
- Extends `BridgeActivity`. - 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 ## Dependencies
@@ -152,74 +154,77 @@ Observed architectural patterns in the current codebase:
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| |---------|---------|---------|
| `phaser` | `^3.87.0` | Game engine | | `phaser` | `^3.87.0` | Game engine runtime |
| `@capacitor/core` | `^8.3.0` | Capacitor runtime | | `@capacitor/core` | `^8.3.0` | Capacitor runtime bridge |
| `@capacitor/cli` | `^8.3.0` | Capacitor tooling | | `@capacitor/cli` | `^8.3.0` | Capacitor project tooling |
| `@capacitor/android` | `^8.3.0` | Android platform integration | | `@capacitor/android` | `^8.3.0` | Android platform integration |
### JavaScript development dependencies ### JavaScript development dependencies
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| |---------|---------|---------|
| `typescript` | `^5.0.0` | Type-checking and TS compilation for builds | | `typescript` | `^5.0.0` | Static type checking and TS compilation step |
| `vite` | `^5.0.0` | Dev server and bundling | | `vite` | `^5.0.0` | Dev server and production bundler |
### Android / Gradle dependencies ### Android / Gradle dependencies
| Dependency | Source | Purpose | | Dependency | Version | Source | Purpose |
|------------|--------|---------| |------------|---------|--------|---------|
| `com.android.tools.build:gradle:8.13.0` | `android/build.gradle` | Android build plugin | | `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 | | `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.appcompat:appcompat` | `1.7.1` | `android/variables.gradle` | Android app compatibility |
| `androidx.coordinatorlayout:coordinatorlayout` | `android/app/build.gradle` | Android layout support | | `androidx.coordinatorlayout:coordinatorlayout` | `1.3.0` | `android/variables.gradle` | Layout coordination helpers |
| `androidx.core:core-splashscreen` | `android/app/build.gradle` | Splash screen support | | `androidx.core:core-splashscreen` | `1.2.0` | `android/variables.gradle` | Splash screen support |
| `junit:junit` | `android/app/build.gradle` | JVM-side Android tests | | `junit:junit` | `4.13.2` | `android/variables.gradle` | JVM Android tests |
| `androidx.test.ext:junit` | `android/app/build.gradle` | Instrumented Android testing | | `androidx.test.ext:junit` | `1.3.0` | `android/variables.gradle` | Instrumented test runner |
| `androidx.test.espresso:espresso-core` | `android/app/build.gradle` | Android UI testing | | `androidx.test.espresso:espresso-core` | `3.7.0` | `android/variables.gradle` | Instrumented UI testing |
### Platform configuration
- `compileSdkVersion`: 36
- `targetSdkVersion`: 36
- `minSdkVersion`: 24
## Module Organization ## Module Organization
```text ```text
main.ts main.ts
-> BootScene -> BootScene
-> MenuScene -> MenuScene
-> GameScene -> GameScene
-> engine.ts -> engine.ts
-> ai.ts -> types.ts
-> ai-worker-client.ts -> card-tracker.ts
-> ai-worker-protocol.ts -> ai-worker-client.ts
-> ai.worker.ts -> ai-worker-protocol.ts
-> ai.ts -> ai.worker.ts
-> card-tracker.ts -> ai.ts
-> types.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/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. - `src/game/` never imports Phaser.
## Data Flow ## Data Flow
1. `main.ts` creates the Phaser app and registers all scenes. 1. `main.ts` creates the Phaser app and registers all scenes.
2. `BootScene` loads assets, then starts `MenuScene`. 2. `BootScene` loads textures and starts `MenuScene`.
3. `MenuScene` passes the chosen difficulty into `GameScene`. 3. `MenuScene` passes the chosen difficulty to `GameScene`.
4. `GameScene.create()` initializes a new `CardTracker`, creates the initial `GameState`, and animates the opening deal. 4. `GameScene.create()` creates a fresh `CardTracker`, constructs a new `GameState`, and starts the opening deal.
5. On each turn: 5. Human turns use pointer-driven card selection and `findCaptures()` output to choose legal captures.
- Human turns use click-driven selection and capture highlighting. 6. AI turns call `AIWorkerClient.chooseMove(state, playerIdx, difficulty, tracker, onProgress)`.
- 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.
6. `AIWorkerClient` posts a typed request into `ai.worker.ts`; if worker setup fails, it falls back to in-thread `chooseMove()`. 8. `chooseMove()` returns a heuristic move for lower tiers or performs batched master search while emitting `AIDecisionProgress`.
7. `chooseMove()` either returns immediately for heuristic tiers or performs batched master search while reporting `AIDecisionProgress`. 9. `GameScene` updates the think bar from progress callbacks, executes the returned move, records tracker state, and advances turn order.
8. Worker progress messages drive `GameScene.updateThinkBar()` until a result is posted back. 10. When every hand is empty, `engine.ts` finalizes scoring and `GameScene` presents the round or match outcome.
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.
## Build System ## Build System
| Command | Source | Purpose | | 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 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 | | `npx tsc --noEmit` | user-provided test command | Type-checks the TypeScript codebase without emitting files |

View File

@@ -1,34 +1,35 @@
# Findings # Findings
> Last Updated: 2026-04-02T19:05:00.000Z > Last Updated: 2026-04-08T19:48:08.000Z
## Summary ## 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 ## Codebase Observations
- Primary gameplay code lives in 8 TypeScript source files under `src/`; the Android wrapper adds 3 Java files. - Primary gameplay code currently lives in 10 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 project is structurally split between framework-free gameplay modules in `src/game/` and Phaser scene code in `src/scenes/`.
- The largest concentration of logic still sits in `src/scenes/GameScene.ts` and `src/game/ai.ts`. - `src/scenes/GameScene.ts` and `src/game/ai.ts` remain the two largest concentrations of application logic.
- `src/game/` remains framework-independent and contains the rules engine, score calculation, card tracker, and AI 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 now has three distinct difficulty levels: `beginner`, `advanced`, and `master`. - The AI exposes three difficulty levels: `beginner`, `advanced`, and `master`.
- The `advanced` and `master` tiers use `CardTracker` to reason about unseen cards instead of reading hidden hands directly. - `advanced` and `master` both use `CardTracker` to reason about unseen cards without directly reading hidden hands.
- The `master` tier performs determinization plus alpha-beta search and reports progress back through `AIDecisionProgress`. - The current `master` search profile is `timeBudgetMs: 4600`, `sampleCount: 10`, `maxDepth: 6`, `batchSize: 2`.
- `GameScene` displays AI progress through a top think bar and updates it from worker-forwarded progress messages. - `GameScene` consumes AI progress callbacks to update an on-screen think bar while a worker request is running.
- `AIWorkerClient` degrades cleanly to in-thread `chooseMove()` execution when workers are unavailable or fail. - `AIWorkerClient` fails over pending work to in-thread `chooseMove()` if worker creation, posting, or deserialization fails.
- Audio remains fully procedural via Web Audio; no audio asset pipeline is present. - The Android wrapper targets SDK 36 with `minSdkVersion` 24 and applies immersive mode from the native activity.
- No ESLint or Prettier config is present. - 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`. - The only repository-wide verification command supplied is `npx tsc --noEmit`.
## Potential Improvement Areas ## Potential Improvement Areas
- `GameScene.ts` still centralizes scene layout, input, effects, audio, HUD, and round transitions in one file, which raises maintenance cost. - `GameScene.ts` still centralizes layout, turn flow, HUD updates, effects, and audio in one scene class, which raises maintenance cost.
- `ai.ts` mixes heuristic tiers, inference helpers, determinization, and alpha-beta evaluation in one module. - `ai.ts` still combines 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. - Worker transport is isolated cleanly, but progress rendering remains coupled to scene-level UI concerns.
- The `master` profile allows up to 9800 ms of search budget, which may be expensive on slower devices even with batch yielding. - A 4600 ms master search budget may still be noticeable on slower mobile devices even with batch yielding.
- There is still no dedicated automated test suite for rules or AI behavior beyond type-checking. - There is no dedicated automated rules or AI test suite beyond type-checking.
- Formatting rules are enforced socially rather than by a linter/formatter toolchain. - Formatting and style are enforced socially rather than by automated linting or formatting tools.
## Current Rule / Implementation Notes ## 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. - Direct-match capture has priority over subset-sum capture.
- When multiple direct matches exist, `findCaptures()` returns one single-card option per matching card. - 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. - `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 ### 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. - `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. - `master` orders legal moves with a quick evaluator, samples hidden hands, and scores them with alpha-beta search under the active deadline.
- `masterMove()` yields back to the browser between batches so Phaser can repaint the progress UI. - Progress is reported through `AIDecisionProgress` so the scene can keep the think bar responsive.
### Worker execution snapshot ### Worker execution snapshot
- `GameScene` creates a fresh `AIWorkerClient` on scene creation and disposes it on shutdown. - `GameScene` creates `AIWorkerClient` during `create()` and disposes it on both `shutdown` and `destroy`.
- `AIWorkerClient` serializes a tracker snapshot instead of sending a live `CardTracker` instance across the worker boundary. - `AIWorkerClient` serializes `CardTracker` state through `toSnapshot()` instead of attempting to transfer the class instance.
- `ai.worker.ts` reconstructs tracker state with `CardTracker.fromSnapshot()` before calling `chooseMove()`. - `ai.worker.ts` rebuilds tracker state with `CardTracker.fromSnapshot()` before calling `chooseMove()`.
- Progress, results, and serialized worker errors all travel through `ai-worker-protocol.ts`. - Progress, result, and serialized error payloads 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. - If worker execution becomes unavailable, pending requests are rerun with the in-thread AI path rather than being dropped.
### Scene / UI implementation snapshot ### Scene / UI implementation snapshot
- `BootScene` loads atlas assets and presents a simple loading bar.
- `MenuScene` exposes difficulty selection before match start. - `MenuScene` exposes difficulty selection before match start.
- `GameScene` records every played card and captured table card in `CardTracker`. - `GameScene` tracks played and captured cards in `CardTracker` as the round evolves.
- The HUD continuously displays cards, denari, settebello, primiera, scope, and total points for both teams. - The scene owns score HUD rendering, player labels, status text, think-bar rendering, and procedural particle effects.
- Round-end and game-over flows are managed in-scene rather than through separate overlay components. - Round-end and match-end flows remain managed inside the scene instead of separate overlay components.
## Research Performed ## 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. - The current implementation does not use Phaser `TimerEvent` progress helpers.
- Instead, `chooseMove()` emits its own normalized progress payload through `AIDecisionProgress`. - Instead, `chooseMove()` emits its own normalized progress payload through `AIDecisionProgress`.
- `GameScene.updateThinkBar()` renders remaining time from that callback. - `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.

View File

@@ -1,5 +1,5 @@
import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS } from './types'; import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS, DealerRelativeRole } from './types';
import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState } from './engine'; import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState, getDealerRelativeRole } from './engine';
import { CardTracker } from './card-tracker'; import { CardTracker } from './card-tracker';
export interface AIMove { export interface AIMove {
@@ -22,10 +22,59 @@ interface SearchProfile {
batchSize: number; 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<DealerRelativeRole, Omit<DealerRoleContext, 'role' | 'onDealerSide' | 'defendingDealerAdvantage' | 'attackingDealerAdvantage'>> = {
'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<Difficulty, SearchProfile> = { const SEARCH_PROFILES: Record<Difficulty, SearchProfile> = {
beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 }, beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 },
advanced: { timeBudgetMs: 650, 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; 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<number>();
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<number>();
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 { function getSearchProfile(state: GameState, difficulty: Difficulty): SearchProfile {
if (difficulty !== 'master') return SEARCH_PROFILES[difficulty]; if (difficulty !== 'master') return SEARCH_PROFILES[difficulty];
const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); 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) { 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) { if (cardsRemaining <= 12) {
return { timeBudgetMs: 9000, sampleCount: 16, maxDepth: 8, batchSize: 2 }; return { timeBudgetMs: 4200, sampleCount: 8, maxDepth: 8, batchSize: 1 };
} }
if (cardsRemaining <= 20) { 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; return SEARCH_PROFILES.master;
} }
@@ -432,6 +655,8 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr
const table = state.table; const table = state.table;
const phase = gamePhase(state); const phase = gamePhase(state);
const race = getRaceState(state, playerIdx); const race = getRaceState(state, playerIdx);
const roleContext = getDealerRoleContext(state, playerIdx);
const parity = getParitySnapshot(tracker, player.hand, table);
const next = nextPlayer(playerIdx); const next = nextPlayer(playerIdx);
const nextIsOpp = isOpponent(playerIdx, next); const nextIsOpp = isOpponent(playerIdx, next);
const partner = partnerOf(playerIdx); const partner = partnerOf(playerIdx);
@@ -447,14 +672,14 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr
for (const captureSet of captures) { for (const captureSet of captures) {
const score = scoreCaptureAdv( const score = scoreCaptureAdv(
card, captureSet, table, state, playerIdx, race, 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 }; } if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; }
} }
} else { } else {
const score = scoreDumpAdv( const score = scoreDumpAdv(
card, table, state, playerIdx, race, 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: [] }; } if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; }
} }
@@ -467,7 +692,7 @@ function scoreCaptureAdv(
played: Card, captured: Card[], table: Card[], state: GameState, played: Card, captured: Card[], table: Card[], state: GameState,
playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined,
myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number,
lastPlay: boolean, lastPlay: boolean, roleContext: DealerRoleContext, parity: ParitySnapshot | null,
): number { ): number {
let score = 100; let score = 100;
const allCaptured = [played, ...captured]; const allCaptured = [played, ...captured];
@@ -508,6 +733,8 @@ function scoreCaptureAdv(
} }
} }
score += scoreCaptureParityPlan(played, captured, afterTable, parity, roleContext, nextIsOpp);
// --- ANCHOR STRATEGY --- // --- ANCHOR STRATEGY ---
// Prefer captures that leave table cards matching values we hold (we can recapture) // Prefer captures that leave table cards matching values we hold (we can recapture)
if (!isScopa) { if (!isScopa) {
@@ -543,6 +770,8 @@ function scoreCaptureAdv(
} }
} }
score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp);
// --- PARTNER COOPERATION --- // --- PARTNER COOPERATION ---
const next = nextPlayer(playerIdx); const next = nextPlayer(playerIdx);
if (!isScopa && !isOpponent(playerIdx, next)) { if (!isScopa && !isOpponent(playerIdx, next)) {
@@ -620,6 +849,14 @@ function scoreCaptureAdv(
if (tableSum <= 5 && nextIsOpp) score -= 60; 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; return score;
} }
@@ -627,7 +864,7 @@ function scoreDumpAdv(
card: Card, table: Card[], state: GameState, card: Card, table: Card[], state: GameState,
playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined,
myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number,
lastPlay: boolean, lastPlay: boolean, roleContext: DealerRoleContext, parity: ParitySnapshot | null,
): number { ): number {
let score = 0; let score = 0;
const afterTable = [...table, card]; const afterTable = [...table, card];
@@ -653,6 +890,8 @@ function scoreDumpAdv(
const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table); const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table);
if (partnerProb > 0.4) score += 55; // partner can recapture what we dump if (partnerProb > 0.4) score += 55; // partner can recapture what we dump
score += scoreDumpParityPlan(card, afterTable, parity, roleContext, nextIsOpp);
// --- ANTI-SCOPA --- // --- ANTI-SCOPA ---
if (tableSum >= 11) { if (tableSum >= 11) {
score += 150; score += 150;
@@ -694,6 +933,8 @@ function scoreDumpAdv(
score += 20; // safe dump before partner's turn, signals we don't need this suit score += 20; // safe dump before partner's turn, signals we don't need this suit
} }
score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp);
// --- CARD TRACKING --- // --- CARD TRACKING ---
if (tracker) { if (tracker) {
const unseen = tracker.getUnseenCards(myHand, afterTable); const unseen = tracker.getUnseenCards(myHand, afterTable);
@@ -729,6 +970,13 @@ function scoreDumpAdv(
if (card.value >= 8) score += 15; 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; return score;
} }
@@ -744,6 +992,8 @@ function tableControlPressure(
tracker: CardTracker | undefined, tracker: CardTracker | undefined,
myHand: Card[], myHand: Card[],
race: RaceState, race: RaceState,
roleContext: DealerRoleContext,
parity: ParitySnapshot | null,
): number { ): number {
if (afterTable.length === 0) return 0; if (afterTable.length === 0) return 0;
@@ -790,6 +1040,8 @@ function tableControlPressure(
} }
if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60; if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60;
score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp);
score += scoreParityTableState(afterTable, parity, roleContext, nextIsOpp);
return score; return score;
} }
@@ -814,9 +1066,11 @@ async function masterMove(
// Quick-eval move ordering for better pruning // Quick-eval move ordering for better pruning
const lastPlay = isLastPlay(state, playerIdx); const lastPlay = isLastPlay(state, playerIdx);
const race = getRaceState(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 => ({ const quickScored = legalMoves.map(m => ({
move: 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); quickScored.sort((a, b) => b.quick - a.quick);
const sortedMoves = quickScored.map(qs => qs.move); const sortedMoves = quickScored.map(qs => qs.move);
@@ -891,15 +1145,19 @@ function quickEval(
move: AIMove, state: GameState, playerIdx: PlayerIndex, move: AIMove, state: GameState, playerIdx: PlayerIndex,
tracker: CardTracker | undefined, lastPlay: boolean, tracker: CardTracker | undefined, lastPlay: boolean,
race: RaceState, race: RaceState,
roleContext: DealerRoleContext,
parity: ParitySnapshot | null,
): number { ): number {
let score = 0; let score = 0;
const table = state.table; 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 allCaptured = [move.card, ...move.capture];
const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx));
// Scopa (not on last play!) // 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; score += lastPlay ? 50 : 1200;
} }
@@ -924,21 +1182,35 @@ function quickEval(
} }
// Anti-scopa // Anti-scopa
if (afterTable.length > 0) { if (projectedTable.length > 0) {
const sum = afterTable.reduce((s, c) => s + c.value, 0); const sum = projectedTable.reduce((s, c) => s + c.value, 0);
if (sum <= 10 && nextIsOpp) score -= 180; if (sum <= 10 && nextIsOpp) score -= 180;
if (sum >= 11) score += 60; if (sum >= 11) score += 60;
if (afterTable.length === 1 && nextIsOpp) score -= 120; if (projectedTable.length === 1 && nextIsOpp) score -= 120;
} }
// Partner awareness // Partner awareness
const next = nextPlayer(playerIdx); const next = nextPlayer(playerIdx);
if (!isOpponent(playerIdx, next) && afterTable.length > 0) { if (!isOpponent(playerIdx, next) && projectedTable.length > 0) {
const sum = afterTable.reduce((s, c) => s + c.value, 0); const sum = projectedTable.reduce((s, c) => s + c.value, 0);
if (sum >= 1 && sum <= 10) score += 40; // partner might scopa 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; return score;
} }
@@ -1036,20 +1308,22 @@ function alphaBeta(
tracker: CardTracker | undefined, tracker: CardTracker | undefined,
): number { ): number {
if (depth === 0 || state.roundOver || Date.now() > deadline) { 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 cur = state.currentPlayer;
const isMyTeam = teamOf(cur) === myTeam; const isMyTeam = teamOf(cur) === myTeam;
const moves = getLegalMoves(state, cur); 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 // Move ordering: settebello captures first, then scopa, then captures by size, then dumps
if (moves.length > 2) { if (moves.length > 2) {
const race = getRaceState(state, cur); const race = getRaceState(state, cur);
const lastPlay = isLastPlay(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) { if (isMyTeam) {
@@ -1076,12 +1350,20 @@ function alphaBeta(
} }
/** Fast evaluation: avoids flatMap/filter at every leaf node */ /** 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 p0 = state.players[0], p1 = state.players[1], p2 = state.players[2], p3 = state.players[3];
const myA = myTeam === 0 ? p0 : p1; const myA = myTeam === 0 ? p0 : p1;
const myB = myTeam === 0 ? p2 : p3; const myB = myTeam === 0 ? p2 : p3;
const oppA = myTeam === 0 ? p1 : p0; const oppA = myTeam === 0 ? p1 : p0;
const oppB = myTeam === 0 ? p3 : p2; 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 // Single-pass pile scan — no flatMap/filter allocations
let myCards = 0, oppCards = 0; 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 // Good: table has cards we can capture
score += state.table.length * 5; 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; return score;

View File

@@ -4,12 +4,35 @@ export interface CardTrackerSnapshot {
playedCardIds: string[]; playedCardIds: string[];
} }
export interface CardTrackerValueParityResidue {
value: number;
knownCount: number;
unseenCount: number;
hasOddUnseenResidue: boolean;
hasEvenUnseenResidue: boolean;
}
interface VisibleValueResidueKnowledge {
unseenCards: Card[];
unseenCountBySuit: Record<Suit, number>;
unseenCountByValue: number[];
valueParityResidues: CardTrackerValueParityResidue[];
}
function normalizeSnapshot(snapshot: CardTrackerSnapshot): CardTrackerSnapshot { function normalizeSnapshot(snapshot: CardTrackerSnapshot): CardTrackerSnapshot {
return { return {
playedCardIds: Array.from(new Set(snapshot.playedCardIds)), playedCardIds: Array.from(new Set(snapshot.playedCardIds)),
}; };
} }
function createEmptySuitCounts(): Record<Suit, number> {
const counts = {} as Record<Suit, number>;
for (const suit of SUITS) {
counts[suit] = 0;
}
return counts;
}
/** /**
* Tracks which cards have been played/captured during a round. * Tracks which cards have been played/captured during a round.
* Used by AI to infer opponent hands WITHOUT cheating. * Used by AI to infer opponent hands WITHOUT cheating.
@@ -65,44 +88,96 @@ export class CardTracker {
return !this.played.has('denara_7'); return !this.played.has('denara_7');
} }
private buildVisibleValueResidueKnowledge(myHand: Card[], table: Card[]): VisibleValueResidueKnowledge {
const knownCardIds = new Set<string>(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. * Get cards that could be in opponent hands.
* = full 40-card deck minus: already played, my hand, currently on table * = full 40-card deck minus: already played, my hand, currently on table
*/ */
getUnseenCards(myHand: Card[], table: Card[]): Card[] { getUnseenCards(myHand: Card[], table: Card[]): Card[] {
const known = new Set<string>(); return this.buildVisibleValueResidueKnowledge(myHand, table).unseenCards;
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;
} }
/** Count how many cards of a suit are still unseen */ /** Count how many cards of a suit are still unseen */
countRemainingSuit(suit: Suit, myHand: Card[], table: Card[]): number { 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 */ /** Count how many unseen cards share a value */
countRemainingValue(value: number, myHand: Card[], table: Card[]): number { 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 */ /** Probability that a hidden hand contains at least one card with the requested value */
probabilityHandHasValue(value: number, handSize: number, myHand: Card[], table: Card[]): number { probabilityHandHasValue(value: number, handSize: number, myHand: Card[], table: Card[]): number {
if (handSize <= 0) return 0; if (handSize <= 0) return 0;
const unseen = this.getUnseenCards(myHand, table); const visibleValueResidueKnowledge = this.buildVisibleValueResidueKnowledge(myHand, table);
const matching = unseen.filter(c => c.value === value).length; const unseen = visibleValueResidueKnowledge.unseenCards;
const matching = visibleValueResidueKnowledge.unseenCountByValue[value] ?? 0;
if (matching === 0) return 0; if (matching === 0) return 0;
if (handSize >= unseen.length) return 1; if (handSize >= unseen.length) return 1;

View File

@@ -1,6 +1,6 @@
import { import {
Card, Suit, SUITS, Player, PlayerIndex, GameState, Card, Suit, SUITS, Player, PlayerIndex, GameState,
TeamScore, ScoreBreakdown, PRIMIERA_VALUES, Capture TeamScore, ScoreBreakdown, PRIMIERA_VALUES, Capture, DealerRelativeRole
} from './types'; } from './types';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -76,8 +76,38 @@ export function canCapture(played: Card, table: Card[]): boolean {
// Game state initialisation // 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 deck = shuffle(buildDeck());
const startingPlayer = getOpeningPlayerForDealer(dealer);
const players: [Player, Player, Player, Player] = [ const players: [Player, Player, Player, Player] = [
{ index: 0, hand: [], pile: [], scope: 0, isHuman: true, name: 'Tu' }, { index: 0, hand: [], pile: [], scope: 0, isHuman: true, name: 'Tu' },
@@ -102,6 +132,7 @@ export function createInitialState(startingPlayer: PlayerIndex = 0): GameState {
players, players,
table, table,
matchStartingPlayer: startingPlayer, matchStartingPlayer: startingPlayer,
dealer,
currentPlayer: startingPlayer, currentPlayer: startingPlayer,
roundOver: false, roundOver: false,
gameOver: false, gameOver: false,
@@ -164,7 +195,7 @@ export function applyMove(
} }
// Advance turn // Advance turn
state2.currentPlayer = ((playerIdx + 1) % 4) as PlayerIndex; state2.currentPlayer = nextPlayer(playerIdx);
// Check if round is over (all hands empty) // Check if round is over (all hands empty)
const allHandsEmpty = state2.players.every(p => p.hand.length === 0); 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), table: state.table.map(cloneCard),
matchStartingPlayer: state.matchStartingPlayer, matchStartingPlayer: state.matchStartingPlayer,
dealer: state.dealer,
currentPlayer: state.currentPlayer, currentPlayer: state.currentPlayer,
roundOver: state.roundOver, roundOver: state.roundOver,
gameOver: state.gameOver, gameOver: state.gameOver,

View File

@@ -14,6 +14,8 @@ export interface Capture {
export type PlayerIndex = 0 | 1 | 2 | 3; export type PlayerIndex = 0 | 1 | 2 | 3;
export type DealerRelativeRole = 'first-hand' | 'second-hand' | 'third-hand' | 'dealer';
export type Difficulty = 'beginner' | 'advanced' | 'master'; export type Difficulty = 'beginner' | 'advanced' | 'master';
export interface Player { export interface Player {
@@ -29,6 +31,7 @@ export interface GameState {
players: [Player, Player, Player, Player]; players: [Player, Player, Player, Player];
table: Card[]; table: Card[];
matchStartingPlayer: PlayerIndex; matchStartingPlayer: PlayerIndex;
dealer: PlayerIndex;
currentPlayer: PlayerIndex; currentPlayer: PlayerIndex;
roundOver: boolean; roundOver: boolean;
gameOver: boolean; gameOver: boolean;

View File

@@ -1,7 +1,8 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { Card, PlayerIndex, GameState, Difficulty } from '../game/types'; import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
import { import {
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome,
nextPlayer
} from '../game/engine'; } from '../game/engine';
import { AIDecisionProgress } from '../game/ai'; import { AIDecisionProgress } from '../game/ai';
import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client'; import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client';
@@ -130,8 +131,8 @@ export class GameScene extends Phaser.Scene {
this.input.once('pointerdown', () => this.startMusic()); this.input.once('pointerdown', () => this.startMusic());
const startingPlayer = Phaser.Math.Between(0, 3) as PlayerIndex; const initialDealer = Phaser.Math.Between(0, 3) as PlayerIndex;
this.state = createInitialState(startingPlayer); this.state = createInitialState(initialDealer);
this.dealAnimation(() => { this.dealAnimation(() => {
this.updateScoreBar(); this.updateScoreBar();
this.nextTurn(); this.nextTurn();
@@ -1434,11 +1435,11 @@ export class GameScene extends Phaser.Scene {
const totals = this.state.teamScores.map(t => t.totalPoints); const totals = this.state.teamScores.map(t => t.totalPoints);
const nextRound = (this.state.roundNumber ?? 1) + 1; const nextRound = (this.state.roundNumber ?? 1) + 1;
const matchStartingPlayer = this.state.matchStartingPlayer; 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(); for (const img of this.cardImages.values()) img.destroy();
this.cardImages.clear(); this.cardImages.clear();
this.tracker.reset(); this.tracker.reset();
this.state = createInitialState(startingPlayer); this.state = createInitialState(nextDealer);
this.state.matchStartingPlayer = matchStartingPlayer; this.state.matchStartingPlayer = matchStartingPlayer;
this.state.teamScores[0].totalPoints = totals[0]; this.state.teamScores[0].totalPoints = totals[0];
this.state.teamScores[1].totalPoints = totals[1]; this.state.teamScores[1].totalPoints = totals[1];