feat: implement bot intelligence system with 8 archetypes and coaching
Add personality-driven bots with 8 archetypes (Nit, TAG, LAG, Maniac, Calling Station, Loose Fish, Old Man, Monster TAG) across 5 skill levels. Includes: - Three-layer decision pipeline (base strategy → personality filter → skill noise) - Decision timer system with archetype-specific timeout defaults - Observation tracking engine (VPIP, PFR, Fold-to-CBet, WTSD, bet sizing, timing tells) - Player classification engine with weighted scoring and confidence scaling - Table setup UI with visual seat editor and quick presets - Info display system with 4 visibility levels - Teaching coach with post-hand analysis and real-time suggestions Archives bot-intelligence change and syncs all 8 delta specs to main specs.
This commit is contained in:
parent
a07117efaf
commit
422fa5b3ab
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-17
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Current bot implementation uses coin-flip decisions with no strategic reasoning, personality, or skill variation. The `PlayerSeat` type has basic fields (chips, status, cards) but no personality or skill attributes. Bot turns execute instantly with no timing consideration. No observation tracking exists — the player learns nothing about opponent behavior.
|
||||||
|
|
||||||
|
The game uses a functional state pattern: actions return new GameState objects. Bot decisions are invoked during turn processing in `turn.ts`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Replace coin-flip bot decisions with personality-driven, skill-aware decision engine
|
||||||
|
- Provide 8 distinct bot archetypes across 5 skill levels with realistic play patterns
|
||||||
|
- Enable player-configurable table setup with visual seat editor
|
||||||
|
- Track opponent statistics and classify player types automatically
|
||||||
|
- Offer configurable learning assistance (info levels, feedback, coaching)
|
||||||
|
- Implement sequential decision timer with timing tells
|
||||||
|
- Keep all logic client-side with no external dependencies
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Multiplayer networking (single-player vs bots only)
|
||||||
|
- Machine learning-based classification (rule-based for v1)
|
||||||
|
- Bot-to-bot adaptation (bots adapt to human player only)
|
||||||
|
- Tournament mode (cash game focus)
|
||||||
|
- Persistent cross-session memory (optional toggle in v2)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: Three-layer bot decision pipeline
|
||||||
|
|
||||||
|
**Chosen**: Base Strategy → Personality Filter → Skill Noise
|
||||||
|
|
||||||
|
```
|
||||||
|
GTO-informed baseline → Archetype bias injection → Mistake/noise layer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**: Separates concerns cleanly. Base strategy computed once, personality reshapes probabilities, skill injects errors. Easy to tune each layer independently and add new archetypes without rewriting core logic.
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- Lookup table per archetype × skill: Too many combinations (8 × 5 = 40 tables), hard to maintain
|
||||||
|
- ML model: Overkill for v1, requires training data, opaque decision process defeats teaching purpose
|
||||||
|
|
||||||
|
### Decision 2: Weighted scoring classification over pure rules
|
||||||
|
|
||||||
|
**Chosen**: Each archetype has a scoring function; stats contribute weighted points; highest score wins with confidence based on total.
|
||||||
|
|
||||||
|
**Rationale**: Handles edge cases naturally (e.g., VPIP=32% is clearly LAG-adjacent but rigid rules might miss it). Produces natural confidence percentages for UI. Tunable via weight adjustments.
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- Rule-based if/else: Brittle at boundaries, no confidence metric, confusing when misclassifies
|
||||||
|
- Naive Bayes classifier: More accurate but adds complexity, less transparent for teaching purposes
|
||||||
|
|
||||||
|
### Decision 3: Archetype-specific mistake libraries
|
||||||
|
|
||||||
|
**Chosen**: Each archetype has its own set of possible mistakes; skill level controls frequency. A Novice TAG folds too much; a Novice Fish calls too much — same skill, different errors.
|
||||||
|
|
||||||
|
**Rationale**: Realistic — bad players fail in ways consistent with their style. More educational for the player to observe pattern-consistent mistakes.
|
||||||
|
|
||||||
|
### Decision 4: Sequential timer with configurable duration
|
||||||
|
|
||||||
|
**Chosen**: Turn passes sequentially, each player gets independent countdown. Duration configurable (5-30s). Human can have same timer, no timer, or custom timer. Timeout triggers archetype-appropriate default action.
|
||||||
|
|
||||||
|
**Rationale**: Mimics online poker flow. Configurable duration accommodates different learning paces. Timeout defaults add realism (real players sometimes fold from inaction).
|
||||||
|
|
||||||
|
### Decision 5: Bet sizing tracked per street
|
||||||
|
|
||||||
|
**Chosen**: Track average bet sizes on pre-flop, flop, turn, and river separately. Flag patterns like "always bets 1/3 pot" or "polarized river betting."
|
||||||
|
|
||||||
|
**Rationale**: Different streets reveal different information. Pre-flop sizing shows aggression level, post-flop sizing reveals hand reading ability. Per-street tracking enables richer classification.
|
||||||
|
|
||||||
|
### Decision 6: Timing data as observable tell
|
||||||
|
|
||||||
|
**Chosen**: Record decision time for every action. Track fast vs slow distributions per action type (call/fold/raise/check). Skill level controls timing consistency — Novice has random timing, Ultra deliberately randomizes.
|
||||||
|
|
||||||
|
**Rationale**: Timing is a real poker skill. Teaches players to notice hesitation patterns. Adds depth without UI complexity (just track timestamps).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
[Complex decision engine] → Start with simplified base strategy (position-based hand ranking) rather than full GTO solver. Can be upgraded incrementally.
|
||||||
|
|
||||||
|
[Performance with many tracked stats] → Observation tracking is per-hand, not per-decision. Stats update once per completed hand, keeping computation minimal.
|
||||||
|
|
||||||
|
[Classification accuracy in early hands] → System shows "insufficient data" until minimum sample size reached (~10 hands). Confidence starts low and increases. Player learns patience.
|
||||||
|
|
||||||
|
[UI complexity from many settings] → Table setup uses progressive disclosure: basic presets first, advanced options expandable. Info/feedback settings use simple dropdowns.
|
||||||
|
|
||||||
|
[Mistake injection feeling unrealistic] → Mistakes are probabilistic, not deterministic. Same bot plays differently each game. Testing with human review to calibrate feel.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No migration needed — this is new functionality added to an early-stage project (v0.0.1). Existing bot logic will be replaced entirely. The `PlayerSeat` type will extend with personality/skill fields, but existing fields remain unchanged for backward compatibility.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the base strategy use a simplified GTO approximation or position-based hand ranking charts? Hand charts are simpler to implement and debug.
|
||||||
|
- How granular should bet sizing tracking be? (e.g., track exact percentages vs bucket into "small/medium/large")
|
||||||
|
- Should timeout actions feel like mistakes (count against skill) or neutral decisions?
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Bots currently make random coin-flip decisions, providing no strategic depth or learning value. Players need realistic bot opponents with distinct personalities, skill levels, and exploitable patterns — turning PokeR from a basic poker simulator into a player reading training ground.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Bot personality system**: 8 distinct player archetypes (Nit, TAG, LAG, Maniac, Calling Station, Loose Fish, Old Man, Monster TAG) each with unique decision-making profiles
|
||||||
|
- **Skill levels**: 5 difficulty tiers (Novice, Beginner, Medium, Hard, Ultra) per archetype, with archetype-specific mistake libraries
|
||||||
|
- **Table setup UI**: Visual seat editor letting players assign bot types and skill levels, with quick presets (Fish Table, Regular Grind, High Stakes, Training Mix)
|
||||||
|
- **Decision timer**: Sequential action timer with configurable duration (5-30s), optional human timer, timeout defaults per archetype
|
||||||
|
- **Observation engine**: Tracks VPIP, PFR, Fold-to-CBet, WTSD, bet sizing patterns, and timing data per opponent
|
||||||
|
- **Classification system**: Weighted scoring algorithm that infers player types from observed stats with confidence percentages
|
||||||
|
- **Info levels**: 4 tiers (None, Hints, Stats, Full Reveal) controlling what the player sees about opponents
|
||||||
|
- **Feedback system**: 3 tiers (Off, Post-hand, Real-time) providing coaching at the player's chosen intensity
|
||||||
|
- **Teaching moments**: Pattern recognition confirmations when players successfully exploit identified bot types
|
||||||
|
- **Timing tells**: Decision time tracked per action; archetype-specific timing patterns that vary by skill level
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `bot-personalities`: 8 bot archetypes with personality-driven decision filters and archetype-specific mistake libraries across 5 skill levels
|
||||||
|
- `table-setup`: Configurable table builder UI with seat-level bot type/skill selection, presets, and global settings (blinds, stack sizes)
|
||||||
|
- `decision-timer`: Sequential action timer system with configurable duration, timeout defaults per archetype, and optional human timer
|
||||||
|
- `observation-tracking`: Per-bot stat tracking (VPIP, PFR, Fold-to-CBet, WTSD, bet sizing, timing) across hand history
|
||||||
|
- `player-classification`: Weighted scoring algorithm that classifies observed bots into archetypes with confidence scores
|
||||||
|
- `info-display`: Configurable info level system controlling what opponent data the player sees during gameplay
|
||||||
|
- `teaching-coach`: Context-sensitive coaching system with post-hand analysis, real-time suggestions, and pattern recognition confirmations
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `gameplay-core`: Bot decision flow changes from random coin-flip to personality × skill × GTO base pipeline; bot turn handling integrates with new timer system
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Affected files**: `src/lib/types/player.ts` (add personality/skill fields), `src/lib/game/state.ts` (observation tracking state), `src/lib/game/actions.ts` (bot decision integration), `src/lib/game/turn.ts` (timer integration)
|
||||||
|
- **New modules**: Bot decision engine, observation tracker, classification algorithm, teaching coach, table setup component
|
||||||
|
- **No external dependencies**: All logic implemented in TypeScript within the existing SvelteKit project
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Eight distinct bot archetypes exist
|
||||||
|
The system SHALL provide 8 bot archetypes: Nit, TAG (Tight-Aggressive), LAG (Loose-Aggressive), Maniac, Calling Station, Loose Fish, Old Man (Tight-Passive), and Monster TAG. Each archetype SHALL have a defined personality profile specifying characteristic VPIP range, PFR range, bluff frequency, and aggression pattern.
|
||||||
|
|
||||||
|
#### Scenario: Archetype list is complete
|
||||||
|
- **WHEN** the system initializes the bot personality registry
|
||||||
|
- **THEN** all 8 archetypes are available for assignment to bot seats
|
||||||
|
|
||||||
|
#### Scenario: Each archetype has defined statistical profile
|
||||||
|
- **WHEN** querying an archetype's personality profile
|
||||||
|
- **THEN** the profile includes VPIP range, PFR range, bluff frequency, and aggression pattern
|
||||||
|
|
||||||
|
### Requirement: Five skill levels per archetype
|
||||||
|
The system SHALL provide 5 skill levels: Novice, Beginner, Medium, Hard, and Ultra. Each skill level SHALL define an error rate range and a set of enabled behavioral capabilities.
|
||||||
|
|
||||||
|
#### Scenario: Skill levels span full difficulty range
|
||||||
|
- **WHEN** querying available skill levels
|
||||||
|
- **THEN** Novice through Ultra are returned with increasing competence
|
||||||
|
|
||||||
|
#### Scenario: Error rates scale with skill
|
||||||
|
- **WHEN** comparing error rates between adjacent skill levels
|
||||||
|
- **THEN** higher skill levels have lower error rates (Ultra < Hard < Medium < Beginner < Novice)
|
||||||
|
|
||||||
|
### Requirement: Archetype-specific mistake libraries
|
||||||
|
The system SHALL maintain a mistake library per archetype. Each archetype's mistakes SHALL be thematically consistent with its play style (e.g., Nit mistakes involve folding too much, Fish mistakes involve calling too much). A bot's skill level SHALL determine the frequency at which mistakes from its library are triggered.
|
||||||
|
|
||||||
|
#### Scenario: Novice TAG makes tight-player mistakes
|
||||||
|
- **WHEN** a Novice TAG bot faces a decision where a mistake could occur
|
||||||
|
- **THEN** the possible mistakes include folding too much and missing value bets, not calling too wide
|
||||||
|
|
||||||
|
#### Scenario: Novice Fish makes loose-player mistakes
|
||||||
|
- **WHEN** a Novice Fish bot faces a decision where a mistake could occur
|
||||||
|
- **THEN** the possible mistakes include calling with weak hands and falling for bluffs, not folding too tight
|
||||||
|
|
||||||
|
#### Scenario: Ultra bots rarely make mistakes
|
||||||
|
- **WHEN** an Ultra-skill bot plays 100 hands
|
||||||
|
- **THEN** fewer than 5 decisions are influenced by mistake injection
|
||||||
|
|
||||||
|
### Requirement: Three-layer decision pipeline
|
||||||
|
The system SHALL compute bot decisions through a three-layer pipeline: (1) base strategy produces GTO-informed baseline probabilities, (2) personality filter reshapes probabilities according to archetype traits, (3) skill noise layer injects mistakes proportional to the bot's error rate. The final decision SHALL be sampled from the resulting probability distribution.
|
||||||
|
|
||||||
|
#### Scenario: Base strategy provides starting point
|
||||||
|
- **WHEN** a bot holds premium cards in late position with no prior action
|
||||||
|
- **THEN** the base strategy assigns high probability to raising
|
||||||
|
|
||||||
|
#### Scenario: Personality filter adjusts for archetype
|
||||||
|
- **WHEN** a Nit bot evaluates the same hand as an LAG bot
|
||||||
|
- **THEN** the Nit's raise probability is lower due to tighter range filtering
|
||||||
|
|
||||||
|
#### Scenario: Skill noise degrades decisions for lower skill
|
||||||
|
- **WHEN** a Novice bot makes a decision through the full pipeline
|
||||||
|
- **THEN** the final action may differ from the optimal base strategy action due to mistake injection
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Sequential action timer with countdown display
|
||||||
|
The system SHALL enforce sequential turn order where each active player receives an independent countdown timer. The timer duration SHALL be configurable (5-30 seconds, default 10s). A visual countdown indicator SHALL be displayed for the current acting player. When the timer reaches zero, the system SHALL execute a timeout action.
|
||||||
|
|
||||||
|
#### Scenario: Timer counts down for current player
|
||||||
|
- **WHEN** it is Bot #2's turn to act and timer is set to 10s
|
||||||
|
- **THEN** a countdown from 10 to 0 is displayed, then turn advances to next active player
|
||||||
|
|
||||||
|
#### Scenario: Timer expires triggers timeout action
|
||||||
|
- **WHEN** a bot's timer reaches zero before the bot makes a decision
|
||||||
|
- **THEN** the system executes the bot's archetype-appropriate timeout default action
|
||||||
|
|
||||||
|
### Requirement: Archetype-specific timeout defaults
|
||||||
|
The system SHALL define a default timeout action per archetype: Nit folds, TAG checks or folds, LAG calls, Maniac raises, Calling Station calls, Loose Fish calls, Old Man calls, Monster TAG checks. The timeout action SHALL be recorded in the action history and counted as a mistake for skill assessment.
|
||||||
|
|
||||||
|
#### Scenario: Nit times out by folding
|
||||||
|
- **WHEN** a Nit bot's timer expires during a betting round
|
||||||
|
- **THEN** the bot folds and the turn advances to the next active player
|
||||||
|
|
||||||
|
#### Scenario: Maniac times out by raising
|
||||||
|
- **WHEN** a Maniac bot's timer expires and raising is a valid action
|
||||||
|
- **THEN** the bot makes a raise and the turn advances
|
||||||
|
|
||||||
|
### Requirement: Optional human player timer
|
||||||
|
The system SHALL support three human timer modes: "Same as bots" (human gets same duration as bot timer), "No limit" (human has unlimited time), or "Custom" (player specifies duration). In No Limit mode, no countdown is displayed for the human. Timer mode SHALL be configurable in table setup.
|
||||||
|
|
||||||
|
#### Scenario: Human plays with no timer
|
||||||
|
- **WHEN** human timer is set to "No limit" and it is the human's turn
|
||||||
|
- **THEN** no countdown appears and the human may take unlimited time to decide
|
||||||
|
|
||||||
|
#### Scenario: Human shares bot timer duration
|
||||||
|
- **WHEN** human timer is set to "Same as bots" with 10s bot timer
|
||||||
|
- **THEN** the human has a 10-second countdown when it is their turn
|
||||||
|
|
||||||
|
### Requirement: Timing data recorded for observation
|
||||||
|
The system SHALL record the decision time (in seconds) for every action taken by every player. The recorded timing data SHALL be available to the observation tracking system and classification engine. Decision time SHALL be measured from when the timer starts for a player to when they commit an action.
|
||||||
|
|
||||||
|
#### Scenario: Fast action is recorded
|
||||||
|
- **WHEN** Bot #3 calls within 1.5 seconds of their timer starting
|
||||||
|
- **THEN** the action history records decision_time: 1.5 for that action
|
||||||
|
|
||||||
|
#### Scenario: Slow hesitation is recorded
|
||||||
|
- **WHEN** Bot #5 takes 8.2 seconds to decide before folding
|
||||||
|
- **THEN** the action history records decision_time: 8.2 for that fold
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Bot turn decision execution
|
||||||
|
**WHEN** it is a bot player's turn to act
|
||||||
|
**THEN** the system SHALL invoke the bot decision engine (base strategy → personality filter → skill noise) to compute the action, execute the action through the existing action application functions, and advance the turn to the next active player
|
||||||
|
|
||||||
|
#### Scenario: Bot makes personality-driven decision
|
||||||
|
- **WHEN** it is a TAG bot's turn with a medium-strength hand in late position
|
||||||
|
- **THEN** the bot raises according to TAG personality profile rather than making a random choice
|
||||||
|
|
||||||
|
[MODIFIED from: "WHEN it is a bot's turn, the system SHALL randomly select an available action"]
|
||||||
|
|
||||||
|
#### Scenario: Bot decision respects timer constraints
|
||||||
|
- **WHEN** timer is enabled and it is a bot's turn
|
||||||
|
- **THEN** the bot makes its decision within the configured timer duration, with decision time recorded for observation tracking
|
||||||
|
|
||||||
|
[ADDED requirement not in original spec]
|
||||||
|
|
||||||
|
### Requirement: PlayerSeat type includes personality data
|
||||||
|
The system SHALL extend the PlayerSeat type to include `personality` (bot archetype enum) and `skillLevel` (skill tier enum) fields. Human players SHALL have these fields set to null or a default human value. Existing fields (id, name, chips, currentBet, betMatched, status, holeCards, position) SHALL remain unchanged.
|
||||||
|
|
||||||
|
#### Scenario: Bot seat includes personality data
|
||||||
|
- **WHEN** a bot player is created with LAG archetype at Hard skill
|
||||||
|
- **THEN** the PlayerSeat object has personality: "LAG" and skillLevel: "Hard"
|
||||||
|
|
||||||
|
[ADDED requirement not in original spec]
|
||||||
|
|
||||||
|
#### Scenario: Human seat has no bot personality
|
||||||
|
- **WHEN** the human player's PlayerSeat is created
|
||||||
|
- **THEN** personality and skillLevel fields are null or marked as human
|
||||||
|
|
||||||
|
[ADDED requirement not in original spec]
|
||||||
|
|
||||||
|
### Requirement: GameState tracks observation data
|
||||||
|
The system SHALL extend the GameState to include an `observationData` field containing per-player tracked statistics (VPIP, PFR, Fold-to-CBet, WTSD, bet sizing profiles, timing data). Observation data SHALL update after each completed hand and SHALL persist across hands within a session.
|
||||||
|
|
||||||
|
#### Scenario: Observation data accumulates across hands
|
||||||
|
- **WHEN** 10 hands have been played and observation tracking is active
|
||||||
|
- **THEN** GameState.observationData contains accumulated statistics for all bot players
|
||||||
|
|
||||||
|
[ADDED requirement not in original spec]
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Four info levels control opponent visibility
|
||||||
|
The system SHALL provide 4 info levels: None, Hints, Stats, and Full Reveal. The chosen level SHALL determine what information about opponents is displayed during gameplay. The info level SHALL be configurable in table setup and SHALL apply consistently throughout the session.
|
||||||
|
|
||||||
|
#### Scenario: None level hides all data
|
||||||
|
- **WHEN** info level is "None"
|
||||||
|
- **THEN** no statistics, type labels, or inferred classifications are shown for any opponent
|
||||||
|
|
||||||
|
#### Scenario: Hints level shows occasional contextual tips
|
||||||
|
- **WHEN** info level is "Hints" and a notable pattern has been detected for an opponent
|
||||||
|
- **THEN** a subtle hint appears (e.g., "Player 3 tends to play a lot of hands")
|
||||||
|
|
||||||
|
#### Scenario: Stats level shows raw numbers
|
||||||
|
- **WHEN** info level is "Stats"
|
||||||
|
- **THEN** VPIP, PFR, Fold-to-CBet, and WTSD are displayed for each opponent
|
||||||
|
|
||||||
|
#### Scenario: Full Reveal shows everything
|
||||||
|
- **WHEN** info level is "Full Reveal"
|
||||||
|
- **THEN** all statistics, inferred type with confidence, skill estimate, and detected patterns are shown
|
||||||
|
|
||||||
|
### Requirement: Info display updates as data accumulates
|
||||||
|
The system SHALL update the info display dynamically as new hand data becomes available. Statistics SHALL refresh after each completed hand. Classification results SHALL update when sufficient sample size is reached. At Hints level, new hints SHALL appear as new patterns are detected.
|
||||||
|
|
||||||
|
#### Scenario: Stats update after hand completion
|
||||||
|
- **WHEN** a hand completes and a new action history entry is recorded
|
||||||
|
- **THEN** all tracked statistics for involved players are recalculated and displayed values update
|
||||||
|
|
||||||
|
#### Scenario: Classification appears when threshold is met
|
||||||
|
- **WHEN** an opponent reaches 10 observed hands and the classification engine produces a result
|
||||||
|
- **THEN** the inferred type and confidence appear in the display (if info level permits)
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Core statistical tracking per opponent
|
||||||
|
The system SHALL track the following statistics for each opponent across all hands in a session: VPIP (Voluntary Put Money In Pot), PFR (Pre-Flop Raise), Fold-to-CBet (fold percentage to continuation bets), and WTSD (Went To Showdown percentage). Statistics SHALL update after each completed hand and SHALL be accessible to the classification engine.
|
||||||
|
|
||||||
|
#### Scenario: VPIP tracks hand participation
|
||||||
|
- **WHEN** a bot voluntarily puts money in the pot in 35 out of 100 hands
|
||||||
|
- **THEN** the bot's VPIP is recorded as 35%
|
||||||
|
|
||||||
|
#### Scenario: PFR tracks pre-flop aggression
|
||||||
|
- **WHEN** a bot raises pre-flop in 20 out of 100 hands dealt
|
||||||
|
- **THEN** the bot's PFR is recorded as 20%
|
||||||
|
|
||||||
|
### Requirement: Bet sizing tracking per street
|
||||||
|
The system SHALL track average bet sizes separately for each betting street (pre-flop, flop, turn, river). For each street, the system SHALL record the average bet as a percentage of the pot and flag notable patterns (e.g., consistently small bets, polarized sizing, frequent overbets).
|
||||||
|
|
||||||
|
#### Scenario: Small bet pattern is detected
|
||||||
|
- **WHEN** a bot's average flop bet size is 30% of pot over 20 hands
|
||||||
|
- **THEN** the system flags this as "consistently small bets" pattern
|
||||||
|
|
||||||
|
#### Scenario: Polarized river betting is detected
|
||||||
|
- **WHEN** a bot's river bets are predominantly either min-bet or full-pot-or-larger
|
||||||
|
- **THEN** the system flags this as "polarized river betting" pattern
|
||||||
|
|
||||||
|
### Requirement: Timing tell tracking
|
||||||
|
The system SHALL track decision timing statistics per opponent: average decision time, distribution of fast actions (under 3s) vs slow actions (over 7s), and average timing broken down by action type (call, fold, raise, check). The system SHALL compute a timing consistency score indicating how reliably the bot's timing correlates with hand strength.
|
||||||
|
|
||||||
|
#### Scenario: Fast call pattern is identified
|
||||||
|
- **WHEN** a bot's average call time is 1.8s and average fold time is 8.4s over 30 decisions
|
||||||
|
- **THEN** the system records this as "fast calls, slow folds" timing profile
|
||||||
|
|
||||||
|
#### Scenario: Timing consistency reflects skill level
|
||||||
|
- **WHEN** a Medium-skill bot plays 50 hands
|
||||||
|
- **THEN** the timing consistency score is approximately 70-80%, reflecting mostly reliable tells with some noise
|
||||||
|
|
||||||
|
### Requirement: Observation data availability based on info level
|
||||||
|
The system SHALL make observation data available to the player only according to their chosen info level. At "None" level, no stats are displayed. At "Hints" level, occasional contextual hints reference observed patterns. At "Stats" level, raw VPIP/PFR numbers are visible. At "Full Reveal" level, all tracked statistics and inferred type are shown.
|
||||||
|
|
||||||
|
#### Scenario: Player sees nothing at None level
|
||||||
|
- **WHEN** info level is set to "None" and the player views an opponent's seat
|
||||||
|
- **THEN** no statistical data or type information is displayed
|
||||||
|
|
||||||
|
#### Scenario: Player sees raw stats at Stats level
|
||||||
|
- **WHEN** info level is set to "Stats" and the player views an opponent's seat
|
||||||
|
- **THEN** VPIP, PFR, Fold-to-CBet, and WTSD percentages are displayed
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Weighted scoring classification algorithm
|
||||||
|
The system SHALL classify each observed opponent using a weighted scoring algorithm. Each archetype SHALL have a scoring function that evaluates the opponent's tracked statistics (VPIP, PFR, Fold-to-CBet, WTSD) and assigns weighted points. The archetype with the highest score SHALL be the inferred type. The system SHALL compute a confidence percentage based on the score magnitude and sample size.
|
||||||
|
|
||||||
|
#### Scenario: LAG is correctly classified
|
||||||
|
- **WHEN** an opponent has VPIP=38%, PFR=25%, Fold-to-CBet=45%, WTSD=60% after 40 hands
|
||||||
|
- **THEN** the LAG scoring function produces the highest score and the bot is classified as LAG
|
||||||
|
|
||||||
|
#### Scenario: Calling Station is correctly classified
|
||||||
|
- **WHEN** an opponent has VPIP=42%, PFR=8%, Fold-to-CBet=15%, WTSD=75% after 30 hands
|
||||||
|
- **THEN** the Calling Station scoring function produces the highest score and the bot is classified as Fish
|
||||||
|
|
||||||
|
### Requirement: Confidence scales with sample size
|
||||||
|
The system SHALL require a minimum number of observed hands before producing a classification. With fewer than 10 hands, the system SHALL report "insufficient data." Between 10-20 hands, confidence SHALL be capped at 60%. Between 20-40 hands, confidence SHALL be capped at 80%. After 40+ hands, full confidence SHALL be calculated from score magnitude.
|
||||||
|
|
||||||
|
#### Scenario: Insufficient data shown early
|
||||||
|
- **WHEN** only 5 hands have been played against an opponent
|
||||||
|
- **THEN** the classification displays "Insufficient data" with no type assigned
|
||||||
|
|
||||||
|
#### Scenario: Confidence increases with more hands
|
||||||
|
- **WHEN** an opponent has been observed for 50 hands and scores strongly for TAG
|
||||||
|
- **THEN** confidence may reach 85-90% for a consistent Medium-skill bot
|
||||||
|
|
||||||
|
### Requirement: Classification difficulty scales with bot skill
|
||||||
|
The system SHALL adjust classification reliability based on the bot's skill level. Novice and Beginner bots SHALL produce clear, easily classifiable patterns (high confidence achieved quickly). Hard and Ultra bots SHALL produce ambiguous or mixed statistics that reduce achievable confidence and may require 70+ hands for reliable classification.
|
||||||
|
|
||||||
|
#### Scenario: Novice bot is quickly classified
|
||||||
|
- **WHEN** a Novice LAG has played 12 hands with extreme loose-aggressive patterns
|
||||||
|
- **THEN** the system classifies them as LAG with 60% confidence (capped by sample size)
|
||||||
|
|
||||||
|
#### Scenario: Ultra bot resists easy classification
|
||||||
|
- **WHEN** an Ultra TAG has played 40 hands with balanced, position-dependent play
|
||||||
|
- **THEN** the system may show lower confidence (~65%) due to overlapping stat ranges
|
||||||
|
|
||||||
|
### Requirement: Secondary pattern detection
|
||||||
|
The system SHALL detect secondary behavioral patterns beyond core statistics, including bet sizing tendencies (small/standard/polarized), timing tells (fast calls vs slow folds), and bluff frequency estimation. These patterns SHALL supplement the primary archetype classification and be available for teaching hints.
|
||||||
|
|
||||||
|
#### Scenario: Bet sizing pattern is detected
|
||||||
|
- **WHEN** a bot consistently bets 30% of pot on all post-flop streets over 25 hands
|
||||||
|
- **THEN** the system flags "characteristically small bet sizes" as a secondary pattern
|
||||||
|
|
||||||
|
#### Scenario: Timing tell is identified
|
||||||
|
- **WHEN** a bot's calls average 1.5s and folds average 8.5s over 30 decisions
|
||||||
|
- **THEN** the system flags "fast on comfort, slow on discomfort" as a timing tell
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Visual seat editor for table configuration
|
||||||
|
The system SHALL provide a visual table layout showing all seats. The player SHALL be able to click any bot seat and select an archetype and skill level from dropdown controls. The human player's seat SHALL be fixed and non-configurable.
|
||||||
|
|
||||||
|
#### Scenario: Player assigns bot type to a seat
|
||||||
|
- **WHEN** the player clicks a bot seat and selects "LAG" at "Hard" difficulty
|
||||||
|
- **THEN** that seat displays the LAG archetype label and Hard skill indicator
|
||||||
|
|
||||||
|
#### Scenario: Human seat cannot be reassigned
|
||||||
|
- **WHEN** the player attempts to change the human player's seat configuration
|
||||||
|
- **THEN** the system prevents the change or grays out the controls
|
||||||
|
|
||||||
|
### Requirement: Quick presets for common table compositions
|
||||||
|
The system SHALL provide at least 4 predefined table presets: Fish Table (all Calling Stations, Novice-Beginner), Regular Grind (mix of TAGs and LAGs, Medium-Hard), High Stakes (Ultra/Hard TAGs and LAGs), and Training Mix (one of each type across skill levels). Selecting a preset SHALL populate all bot seats automatically.
|
||||||
|
|
||||||
|
#### Scenario: Fish Table preset configures easy opponents
|
||||||
|
- **WHEN** the player selects "Fish Table" preset
|
||||||
|
- **THEN** all bot seats are assigned Calling Station or Loose Fish archetypes at Novice-Beginner skill
|
||||||
|
|
||||||
|
#### Scenario: Training Mix preset provides variety
|
||||||
|
- **WHEN** the player selects "Training Mix" preset
|
||||||
|
- **THEN** each bot seat receives a different archetype at varying skill levels
|
||||||
|
|
||||||
|
### Requirement: Global table settings
|
||||||
|
The system SHALL allow configuring global table parameters: blind structure (small/big blind values), starting stack size, number of players (heads-up through 9-max), timer duration, and timer enable/disable. Settings SHALL apply to all seats uniformly.
|
||||||
|
|
||||||
|
#### Scenario: Player sets custom blinds
|
||||||
|
- **WHEN** the player enters 50/100 for blinds
|
||||||
|
- **THEN** all hands at the table use 50/100 blind structure
|
||||||
|
|
||||||
|
#### Scenario: Player adjusts player count
|
||||||
|
- **WHEN** the player changes from 6-max to 9-max
|
||||||
|
- **THEN** the table layout updates to show additional seats with unassigned bot slots
|
||||||
|
|
||||||
|
### Requirement: Timer configuration per table setup
|
||||||
|
The system SHALL allow the player to configure timer behavior: enable/disable timer, set duration (5-30 seconds), and choose human timer mode (same as bots, no limit, or custom duration). Timer settings SHALL be saved with the table configuration.
|
||||||
|
|
||||||
|
#### Scenario: Player disables timer
|
||||||
|
- **WHEN** the player sets timer to "Off"
|
||||||
|
- **THEN** bot decisions execute instantly without countdown display
|
||||||
|
|
||||||
|
#### Scenario: Player sets custom human timer
|
||||||
|
- **WHEN** the player sets human timer to "Custom: 30s" while bot timer is "10s"
|
||||||
|
- **THEN** bots have 10 seconds per decision and the human has 30 seconds per decision
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Three feedback levels control coaching intensity
|
||||||
|
The system SHALL provide 3 feedback levels: Off, Post-hand, and Real-time. At "Off," no coaching is provided. At "Post-hand," a brief analysis appears after each completed hand. At "Real-time," contextual suggestions appear during the player's decision-making phase. The feedback level SHALL be configurable in table setup.
|
||||||
|
|
||||||
|
#### Scenario: Off provides no coaching
|
||||||
|
- **WHEN** feedback level is "Off" and the player completes a hand
|
||||||
|
- **THEN** no analysis, suggestion, or teaching content is displayed
|
||||||
|
|
||||||
|
#### Scenario: Post-hand shows analysis after completion
|
||||||
|
- **WHEN** feedback level is "Post-hand" and a hand completes with the human involved
|
||||||
|
- **THEN** an analysis panel appears summarizing key decisions, opponent reads, and suggested improvements
|
||||||
|
|
||||||
|
#### Scenario: Real-time shows suggestions during play
|
||||||
|
- **WHEN** feedback level is "Real-time" and it is the human's turn to act
|
||||||
|
- **THEN** a contextual suggestion may appear (e.g., "Player 2 folds 65% to raises — good bluff spot")
|
||||||
|
|
||||||
|
### Requirement: Teaching respects player info preferences
|
||||||
|
The system SHALL ensure that coaching content does not reveal more information than the player's chosen info level permits. At "None" info level, real-time suggestions SHALL be generic and not reference specific opponent statistics. At "Hints" level, suggestions MAY reference detected patterns but SHALL not show raw numbers.
|
||||||
|
|
||||||
|
#### Scenario: Coaching respects None info level
|
||||||
|
- **WHEN** info level is "None" and feedback is "Real-time"
|
||||||
|
- **THEN** suggestions are general strategic advice without referencing opponent stats or types
|
||||||
|
|
||||||
|
#### Scenario: Coaching respects Hints info level
|
||||||
|
- **WHEN** info level is "Hints" and a pattern has been detected for Player 3
|
||||||
|
- **THEN** a suggestion may say "Player 3 plays many hands — consider raising more against them" without showing VPIP numbers
|
||||||
|
|
||||||
|
### Requirement: Pattern recognition confirmations reward correct reads
|
||||||
|
The system SHALL detect when the human player successfully exploits an identified opponent pattern (e.g., bluffing a known nit, value-betting a calling station). When exploitation is detected over 3+ consecutive hands, the system SHALL display a "Read Confirmed" notification acknowledging the player's successful read and reinforcing the lesson.
|
||||||
|
|
||||||
|
#### Scenario: Successful exploitation triggers confirmation
|
||||||
|
- **WHEN** the player bluffs against Player 2 three times in succession and wins all pots, and Player 2 is classified as a Nit
|
||||||
|
- **THEN** a "Read Confirmed" notification appears: "You've been exploiting Player 2's tight play — they're folding too much to your bluffs"
|
||||||
|
|
||||||
|
#### Scenario: Confirmation includes teaching lesson
|
||||||
|
- **WHEN** a read confirmation triggers
|
||||||
|
- **THEN** the notification includes a brief lesson explaining the underlying principle (e.g., "Nits fold frequently to aggression. Bluffing them is profitable.")
|
||||||
|
|
||||||
|
### Requirement: Post-hand analysis covers key learning areas
|
||||||
|
Post-hand analysis SHALL address: (1) whether the player's action was optimal given their hand and position, (2) what the opponent likely held based on observed patterns, (3) whether the player correctly read the opponent, and (4) a suggested alternative action if applicable. Analysis depth SHALL scale with feedback level.
|
||||||
|
|
||||||
|
#### Scenario: Post-hand identifies missed value
|
||||||
|
- **WHEN** the player checks with top pair against a known Calling Station who would have called a bet
|
||||||
|
- **THEN** post-hand analysis notes: "You could have bet for value — Player 3 calls frequently and likely has a worse hand"
|
||||||
|
|
||||||
|
#### Scenario: Post-hand confirms good read
|
||||||
|
- **WHEN** the player correctly folds to a bluff from a known LAG
|
||||||
|
- **THEN** post-hand analysis notes: "Good fold — Player 4 bluffs frequently, and their bet sizing was inconsistent with strength"
|
||||||
113
openspec/changes/archive/2026-05-17-bot-intelligence/tasks.md
Normal file
113
openspec/changes/archive/2026-05-17-bot-intelligence/tasks.md
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
## 1. Type System & Data Models
|
||||||
|
|
||||||
|
- [x] 1.1 Define BotArchetype enum with all 8 archetypes (Nit, TAG, LAG, Maniac, CallingStation, LooseFish, OldMan, MonsterTAG)
|
||||||
|
- [x] 1.2 Define SkillLevel enum with 5 tiers (Novice, Beginner, Medium, Hard, Ultra)
|
||||||
|
- [x] 1.3 Extend PlayerSeat type with personality and skillLevel fields
|
||||||
|
- [x] 1.4 Create PersonalityProfile type with VPIP range, PFR range, bluff frequency, aggression pattern, and timeout default
|
||||||
|
- [x] 1.5 Create ObservationStats type with VPIP, PFR, FoldToCBet, WTSD, bet sizing profiles, timing data
|
||||||
|
- [x] 1.6 Extend GameState to include observationData field mapping player IDs to ObservationStats
|
||||||
|
|
||||||
|
## 2. Bot Personality Profiles
|
||||||
|
|
||||||
|
- [x] 2.1 Implement personality profile for Nit archetype (VPIP 5-15%, PFR 4-12%, low bluff, timeout: fold)
|
||||||
|
- [x] 2.2 Implement personality profile for TAG archetype (VPIP 15-25%, PFR 10-20%, moderate bluff, timeout: check/fold)
|
||||||
|
- [x] 2.3 Implement personality profile for LAG archetype (VPIP 25-40%, PFR 20-35%, high bluff, timeout: call)
|
||||||
|
- [x] 2.4 Implement personality profile for Maniac archetype (VPIP 40-60%, PFR 35-55%, extreme bluff, timeout: raise)
|
||||||
|
- [x] 2.5 Implement personality profile for Calling Station archetype (VPIP 30-50%, PFR 5-15%, zero bluff, timeout: call)
|
||||||
|
- [x] 2.6 Implement personality profile for Loose Fish archetype (VPIP 35-50%, PFR 10-20%, low bluff, timeout: call)
|
||||||
|
- [x] 2.7 Implement personality profile for Old Man archetype (VPIP 10-20%, PFR 3-8%, zero bluff, timeout: call)
|
||||||
|
- [x] 2.8 Implement personality profile for Monster TAG archetype (VPIP 10-18%, PFR 9-16%, selective bluff, timeout: check)
|
||||||
|
|
||||||
|
## 3. Mistake Libraries
|
||||||
|
|
||||||
|
- [x] 3.1 Define mistake library for Nit (folds too much, misses value, never bluffs)
|
||||||
|
- [x] 3.2 Define mistake library for TAG (occasional dominated hand play, slow-plays too often)
|
||||||
|
- [x] 3.3 Define mistake library for LAG (overbluffs river, wide EP ranges, inconsistent sizing)
|
||||||
|
- [x] 3.4 Define mistake library for Maniac (bluffs into strength, can't extract value, zero fold ability)
|
||||||
|
- [x] 3.5 Define mistake library for Calling Station (calls with any pair, never folds post-flop, tiny bets)
|
||||||
|
- [x] 3.6 Define mistake library for Loose Fish (calls too wide, occasional surprise raise, small bets)
|
||||||
|
- [x] 3.7 Define mistake library for Old Man (smooth-calls then folds, misses value checks, passive to a fault)
|
||||||
|
- [x] 3.8 Define mistake library for Monster TAG (rarely misreads, occasionally gets trapped by extreme bluffers)
|
||||||
|
- [x] 3.9 Implement skill-level error rate mapping (Novice ~40%, Beginner ~25%, Medium ~15%, Hard ~10%, Ultra ~5%)
|
||||||
|
- [x] 3.10 Implement mistake injection function that samples from archetype library based on skill error rate
|
||||||
|
|
||||||
|
## 4. Base Strategy Engine
|
||||||
|
|
||||||
|
- [x] 4.1 Implement position-based hand ranking chart for pre-flop decisions (9 positions × hand matrix)
|
||||||
|
- [x] 4.2 Implement post-flop base strategy using hand strength evaluation vs board texture
|
||||||
|
- [x] 4.3 Implement pot odds calculation for drawing hand decisions
|
||||||
|
- [x] 4.4 Implement base bluff frequency calculator based on board and position
|
||||||
|
- [x] 4.5 Implement base bet sizing recommendations per street
|
||||||
|
|
||||||
|
## 5. Bot Decision Pipeline
|
||||||
|
|
||||||
|
- [x] 5.1 Implement three-layer pipeline: base strategy → personality filter → skill noise
|
||||||
|
- [x] 5.2 Implement personality filter that reshapes action probabilities based on archetype profile
|
||||||
|
- [x] 5.3 Implement skill noise layer that probabilistically overrides decisions with mistakes
|
||||||
|
- [x] 5.4 Implement timing decision function (archetype × skill determines how long bot "thinks")
|
||||||
|
- [x] 5.5 Integrate bot decision engine into turn.ts to replace coin-flip logic
|
||||||
|
|
||||||
|
## 6. Decision Timer System
|
||||||
|
|
||||||
|
- [x] 6.1 Create Timer component with configurable duration and visual countdown display
|
||||||
|
- [x] 6.2 Implement sequential timer management (starts for current player, advances on action/timeout)
|
||||||
|
- [x] 6.3 Implement timeout action handler that triggers archetype-appropriate default
|
||||||
|
- [x] 6.4 Record decision time for every action in action history
|
||||||
|
- [x] 6.5 Integrate timer into betting round flow with human timer mode support
|
||||||
|
|
||||||
|
## 7. Observation Tracking Engine
|
||||||
|
|
||||||
|
- [x] 7.1 Implement VPIP tracking (count voluntary pot entries / total hands)
|
||||||
|
- [x] 7.2 Implement PFR tracking (count pre-flop raises / total hands)
|
||||||
|
- [x] 7.3 Implement Fold-to-CBet tracking (count folds to continuation bets / total CBet faces)
|
||||||
|
- [x] 7.4 Implement WTSD tracking (count showdowns / total post-flop participations)
|
||||||
|
- [x] 7.5 Implement per-street bet sizing tracker with pattern detection (small/standard/polarized)
|
||||||
|
- [x] 7.6 Implement timing tell tracker (fast vs slow distributions per action type)
|
||||||
|
- [x] 7.7 Wire observation updates to trigger after each completed hand
|
||||||
|
|
||||||
|
## 8. Player Classification Engine
|
||||||
|
|
||||||
|
- [x] 8.1 Define weighted scoring functions for all 8 archetypes
|
||||||
|
- [x] 8.2 Implement confidence calculation based on score magnitude and sample size
|
||||||
|
- [x] 8.3 Implement minimum sample size check (10 hands before classification)
|
||||||
|
- [x] 8.4 Implement confidence capping by sample size brackets (10-20: 60%, 20-40: 80%, 40+: uncapped)
|
||||||
|
- [x] 8.5 Implement secondary pattern detection (bet sizing tendencies, timing tells, bluff frequency)
|
||||||
|
- [x] 8.6 Integrate classification results into observation data for UI display
|
||||||
|
|
||||||
|
## 9. Table Setup UI
|
||||||
|
|
||||||
|
- [x] 9.1 Create visual table layout component showing all seats with poker table aesthetic
|
||||||
|
- [x] 9.2 Implement seat click handler that opens archetype/skill selection dropdown
|
||||||
|
- [x] 9.3 Implement quick presets (Fish Table, Regular Grind, High Stakes, Training Mix)
|
||||||
|
- [x] 9.4 Implement global settings panel (blinds, stack size, player count, timer config)
|
||||||
|
- [x] 9.5 Implement info level selector (None, Hints, Stats, Full Reveal)
|
||||||
|
- [x] 9.6 Implement feedback level selector (Off, Post-hand, Real-time)
|
||||||
|
- [x] 9.7 Wire table setup to initialize GameState with configured bot personalities
|
||||||
|
|
||||||
|
## 10. Info Display System
|
||||||
|
|
||||||
|
- [x] 10.1 Create info display component that renders opponent data based on info level
|
||||||
|
- [x] 10.2 Implement "None" mode (no data shown)
|
||||||
|
- [x] 10.3 Implement "Hints" mode (occasional contextual pattern hints)
|
||||||
|
- [x] 10.4 Implement "Stats" mode (raw VPIP, PFR, Fold-to-CBet, WTSD display)
|
||||||
|
- [x] 10.5 Implement "Full Reveal" mode (all stats + inferred type + confidence + patterns)
|
||||||
|
- [ ] 10.6 Wire dynamic updates to refresh after each hand completion
|
||||||
|
|
||||||
|
## 11. Teaching Coach System
|
||||||
|
|
||||||
|
- [x] 11.1 Create post-hand analysis generator that evaluates player decisions vs optimal play
|
||||||
|
- [x] 11.2 Implement real-time suggestion engine that references opponent observation data
|
||||||
|
- [x] 11.3 Implement coaching content filtering based on info level (no stat leakage at lower levels)
|
||||||
|
- [x] 11.4 Implement pattern recognition confirmation detection (3+ consecutive successful exploitations)
|
||||||
|
- [x] 11.5 Create "Read Confirmed" notification component with lesson reinforcement
|
||||||
|
- [x] 11.6 Integrate teaching coach into hand completion flow and decision UI
|
||||||
|
|
||||||
|
## 12. Integration & Testing
|
||||||
|
|
||||||
|
- [x] 12.1 End-to-end test: full hand plays with personality-driven bot decisions
|
||||||
|
- [ ] 12.2 End-to-end test: timer expires and triggers correct timeout action per archetype
|
||||||
|
- [x] 12.3 End-to-end test: observation stats accumulate correctly across multiple hands
|
||||||
|
- [x] 12.4 End-to-end test: classification produces correct type after sufficient sample size
|
||||||
|
- [ ] 12.5 End-to-end test: info display respects chosen level and hides/reveals appropriately
|
||||||
|
- [ ] 12.6 End-to-end test: teaching coach provides feedback matching chosen intensity level
|
||||||
|
- [x] 12.7 Run svelte-check and fix any type errors
|
||||||
58
openspec/specs/bot-personalities/spec.md
Normal file
58
openspec/specs/bot-personalities/spec.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Bot Personalities
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Defines bot archetypes, skill levels, and the three-layer decision pipeline that produces realistic, personality-driven poker play.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Eight distinct bot archetypes exist
|
||||||
|
The system SHALL provide 8 bot archetypes: Nit, TAG (Tight-Aggressive), LAG (Loose-Aggressive), Maniac, Calling Station, Loose Fish, Old Man (Tight-Passive), and Monster TAG. Each archetype SHALL have a defined personality profile specifying characteristic VPIP range, PFR range, bluff frequency, and aggression pattern.
|
||||||
|
|
||||||
|
#### Scenario: Archetype list is complete
|
||||||
|
- **WHEN** the system initializes the bot personality registry
|
||||||
|
- **THEN** all 8 archetypes are available for assignment to bot seats
|
||||||
|
|
||||||
|
#### Scenario: Each archetype has defined statistical profile
|
||||||
|
- **WHEN** querying an archetype's personality profile
|
||||||
|
- **THEN** the profile includes VPIP range, PFR range, bluff frequency, and aggression pattern
|
||||||
|
|
||||||
|
### Requirement: Five skill levels per archetype
|
||||||
|
The system SHALL provide 5 skill levels: Novice, Beginner, Medium, Hard, and Ultra. Each skill level SHALL define an error rate range and a set of enabled behavioral capabilities.
|
||||||
|
|
||||||
|
#### Scenario: Skill levels span full difficulty range
|
||||||
|
- **WHEN** querying available skill levels
|
||||||
|
- **THEN** Novice through Ultra are returned with increasing competence
|
||||||
|
|
||||||
|
#### Scenario: Error rates scale with skill
|
||||||
|
- **WHEN** comparing error rates between adjacent skill levels
|
||||||
|
- **THEN** higher skill levels have lower error rates (Ultra < Hard < Medium < Beginner < Novice)
|
||||||
|
|
||||||
|
### Requirement: Archetype-specific mistake libraries
|
||||||
|
The system SHALL maintain a mistake library per archetype. Each archetype's mistakes SHALL be thematically consistent with its play style (e.g., Nit mistakes involve folding too much, Fish mistakes involve calling too much). A bot's skill level SHALL determine the frequency at which mistakes from its library are triggered.
|
||||||
|
|
||||||
|
#### Scenario: Novice TAG makes tight-player mistakes
|
||||||
|
- **WHEN** a Novice TAG bot faces a decision where a mistake could occur
|
||||||
|
- **THEN** the possible mistakes include folding too much and missing value bets, not calling too wide
|
||||||
|
|
||||||
|
#### Scenario: Novice Fish makes loose-player mistakes
|
||||||
|
- **WHEN** a Novice Fish bot faces a decision where a mistake could occur
|
||||||
|
- **THEN** the possible mistakes include calling with weak hands and falling for bluffs, not folding too tight
|
||||||
|
|
||||||
|
#### Scenario: Ultra bots rarely make mistakes
|
||||||
|
- **WHEN** an Ultra-skill bot plays 100 hands
|
||||||
|
- **THEN** fewer than 5 decisions are influenced by mistake injection
|
||||||
|
|
||||||
|
### Requirement: Three-layer decision pipeline
|
||||||
|
The system SHALL compute bot decisions through a three-layer pipeline: (1) base strategy produces GTO-informed baseline probabilities, (2) personality filter reshapes probabilities according to archetype traits, (3) skill noise layer injects mistakes proportional to the bot's error rate. The final decision SHALL be sampled from the resulting probability distribution.
|
||||||
|
|
||||||
|
#### Scenario: Base strategy provides starting point
|
||||||
|
- **WHEN** a bot holds premium cards in late position with no prior action
|
||||||
|
- **THEN** the base strategy assigns high probability to raising
|
||||||
|
|
||||||
|
#### Scenario: Personality filter adjusts for archetype
|
||||||
|
- **WHEN** a Nit bot evaluates the same hand as an LAG bot
|
||||||
|
- **THEN** the Nit's raise probability is lower due to tighter range filtering
|
||||||
|
|
||||||
|
#### Scenario: Skill noise degrades decisions for lower skill
|
||||||
|
- **WHEN** a Novice bot makes a decision through the full pipeline
|
||||||
|
- **THEN** the final action may differ from the optimal base strategy action due to mistake injection
|
||||||
50
openspec/specs/decision-timer/spec.md
Normal file
50
openspec/specs/decision-timer/spec.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Decision Timer
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Enforces sequential turn order with configurable countdown timers, adding realism and pressure to bot and human decision-making.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Sequential action timer with countdown display
|
||||||
|
The system SHALL enforce sequential turn order where each active player receives an independent countdown timer. The timer duration SHALL be configurable (5-30 seconds, default 10s). A visual countdown indicator SHALL be displayed for the current acting player. When the timer reaches zero, the system SHALL execute a timeout action.
|
||||||
|
|
||||||
|
#### Scenario: Timer counts down for current player
|
||||||
|
- **WHEN** it is Bot #2's turn to act and timer is set to 10s
|
||||||
|
- **THEN** a countdown from 10 to 0 is displayed, then turn advances to next active player
|
||||||
|
|
||||||
|
#### Scenario: Timer expires triggers timeout action
|
||||||
|
- **WHEN** a bot's timer reaches zero before the bot makes a decision
|
||||||
|
- **THEN** the system executes the bot's archetype-appropriate timeout default action
|
||||||
|
|
||||||
|
### Requirement: Archetype-specific timeout defaults
|
||||||
|
The system SHALL define a default timeout action per archetype: Nit folds, TAG checks or folds, LAG calls, Maniac raises, Calling Station calls, Loose Fish calls, Old Man calls, Monster TAG checks. The timeout action SHALL be recorded in the action history and counted as a mistake for skill assessment.
|
||||||
|
|
||||||
|
#### Scenario: Nit times out by folding
|
||||||
|
- **WHEN** a Nit bot's timer expires during a betting round
|
||||||
|
- **THEN** the bot folds and the turn advances to the next active player
|
||||||
|
|
||||||
|
#### Scenario: Maniac times out by raising
|
||||||
|
- **WHEN** a Maniac bot's timer expires and raising is a valid action
|
||||||
|
- **THEN** the bot makes a raise and the turn advances
|
||||||
|
|
||||||
|
### Requirement: Optional human player timer
|
||||||
|
The system SHALL support three human timer modes: "Same as bots" (human gets same duration as bot timer), "No limit" (human has unlimited time), or "Custom" (player specifies duration). In No Limit mode, no countdown is displayed for the human. Timer mode SHALL be configurable in table setup.
|
||||||
|
|
||||||
|
#### Scenario: Human plays with no timer
|
||||||
|
- **WHEN** human timer is set to "No limit" and it is the human's turn
|
||||||
|
- **THEN** no countdown appears and the human may take unlimited time to decide
|
||||||
|
|
||||||
|
#### Scenario: Human shares bot timer duration
|
||||||
|
- **WHEN** human timer is set to "Same as bots" with 10s bot timer
|
||||||
|
- **THEN** the human has a 10-second countdown when it is their turn
|
||||||
|
|
||||||
|
### Requirement: Timing data recorded for observation
|
||||||
|
The system SHALL record the decision time (in seconds) for every action taken by every player. The recorded timing data SHALL be available to the observation tracking system and classification engine. Decision time SHALL be measured from when the timer starts for a player to when they commit an action.
|
||||||
|
|
||||||
|
#### Scenario: Fast action is recorded
|
||||||
|
- **WHEN** Bot #3 calls within 1.5 seconds of their timer starting
|
||||||
|
- **THEN** the action history records decision_time: 1.5 for that action
|
||||||
|
|
||||||
|
#### Scenario: Slow hesitation is recorded
|
||||||
|
- **WHEN** Bot #5 takes 8.2 seconds to decide before folding
|
||||||
|
- **THEN** the action history records decision_time: 8.2 for that fold
|
||||||
@ -92,3 +92,33 @@ The game engine SHALL represent each action as a pure function that takes the cu
|
|||||||
#### Scenario: State immutability preserved
|
#### Scenario: State immutability preserved
|
||||||
- **WHEN** an action function is called with a game state
|
- **WHEN** an action function is called with a game state
|
||||||
- **THEN** the original state object is unchanged and a new state object is returned
|
- **THEN** the original state object is unchanged and a new state object is returned
|
||||||
|
|
||||||
|
### Requirement: Bot turn decision execution
|
||||||
|
**WHEN** it is a bot player's turn to act
|
||||||
|
**THEN** the system SHALL invoke the bot decision engine (base strategy → personality filter → skill noise) to compute the action, execute the action through the existing action application functions, and advance the turn to the next active player
|
||||||
|
|
||||||
|
#### Scenario: Bot makes personality-driven decision
|
||||||
|
- **WHEN** it is a TAG bot's turn with a medium-strength hand in late position
|
||||||
|
- **THEN** the bot raises according to TAG personality profile rather than making a random choice
|
||||||
|
|
||||||
|
#### Scenario: Bot decision respects timer constraints
|
||||||
|
- **WHEN** timer is enabled and it is a bot's turn
|
||||||
|
- **THEN** the bot makes its decision within the configured timer duration, with decision time recorded for observation tracking
|
||||||
|
|
||||||
|
### Requirement: PlayerSeat type includes personality data
|
||||||
|
The system SHALL extend the PlayerSeat type to include `personality` (bot archetype enum) and `skillLevel` (skill tier enum) fields. Human players SHALL have these fields set to null or a default human value. Existing fields (id, name, chips, currentBet, betMatched, status, holeCards, position) SHALL remain unchanged.
|
||||||
|
|
||||||
|
#### Scenario: Bot seat includes personality data
|
||||||
|
- **WHEN** a bot player is created with LAG archetype at Hard skill
|
||||||
|
- **THEN** the PlayerSeat object has personality: "LAG" and skillLevel: "Hard"
|
||||||
|
|
||||||
|
#### Scenario: Human seat has no bot personality
|
||||||
|
- **WHEN** the human player's PlayerSeat is created
|
||||||
|
- **THEN** personality and skillLevel fields are null or marked as human
|
||||||
|
|
||||||
|
### Requirement: GameState tracks observation data
|
||||||
|
The system SHALL extend the GameState to include an `observationData` field containing per-player tracked statistics (VPIP, PFR, Fold-to-CBet, WTSD, bet sizing profiles, timing data). Observation data SHALL update after each completed hand and SHALL persist across hands within a session.
|
||||||
|
|
||||||
|
#### Scenario: Observation data accumulates across hands
|
||||||
|
- **WHEN** 10 hands have been played and observation tracking is active
|
||||||
|
- **THEN** GameState.observationData contains accumulated statistics for all bot players
|
||||||
|
|||||||
36
openspec/specs/info-display/spec.md
Normal file
36
openspec/specs/info-display/spec.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Info Display
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Controls what information about opponents is visible to the human player during gameplay, with configurable levels from complete blindness to full transparency.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Four info levels control opponent visibility
|
||||||
|
The system SHALL provide 4 info levels: None, Hints, Stats, and Full Reveal. The chosen level SHALL determine what information about opponents is displayed during gameplay. The info level SHALL be configurable in table setup and SHALL apply consistently throughout the session.
|
||||||
|
|
||||||
|
#### Scenario: None level hides all data
|
||||||
|
- **WHEN** info level is "None"
|
||||||
|
- **THEN** no statistics, type labels, or inferred classifications are shown for any opponent
|
||||||
|
|
||||||
|
#### Scenario: Hints level shows occasional contextual tips
|
||||||
|
- **WHEN** info level is "Hints" and a notable pattern has been detected for an opponent
|
||||||
|
- **THEN** a subtle hint appears (e.g., "Player 3 tends to play a lot of hands")
|
||||||
|
|
||||||
|
#### Scenario: Stats level shows raw numbers
|
||||||
|
- **WHEN** info level is "Stats"
|
||||||
|
- **THEN** VPIP, PFR, Fold-to-CBet, and WTSD are displayed for each opponent
|
||||||
|
|
||||||
|
#### Scenario: Full Reveal shows everything
|
||||||
|
- **WHEN** info level is "Full Reveal"
|
||||||
|
- **THEN** all statistics, inferred type with confidence, skill estimate, and detected patterns are shown
|
||||||
|
|
||||||
|
### Requirement: Info display updates as data accumulates
|
||||||
|
The system SHALL update the info display dynamically as new hand data becomes available. Statistics SHALL refresh after each completed hand. Classification results SHALL update when sufficient sample size is reached. At Hints level, new hints SHALL appear as new patterns are detected.
|
||||||
|
|
||||||
|
#### Scenario: Stats update after hand completion
|
||||||
|
- **WHEN** a hand completes and a new action history entry is recorded
|
||||||
|
- **THEN** all tracked statistics for involved players are recalculated and displayed values update
|
||||||
|
|
||||||
|
#### Scenario: Classification appears when threshold is met
|
||||||
|
- **WHEN** an opponent reaches 10 observed hands and the classification engine produces a result
|
||||||
|
- **THEN** the inferred type and confidence appear in the display (if info level permits)
|
||||||
50
openspec/specs/observation-tracking/spec.md
Normal file
50
openspec/specs/observation-tracking/spec.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Observation Tracking
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Tracks per-player behavioral statistics across hands to feed the classification engine and info display systems.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Core statistical tracking per opponent
|
||||||
|
The system SHALL track the following statistics for each opponent across all hands in a session: VPIP (Voluntary Put Money In Pot), PFR (Pre-Flop Raise), Fold-to-CBet (fold percentage to continuation bets), and WTSD (Went To Showdown percentage). Statistics SHALL update after each completed hand and SHALL be accessible to the classification engine.
|
||||||
|
|
||||||
|
#### Scenario: VPIP tracks hand participation
|
||||||
|
- **WHEN** a bot voluntarily puts money in the pot in 35 out of 100 hands
|
||||||
|
- **THEN** the bot's VPIP is recorded as 35%
|
||||||
|
|
||||||
|
#### Scenario: PFR tracks pre-flop aggression
|
||||||
|
- **WHEN** a bot raises pre-flop in 20 out of 100 hands dealt
|
||||||
|
- **THEN** the bot's PFR is recorded as 20%
|
||||||
|
|
||||||
|
### Requirement: Bet sizing tracking per street
|
||||||
|
The system SHALL track average bet sizes separately for each betting street (pre-flop, flop, turn, river). For each street, the system SHALL record the average bet as a percentage of the pot and flag notable patterns (e.g., consistently small bets, polarized sizing, frequent overbets).
|
||||||
|
|
||||||
|
#### Scenario: Small bet pattern is detected
|
||||||
|
- **WHEN** a bot's average flop bet size is 30% of pot over 20 hands
|
||||||
|
- **THEN** the system flags this as "consistently small bets" pattern
|
||||||
|
|
||||||
|
#### Scenario: Polarized river betting is detected
|
||||||
|
- **WHEN** a bot's river bets are predominantly either min-bet or full-pot-or-larger
|
||||||
|
- **THEN** the system flags this as "polarized river betting" pattern
|
||||||
|
|
||||||
|
### Requirement: Timing tell tracking
|
||||||
|
The system SHALL track decision timing statistics per opponent: average decision time, distribution of fast actions (under 3s) vs slow actions (over 7s), and average timing broken down by action type (call, fold, raise, check). The system SHALL compute a timing consistency score indicating how reliably the bot's timing correlates with hand strength.
|
||||||
|
|
||||||
|
#### Scenario: Fast call pattern is identified
|
||||||
|
- **WHEN** a bot's average call time is 1.8s and average fold time is 8.4s over 30 decisions
|
||||||
|
- **THEN** the system records this as "fast calls, slow folds" timing profile
|
||||||
|
|
||||||
|
#### Scenario: Timing consistency reflects skill level
|
||||||
|
- **WHEN** a Medium-skill bot plays 50 hands
|
||||||
|
- **THEN** the timing consistency score is approximately 70-80%, reflecting mostly reliable tells with some noise
|
||||||
|
|
||||||
|
### Requirement: Observation data availability based on info level
|
||||||
|
The system SHALL make observation data available to the player only according to their chosen info level. At "None" level, no stats are displayed. At "Hints" level, occasional contextual hints reference observed patterns. At "Stats" level, raw VPIP/PFR numbers are visible. At "Full Reveal" level, all tracked statistics and inferred type are shown.
|
||||||
|
|
||||||
|
#### Scenario: Player sees nothing at None level
|
||||||
|
- **WHEN** info level is set to "None" and the player views an opponent's seat
|
||||||
|
- **THEN** no statistical data or type information is displayed
|
||||||
|
|
||||||
|
#### Scenario: Player sees raw stats at Stats level
|
||||||
|
- **WHEN** info level is set to "Stats" and the player views an opponent's seat
|
||||||
|
- **THEN** VPIP, PFR, Fold-to-CBet, and WTSD percentages are displayed
|
||||||
50
openspec/specs/player-classification/spec.md
Normal file
50
openspec/specs/player-classification/spec.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Player Classification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Classifies observed bot opponents into archetypes using tracked statistics, enabling the human player and coaching system to exploit opponent tendencies.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Weighted scoring classification algorithm
|
||||||
|
The system SHALL classify each observed opponent using a weighted scoring algorithm. Each archetype SHALL have a scoring function that evaluates the opponent's tracked statistics (VPIP, PFR, Fold-to-CBet, WTSD) and assigns weighted points. The archetype with the highest score SHALL be the inferred type. The system SHALL compute a confidence percentage based on the score magnitude and sample size.
|
||||||
|
|
||||||
|
#### Scenario: LAG is correctly classified
|
||||||
|
- **WHEN** an opponent has VPIP=38%, PFR=25%, Fold-to-CBet=45%, WTSD=60% after 40 hands
|
||||||
|
- **THEN** the LAG scoring function produces the highest score and the bot is classified as LAG
|
||||||
|
|
||||||
|
#### Scenario: Calling Station is correctly classified
|
||||||
|
- **WHEN** an opponent has VPIP=42%, PFR=8%, Fold-to-CBet=15%, WTSD=75% after 30 hands
|
||||||
|
- **THEN** the Calling Station scoring function produces the highest score and the bot is classified as Fish
|
||||||
|
|
||||||
|
### Requirement: Confidence scales with sample size
|
||||||
|
The system SHALL require a minimum number of observed hands before producing a classification. With fewer than 10 hands, the system SHALL report "insufficient data." Between 10-20 hands, confidence SHALL be capped at 60%. Between 20-40 hands, confidence SHALL be capped at 80%. After 40+ hands, full confidence SHALL be calculated from score magnitude.
|
||||||
|
|
||||||
|
#### Scenario: Insufficient data shown early
|
||||||
|
- **WHEN** only 5 hands have been played against an opponent
|
||||||
|
- **THEN** the classification displays "Insufficient data" with no type assigned
|
||||||
|
|
||||||
|
#### Scenario: Confidence increases with more hands
|
||||||
|
- **WHEN** an opponent has been observed for 50 hands and scores strongly for TAG
|
||||||
|
- **THEN** confidence may reach 85-90% for a consistent Medium-skill bot
|
||||||
|
|
||||||
|
### Requirement: Classification difficulty scales with bot skill
|
||||||
|
The system SHALL adjust classification reliability based on the bot's skill level. Novice and Beginner bots SHALL produce clear, easily classifiable patterns (high confidence achieved quickly). Hard and Ultra bots SHALL produce ambiguous or mixed statistics that reduce achievable confidence and may require 70+ hands for reliable classification.
|
||||||
|
|
||||||
|
#### Scenario: Novice bot is quickly classified
|
||||||
|
- **WHEN** a Novice LAG has played 12 hands with extreme loose-aggressive patterns
|
||||||
|
- **THEN** the system classifies them as LAG with 60% confidence (capped by sample size)
|
||||||
|
|
||||||
|
#### Scenario: Ultra bot resists easy classification
|
||||||
|
- **WHEN** an Ultra TAG has played 40 hands with balanced, position-dependent play
|
||||||
|
- **THEN** the system may show lower confidence (~65%) due to overlapping stat ranges
|
||||||
|
|
||||||
|
### Requirement: Secondary pattern detection
|
||||||
|
The system SHALL detect secondary behavioral patterns beyond core statistics, including bet sizing tendencies (small/standard/polarized), timing tells (fast calls vs slow folds), and bluff frequency estimation. These patterns SHALL supplement the primary archetype classification and be available for teaching hints.
|
||||||
|
|
||||||
|
#### Scenario: Bet sizing pattern is detected
|
||||||
|
- **WHEN** a bot consistently bets 30% of pot on all post-flop streets over 25 hands
|
||||||
|
- **THEN** the system flags "characteristically small bet sizes" as a secondary pattern
|
||||||
|
|
||||||
|
#### Scenario: Timing tell is identified
|
||||||
|
- **WHEN** a bot's calls average 1.5s and folds average 8.5s over 30 decisions
|
||||||
|
- **THEN** the system flags "fast on comfort, slow on discomfort" as a timing tell
|
||||||
50
openspec/specs/table-setup/spec.md
Normal file
50
openspec/specs/table-setup/spec.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Table Setup
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Provides visual table configuration interface for assigning bot archetypes, skill levels, and global game settings before play begins.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Visual seat editor for table configuration
|
||||||
|
The system SHALL provide a visual table layout showing all seats. The player SHALL be able to click any bot seat and select an archetype and skill level from dropdown controls. The human player's seat SHALL be fixed and non-configurable.
|
||||||
|
|
||||||
|
#### Scenario: Player assigns bot type to a seat
|
||||||
|
- **WHEN** the player clicks a bot seat and selects "LAG" at "Hard" difficulty
|
||||||
|
- **THEN** that seat displays the LAG archetype label and Hard skill indicator
|
||||||
|
|
||||||
|
#### Scenario: Human seat cannot be reassigned
|
||||||
|
- **WHEN** the player attempts to change the human player's seat configuration
|
||||||
|
- **THEN** the system prevents the change or grays out the controls
|
||||||
|
|
||||||
|
### Requirement: Quick presets for common table compositions
|
||||||
|
The system SHALL provide at least 4 predefined table presets: Fish Table (all Calling Stations, Novice-Beginner), Regular Grind (mix of TAGs and LAGs, Medium-Hard), High Stakes (Ultra/Hard TAGs and LAGs), and Training Mix (one of each type across skill levels). Selecting a preset SHALL populate all bot seats automatically.
|
||||||
|
|
||||||
|
#### Scenario: Fish Table preset configures easy opponents
|
||||||
|
- **WHEN** the player selects "Fish Table" preset
|
||||||
|
- **THEN** all bot seats are assigned Calling Station or Loose Fish archetypes at Novice-Beginner skill
|
||||||
|
|
||||||
|
#### Scenario: Training Mix preset provides variety
|
||||||
|
- **WHEN** the player selects "Training Mix" preset
|
||||||
|
- **THEN** each bot seat receives a different archetype at varying skill levels
|
||||||
|
|
||||||
|
### Requirement: Global table settings
|
||||||
|
The system SHALL allow configuring global table parameters: blind structure (small/big blind values), starting stack size, number of players (heads-up through 9-max), timer duration, and timer enable/disable. Settings SHALL apply to all seats uniformly.
|
||||||
|
|
||||||
|
#### Scenario: Player sets custom blinds
|
||||||
|
- **WHEN** the player enters 50/100 for blinds
|
||||||
|
- **THEN** all hands at the table use 50/100 blind structure
|
||||||
|
|
||||||
|
#### Scenario: Player adjusts player count
|
||||||
|
- **WHEN** the player changes from 6-max to 9-max
|
||||||
|
- **THEN** the table layout updates to show additional seats with unassigned bot slots
|
||||||
|
|
||||||
|
### Requirement: Timer configuration per table setup
|
||||||
|
The system SHALL allow the player to configure timer behavior: enable/disable timer, set duration (5-30 seconds), and choose human timer mode (same as bots, no limit, or custom duration). Timer settings SHALL be saved with the table configuration.
|
||||||
|
|
||||||
|
#### Scenario: Player disables timer
|
||||||
|
- **WHEN** the player sets timer to "Off"
|
||||||
|
- **THEN** bot decisions execute instantly without countdown display
|
||||||
|
|
||||||
|
#### Scenario: Player sets custom human timer
|
||||||
|
- **WHEN** the player sets human timer to "Custom: 30s" while bot timer is "10s"
|
||||||
|
- **THEN** bots have 10 seconds per decision and the human has 30 seconds per decision
|
||||||
54
openspec/specs/teaching-coach/spec.md
Normal file
54
openspec/specs/teaching-coach/spec.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Teaching Coach
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Provides coaching feedback to help the human player learn poker strategy through opponent reads, post-hand analysis, and real-time suggestions.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Three feedback levels control coaching intensity
|
||||||
|
The system SHALL provide 3 feedback levels: Off, Post-hand, and Real-time. At "Off," no coaching is provided. At "Post-hand," a brief analysis appears after each completed hand. At "Real-time," contextual suggestions appear during the player's decision-making phase. The feedback level SHALL be configurable in table setup.
|
||||||
|
|
||||||
|
#### Scenario: Off provides no coaching
|
||||||
|
- **WHEN** feedback level is "Off" and the player completes a hand
|
||||||
|
- **THEN** no analysis, suggestion, or teaching content is displayed
|
||||||
|
|
||||||
|
#### Scenario: Post-hand shows analysis after completion
|
||||||
|
- **WHEN** feedback level is "Post-hand" and a hand completes with the human involved
|
||||||
|
- **THEN** an analysis panel appears summarizing key decisions, opponent reads, and suggested improvements
|
||||||
|
|
||||||
|
#### Scenario: Real-time shows suggestions during play
|
||||||
|
- **WHEN** feedback level is "Real-time" and it is the human's turn to act
|
||||||
|
- **THEN** a contextual suggestion may appear (e.g., "Player 2 folds 65% to raises — good bluff spot")
|
||||||
|
|
||||||
|
### Requirement: Teaching respects player info preferences
|
||||||
|
The system SHALL ensure that coaching content does not reveal more information than the player's chosen info level permits. At "None" info level, real-time suggestions SHALL be generic and not reference specific opponent statistics. At "Hints" level, suggestions MAY reference detected patterns but SHALL not show raw numbers.
|
||||||
|
|
||||||
|
#### Scenario: Coaching respects None info level
|
||||||
|
- **WHEN** info level is "None" and feedback is "Real-time"
|
||||||
|
- **THEN** suggestions are general strategic advice without referencing opponent stats or types
|
||||||
|
|
||||||
|
#### Scenario: Coaching respects Hints info level
|
||||||
|
- **WHEN** info level is "Hints" and a pattern has been detected for Player 3
|
||||||
|
- **THEN** a suggestion may say "Player 3 plays many hands — consider raising more against them" without showing VPIP numbers
|
||||||
|
|
||||||
|
### Requirement: Pattern recognition confirmations reward correct reads
|
||||||
|
The system SHALL detect when the human player successfully exploits an identified opponent pattern (e.g., bluffing a known nit, value-betting a calling station). When exploitation is detected over 3+ consecutive hands, the system SHALL display a "Read Confirmed" notification acknowledging the player's successful read and reinforcing the lesson.
|
||||||
|
|
||||||
|
#### Scenario: Successful exploitation triggers confirmation
|
||||||
|
- **WHEN** the player bluffs against Player 2 three times in succession and wins all pots, and Player 2 is classified as a Nit
|
||||||
|
- **THEN** a "Read Confirmed" notification appears: "You've been exploiting Player 2's tight play — they're folding too much to your bluffs"
|
||||||
|
|
||||||
|
#### Scenario: Confirmation includes teaching lesson
|
||||||
|
- **WHEN** a read confirmation triggers
|
||||||
|
- **THEN** the notification includes a brief lesson explaining the underlying principle (e.g., "Nits fold frequently to aggression. Bluffing them is profitable.")
|
||||||
|
|
||||||
|
### Requirement: Post-hand analysis covers key learning areas
|
||||||
|
Post-hand analysis SHALL address: (1) whether the player's action was optimal given their hand and position, (2) what the opponent likely held based on observed patterns, (3) whether the player correctly read the opponent, and (4) a suggested alternative action if applicable. Analysis depth SHALL scale with feedback level.
|
||||||
|
|
||||||
|
#### Scenario: Post-hand identifies missed value
|
||||||
|
- **WHEN** the player checks with top pair against a known Calling Station who would have called a bet
|
||||||
|
- **THEN** post-hand analysis notes: "You could have bet for value — Player 3 calls frequently and likely has a worse hand"
|
||||||
|
|
||||||
|
#### Scenario: Post-hand confirms good read
|
||||||
|
- **WHEN** the player correctly folds to a bluff from a known LAG
|
||||||
|
- **THEN** post-hand analysis notes: "Good fold — Player 4 bluffs frequently, and their bet sizing was inconsistent with strength"
|
||||||
931
package-lock.json
generated
931
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,15 +9,20 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@vitest/ui": "^4.1.6",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"svelte": "^5.55.2",
|
"svelte": "^5.55.2",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.7"
|
"vite": "^8.0.7",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/lib/components/DecisionTimer.svelte
Normal file
115
src/lib/components/DecisionTimer.svelte
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BotArchetype } from '$lib/types/bot-archetype';
|
||||||
|
|
||||||
|
let {
|
||||||
|
duration = 10,
|
||||||
|
active = false,
|
||||||
|
playerName = '',
|
||||||
|
archetype = undefined,
|
||||||
|
onTimeout
|
||||||
|
} = $props<{
|
||||||
|
duration?: number;
|
||||||
|
active?: boolean;
|
||||||
|
playerName?: string;
|
||||||
|
archetype?: BotArchetype | undefined;
|
||||||
|
onTimeout?: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let remaining = $state(0);
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const percent = $derived(duration > 0 ? (remaining / duration) * 100 : 0);
|
||||||
|
const warning = $derived(remaining <= 3 && active);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (active) {
|
||||||
|
remaining = duration;
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
remaining--;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
onTimeout?.();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
remaining = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="timer" class:warning>
|
||||||
|
<div class="player-name">{playerName}</div>
|
||||||
|
<div class="countdown">{remaining}s</div>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill" style="width: {percent}%"></div>
|
||||||
|
</div>
|
||||||
|
{#if archetype}
|
||||||
|
<div class="archetype-badge">{archetype}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.timer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.timer.warning {
|
||||||
|
background: rgba(255, 50, 50, 0.2);
|
||||||
|
border: 1px solid rgba(255, 50, 50, 0.5);
|
||||||
|
}
|
||||||
|
.player-name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.countdown {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.warning .countdown {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
.bar-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #4ade80;
|
||||||
|
transition: width 0.3s linear, background 0.3s;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
:global(.warning) .bar-fill {
|
||||||
|
background: #ff4444;
|
||||||
|
}
|
||||||
|
.archetype-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
184
src/lib/components/OpponentInfo.svelte
Normal file
184
src/lib/components/OpponentInfo.svelte
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InfoLevel } from '$lib/types/game-state';
|
||||||
|
import type { ObservationStats } from '$lib/types/observation-stats';
|
||||||
|
import type { ClassificationResult } from '$lib/game/player-classifier';
|
||||||
|
|
||||||
|
let {
|
||||||
|
playerName = '',
|
||||||
|
stats,
|
||||||
|
classification,
|
||||||
|
infoLevel = 'none'
|
||||||
|
} = $props<{
|
||||||
|
playerName?: string;
|
||||||
|
stats?: ObservationStats;
|
||||||
|
classification?: ClassificationResult;
|
||||||
|
infoLevel?: InfoLevel;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const showNothing = $derived(infoLevel === 'none');
|
||||||
|
const showHints = $derived(infoLevel === 'hints');
|
||||||
|
const showStats = $derived(infoLevel === 'stats' || infoLevel === 'fullReveal');
|
||||||
|
const showFull = $derived(infoLevel === 'fullReveal');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="info-display" class:compact={infoLevel !== 'fullReveal'}>
|
||||||
|
{#if !showNothing && stats}
|
||||||
|
{#if showHints}
|
||||||
|
<div class="hints">
|
||||||
|
{#if stats.vpip > 35}
|
||||||
|
<span class="hint">Plays many hands</span>
|
||||||
|
{/if}
|
||||||
|
{#if stats.pfr > 20}
|
||||||
|
<span class="hint">Aggressive pre-flop</span>
|
||||||
|
{/if}
|
||||||
|
{#if stats.foldToCBet > 50}
|
||||||
|
<span class="hint">Folds often to bets</span>
|
||||||
|
{/if}
|
||||||
|
{#if stats.wtsd > 60 && stats.pfr < 15}
|
||||||
|
<span class="hint">Calls down frequently</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showStats}
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">VPIP</span>
|
||||||
|
<span class="value">{Math.round(stats.vpip)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">PFR</span>
|
||||||
|
<span class="value">{Math.round(stats.pfr)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">FTCB</span>
|
||||||
|
<span class="value">{Math.round(stats.foldToCBet)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">WTSD</span>
|
||||||
|
<span class="value">{Math.round(stats.wtsd)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showFull && classification}
|
||||||
|
<div class="classification">
|
||||||
|
{#if classification.inferredType}
|
||||||
|
<div class="type-badge">
|
||||||
|
<span class="label">Type</span>
|
||||||
|
<span class="value">{classification.inferredType}</span>
|
||||||
|
<span class="confidence">({classification.confidence}%)</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="insufficient">Insufficient data ({stats.handsPlayed}/10 hands)</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if classification.secondaryPatterns.length > 0}
|
||||||
|
<div class="patterns">
|
||||||
|
{#each classification.secondaryPatterns as pattern, i}
|
||||||
|
<span class="pattern">{pattern}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="timing-section">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="label">Avg Call</span>
|
||||||
|
<span class="value">{stats.timing.avgCallTime.toFixed(1)}s</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="label">Avg Fold</span>
|
||||||
|
<span class="value">{stats.timing.avgFoldTime.toFixed(1)}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.info-display {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.info-display.compact {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
.hints {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
color: #4a9eff;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.stat .label {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.stat .value {
|
||||||
|
color: #aaa;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.classification {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.type-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
background: rgba(76, 175, 80, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.type-badge .label { color: #666; }
|
||||||
|
.type-badge .value { color: #4caf50; font-weight: bold; }
|
||||||
|
.type-badge .confidence { color: #888; font-size: 0.5rem; }
|
||||||
|
.insufficient {
|
||||||
|
color: #555;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
}
|
||||||
|
.patterns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.pattern {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
color: #ffc107;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
}
|
||||||
|
.timing-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
}
|
||||||
|
.stat-row .label { color: #555; }
|
||||||
|
.stat-row .value { color: #888; }
|
||||||
|
</style>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from './Card.svelte';
|
import Card from './Card.svelte';
|
||||||
import PlayerSeat from './PlayerSeat.svelte';
|
import PlayerSeat from './PlayerSeat.svelte';
|
||||||
|
import OpponentInfo from './OpponentInfo.svelte';
|
||||||
import type { GameState } from '$lib/types/game-state';
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
|
||||||
let { gameState }: {
|
let { gameState }: {
|
||||||
@ -47,6 +48,13 @@
|
|||||||
{#if gameState.dealerPosition === i}
|
{#if gameState.dealerPosition === i}
|
||||||
<div class="dealer-button" aria-label="Dealer">D</div>
|
<div class="dealer-button" aria-label="Dealer">D</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if i !== 0 && gameState.tableConfig.infoLevel !== 'none' && gameState.bettingRound !== 'idle'}
|
||||||
|
<OpponentInfo
|
||||||
|
playerName={player.name}
|
||||||
|
stats={gameState.observationData[player.id]}
|
||||||
|
infoLevel={gameState.tableConfig.infoLevel}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
121
src/lib/components/PostHandAnalysis.svelte
Normal file
121
src/lib/components/PostHandAnalysis.svelte
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PostHandAnalysis } from '$lib/game/teaching-coach';
|
||||||
|
|
||||||
|
let {
|
||||||
|
analysis,
|
||||||
|
visible = false,
|
||||||
|
onDismiss
|
||||||
|
} = $props<{
|
||||||
|
analysis?: PostHandAnalysis;
|
||||||
|
visible?: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible && analysis}
|
||||||
|
<div class="post-hand-analysis" role="alert">
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">Hand Analysis</span>
|
||||||
|
<button class="dismiss" onclick={onDismiss}>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="action-row">
|
||||||
|
<span class="label">Your Action</span>
|
||||||
|
<span class="value action-badge">{analysis.playerAction}</span>
|
||||||
|
</div>
|
||||||
|
<div class="assessment">
|
||||||
|
{analysis.assessment}
|
||||||
|
</div>
|
||||||
|
{#if analysis.opponentRead}
|
||||||
|
<div class="read">
|
||||||
|
<strong>Opponent Read:</strong> {analysis.opponentRead}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if analysis.lesson}
|
||||||
|
<div class="lesson">
|
||||||
|
<strong>Lesson:</strong> {analysis.lesson}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-hand-analysis {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 320px;
|
||||||
|
background: linear-gradient(135deg, #1a1a2a, #0d0d1a);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4a9eff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.dismiss {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
.dismiss:hover { color: #999; }
|
||||||
|
.content { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.action-row .label { color: #888; font-size: 0.8rem; }
|
||||||
|
.action-badge {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
color: #4a9eff;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.assessment {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.read {
|
||||||
|
padding: 0.4rem;
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
.read strong { color: #cc9a00; }
|
||||||
|
.lesson {
|
||||||
|
padding: 0.4rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.lesson strong { color: #888; }
|
||||||
|
</style>
|
||||||
110
src/lib/components/ReadConfirmed.svelte
Normal file
110
src/lib/components/ReadConfirmed.svelte
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
opponentName = '',
|
||||||
|
inferredType = '',
|
||||||
|
lesson = '',
|
||||||
|
visible = false,
|
||||||
|
onDismiss
|
||||||
|
} = $props<{
|
||||||
|
opponentName?: string;
|
||||||
|
inferredType?: string;
|
||||||
|
lesson?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (visible) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
onDismiss?.();
|
||||||
|
}, 8000);
|
||||||
|
return () => {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div class="read-confirmed" role="alert">
|
||||||
|
<div class="header">
|
||||||
|
<span class="icon">👁️</span>
|
||||||
|
<span class="title">Read Confirmed</span>
|
||||||
|
<button class="dismiss" onclick={onDismiss}>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p class="opponent">You've been exploiting <strong>{opponentName}</strong></p>
|
||||||
|
<p class="type">They're a <span class="badge">{inferredType}</span></p>
|
||||||
|
<div class="lesson">
|
||||||
|
<strong>Lesson:</strong> {lesson}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.read-confirmed {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 320px;
|
||||||
|
background: linear-gradient(135deg, #1a2a1a, #0d1a0d);
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 15px rgba(76, 175, 80, 0.2);
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.icon { font-size: 1.2rem; }
|
||||||
|
.title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.dismiss {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
.dismiss:hover { color: #999; }
|
||||||
|
.content { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.opponent { margin: 0; color: #ccc; font-size: 0.85rem; }
|
||||||
|
.opponent strong { color: #fff; }
|
||||||
|
.type { margin: 0; color: #999; font-size: 0.8rem; }
|
||||||
|
.badge {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
color: #4a9eff;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.lesson {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.lesson strong { color: #888; }
|
||||||
|
</style>
|
||||||
176
src/lib/components/TableSettings.svelte
Normal file
176
src/lib/components/TableSettings.svelte
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SeatConfig, TableSetupConfig } from '$lib/game/table-presets';
|
||||||
|
import type { InfoLevel, FeedbackLevel } from '$lib/types/game-state';
|
||||||
|
import type { TablePreset } from '$lib/game/table-presets';
|
||||||
|
|
||||||
|
let {
|
||||||
|
infoLevel = 'none',
|
||||||
|
feedbackLevel = 'off',
|
||||||
|
timerEnabled = false,
|
||||||
|
timerDuration = 10,
|
||||||
|
humanTimerMode = 'noLimit',
|
||||||
|
humanTimerDuration = 30,
|
||||||
|
smallBlind = 10,
|
||||||
|
bigBlind = 20,
|
||||||
|
startingStack = 2000,
|
||||||
|
numPlayers = 6,
|
||||||
|
preset = 'custom',
|
||||||
|
onUpdate
|
||||||
|
} = $props<{
|
||||||
|
infoLevel?: InfoLevel;
|
||||||
|
feedbackLevel?: FeedbackLevel;
|
||||||
|
timerEnabled?: boolean;
|
||||||
|
timerDuration?: number;
|
||||||
|
humanTimerMode?: 'same' | 'noLimit' | 'custom';
|
||||||
|
humanTimerDuration?: number;
|
||||||
|
smallBlind?: number;
|
||||||
|
bigBlind?: number;
|
||||||
|
startingStack?: number;
|
||||||
|
numPlayers?: number;
|
||||||
|
preset?: TablePreset;
|
||||||
|
onUpdate?: (data: { key: string; value: unknown }) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function updateConfig(key: string, value: unknown) {
|
||||||
|
onUpdate?.({ key, value });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="section">
|
||||||
|
<h3>Preset</h3>
|
||||||
|
<select bind:value={preset} onchange={() => updateConfig('preset', preset)}>
|
||||||
|
<option value="fishTable">Fish Table (Easy)</option>
|
||||||
|
<option value="regularGrind">Regular Grind (Medium)</option>
|
||||||
|
<option value="highStakes">High Stakes (Hard)</option>
|
||||||
|
<option value="trainingMix">Training Mix (Learning)</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Game Settings</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label for="players-input">Players</label>
|
||||||
|
<input id="players-input" type="number" min="2" max="9" bind:value={numPlayers} oninput={() => updateConfig('numPlayers', numPlayers)} />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="sb-input">Small Blind</label>
|
||||||
|
<input id="sb-input" type="number" bind:value={smallBlind} oninput={() => updateConfig('smallBlind', smallBlind)} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="bb-input">Big Blind</label>
|
||||||
|
<input id="bb-input" type="number" bind:value={bigBlind} oninput={() => updateConfig('bigBlind', bigBlind)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="stack-input">Starting Stack</label>
|
||||||
|
<input id="stack-input" type="number" bind:value={startingStack} oninput={() => updateConfig('startingStack', startingStack)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Timer</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox" for="timer-check">
|
||||||
|
<input id="timer-check" type="checkbox" bind:checked={timerEnabled} onchange={() => updateConfig('timerEnabled', timerEnabled)} />
|
||||||
|
Enable Timer
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if timerEnabled}
|
||||||
|
<div class="field">
|
||||||
|
<label for="duration-input">Duration (seconds)</label>
|
||||||
|
<input id="duration-input" type="number" min="5" max="30" bind:value={timerDuration} oninput={() => updateConfig('timerDuration', timerDuration)} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="human-timer-select">Human Timer</label>
|
||||||
|
<select id="human-timer-select" bind:value={humanTimerMode} onchange={() => updateConfig('humanTimerMode', humanTimerMode)}>
|
||||||
|
<option value="same">Same as bots</option>
|
||||||
|
<option value="noLimit">No limit</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{#if humanTimerMode === 'custom'}
|
||||||
|
<div class="field">
|
||||||
|
<label for="human-duration-input">Human Duration (seconds)</label>
|
||||||
|
<input id="human-duration-input" type="number" min="5" max="60" bind:value={humanTimerDuration} oninput={() => updateConfig('humanTimerDuration', humanTimerDuration)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Info Level</h3>
|
||||||
|
<select bind:value={infoLevel} onchange={() => updateConfig('infoLevel', infoLevel)}>
|
||||||
|
<option value="none">None (Mystery)</option>
|
||||||
|
<option value="hints">Hints</option>
|
||||||
|
<option value="stats">Stats (VPIP/PFR)</option>
|
||||||
|
<option value="fullReveal">Full Reveal (HUD)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Feedback</h3>
|
||||||
|
<select bind:value={feedbackLevel} onchange={() => updateConfig('feedbackLevel', feedbackLevel)}>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="postHand">Post-hand Analysis</option>
|
||||||
|
<option value="realTime">Real-time Suggestions</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.section h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.field-row .field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.checkbox input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
181
src/lib/components/TableSetup.svelte
Normal file
181
src/lib/components/TableSetup.svelte
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SeatConfig } from '$lib/game/table-presets';
|
||||||
|
import { ARCHETYPE_LABELS, ALL_ARCHETYPES } from '$lib/types/bot-archetype';
|
||||||
|
import { SKILL_LABELS, ALL_SKILL_LEVELS } from '$lib/types/skill-level';
|
||||||
|
|
||||||
|
let {
|
||||||
|
seatConfigs,
|
||||||
|
humanSeatIndex = 0,
|
||||||
|
onSeatChange
|
||||||
|
} = $props<{
|
||||||
|
seatConfigs: SeatConfig[];
|
||||||
|
humanSeatIndex?: number;
|
||||||
|
onSeatChange?: (index: number, config: SeatConfig) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let selectedSeatIndex = $state<number | null>(null);
|
||||||
|
let archetypeValue = $state('');
|
||||||
|
let skillValue = $state('');
|
||||||
|
|
||||||
|
function getSeatPosition(index: number, total: number): string {
|
||||||
|
const angle = (index / total) * 2 * Math.PI - Math.PI / 2;
|
||||||
|
const radiusX = 180;
|
||||||
|
const radiusY = 130;
|
||||||
|
const x = 200 + Math.cos(angle) * radiusX - 40;
|
||||||
|
const y = 150 + Math.sin(angle) * radiusY - 30;
|
||||||
|
return `left: ${x}px; top: ${y}px;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedSeatIndex !== null && selectedSeatIndex !== humanSeatIndex) {
|
||||||
|
const config = seatConfigs[selectedSeatIndex];
|
||||||
|
archetypeValue = config.archetype;
|
||||||
|
skillValue = config.skillLevel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveChanges() {
|
||||||
|
if (selectedSeatIndex === null || selectedSeatIndex === humanSeatIndex) return;
|
||||||
|
const newConfig: SeatConfig = {
|
||||||
|
...seatConfigs[selectedSeatIndex],
|
||||||
|
archetype: archetypeValue as SeatConfig['archetype'],
|
||||||
|
skillLevel: skillValue as SeatConfig['skillLevel']
|
||||||
|
};
|
||||||
|
if (onSeatChange) {
|
||||||
|
onSeatChange(selectedSeatIndex, newConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="table-setup">
|
||||||
|
<div class="poker-table">
|
||||||
|
{#each seatConfigs as config, i}
|
||||||
|
<button
|
||||||
|
class="seat"
|
||||||
|
class:human={i === humanSeatIndex}
|
||||||
|
class:selected={i === selectedSeatIndex}
|
||||||
|
onclick={() => selectedSeatIndex = i}
|
||||||
|
disabled={i === humanSeatIndex}
|
||||||
|
style={getSeatPosition(i, seatConfigs.length)}
|
||||||
|
>
|
||||||
|
<div class="seat-label">Seat {i + 1}</div>
|
||||||
|
{#if i === humanSeatIndex}
|
||||||
|
<div class="human-badge">YOU</div>
|
||||||
|
{:else}
|
||||||
|
<div class="archetype">{ARCHETYPE_LABELS[config.archetype as keyof typeof ARCHETYPE_LABELS]}</div>
|
||||||
|
<div class="skill">{SKILL_LABELS[config.skillLevel as keyof typeof SKILL_LABELS]}</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedSeatIndex !== null && selectedSeatIndex !== humanSeatIndex}
|
||||||
|
<div class="seat-editor">
|
||||||
|
<h3>Edit Seat {selectedSeatIndex + 1}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label for="archetype-select">Archetype</label>
|
||||||
|
<select id="archetype-select" bind:value={archetypeValue} onchange={saveChanges}>
|
||||||
|
{#each ALL_ARCHETYPES as archetype}
|
||||||
|
<option value={archetype}>{ARCHETYPE_LABELS[archetype]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="skill-select">Skill Level</label>
|
||||||
|
<select id="skill-select" bind:value={skillValue} onchange={saveChanges}>
|
||||||
|
{#each ALL_SKILL_LEVELS as level}
|
||||||
|
<option value={level}>{SKILL_LABELS[level]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.table-setup {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.poker-table {
|
||||||
|
position: relative;
|
||||||
|
width: 400px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid #2d5a27;
|
||||||
|
background: radial-gradient(ellipse at center, #1a4a1a 0%, #0d2a0d 100%);
|
||||||
|
}
|
||||||
|
.seat {
|
||||||
|
position: absolute;
|
||||||
|
width: 80px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #444;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: border-color 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.seat:hover {
|
||||||
|
border-color: #888;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.seat.selected {
|
||||||
|
border-color: #4a9eff;
|
||||||
|
box-shadow: 0 0 10px rgba(74, 158, 255, 0.5);
|
||||||
|
}
|
||||||
|
.seat.human {
|
||||||
|
border-color: #ff9800;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.seat-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.human-badge {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
.archetype {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.skill {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.seat-editor {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.seat-editor h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
104
src/lib/game/base-strategy.ts
Normal file
104
src/lib/game/base-strategy.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import type { Card } from '$lib/types/card';
|
||||||
|
import { RANK_VALUES } from '$lib/types/card';
|
||||||
|
|
||||||
|
export type PositionName = 'UTG' | 'MP' | 'CO' | 'BTN' | 'SB' | 'BB' | 'EP' | 'LP' | 'Hijack';
|
||||||
|
|
||||||
|
export interface HandRangeEntry {
|
||||||
|
hand: string;
|
||||||
|
raise: boolean;
|
||||||
|
call: boolean;
|
||||||
|
fold: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankCode(card: Card): string {
|
||||||
|
return RANK_VALUES[card.rank].toString(16).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handToString(c1: Card, c2: Card): string {
|
||||||
|
const r1 = getRankCode(c1);
|
||||||
|
const r2 = getRankCode(c2);
|
||||||
|
const suited = c1.suit === c2.suit;
|
||||||
|
if (r1 === r2) return `${r1}${r2}`;
|
||||||
|
const high = Number(r1) >= Number(r2) ? r1 : r2;
|
||||||
|
const low = Number(r1) >= Number(r2) ? r2 : r1;
|
||||||
|
return suited ? `${high}${low}s` : `${high}${low}o`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlayabilityScore(hand: string, position: PositionName): number {
|
||||||
|
const premiumHands = ['AA', 'KK', 'QQ', 'JJ', 'AKs', 'AKo', 'AQs', 'AJo'];
|
||||||
|
const strongHands = ['TT', '99', '88', '77', '66', '55', '44', '33', '22', 'ATs', 'ATo', 'A9s', 'KQs', 'KQo', 'KJs', 'KTos'];
|
||||||
|
const mediumHands = ['A8s', 'A7s', 'A6s', 'A5s', 'A4s', 'A3s', 'A2s', 'KJo', 'K9s', 'QJs', 'QTo', 'JTo', '98s', '87s', '76s', '65s', '54s', '43s', '32s'];
|
||||||
|
const speculativeHands = ['T9s', '98s', '87s', '76s', '65s', '54s', '43s', '32s', 'AJo', 'A9o', 'A8o', 'KJs', 'KTo', 'QJo'];
|
||||||
|
|
||||||
|
const positionLooseness: Record<PositionName, number> = {
|
||||||
|
UTG: 0.15,
|
||||||
|
EP: 0.15,
|
||||||
|
MP: 0.25,
|
||||||
|
Hijack: 0.3,
|
||||||
|
CO: 0.4,
|
||||||
|
BTN: 0.55,
|
||||||
|
LP: 0.55,
|
||||||
|
SB: 0.5,
|
||||||
|
BB: 0.7
|
||||||
|
};
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (premiumHands.includes(hand)) score = 90;
|
||||||
|
else if (strongHands.includes(hand)) score = 70;
|
||||||
|
else if (mediumHands.includes(hand)) score = 50;
|
||||||
|
else if (speculativeHands.includes(hand)) score = 35;
|
||||||
|
else score = 15;
|
||||||
|
|
||||||
|
return Math.min(100, score + (positionLooseness[position] * 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRE_FLOP_CHART: Record<PositionName, number> = {
|
||||||
|
UTG: 45,
|
||||||
|
EP: 45,
|
||||||
|
MP: 50,
|
||||||
|
Hijack: 55,
|
||||||
|
CO: 62,
|
||||||
|
BTN: 72,
|
||||||
|
LP: 72,
|
||||||
|
SB: 65,
|
||||||
|
BB: 80
|
||||||
|
};
|
||||||
|
|
||||||
|
export function evaluatePreFlop(holeCards: Card[], position: PositionName): { raise: number; call: number; fold: number } {
|
||||||
|
if (holeCards.length < 2) return { raise: 0, call: 0, fold: 100 };
|
||||||
|
|
||||||
|
const hand = handToString(holeCards[0], holeCards[1]);
|
||||||
|
const playability = isPlayabilityScore(hand, position);
|
||||||
|
const threshold = PRE_FLOP_CHART[position];
|
||||||
|
|
||||||
|
if (playability >= threshold + 20) {
|
||||||
|
return { raise: 85, call: 10, fold: 5 };
|
||||||
|
} else if (playability >= threshold) {
|
||||||
|
return { raise: 60, call: 30, fold: 10 };
|
||||||
|
} else if (playability >= threshold - 15) {
|
||||||
|
return { raise: 20, call: 50, fold: 30 };
|
||||||
|
} else {
|
||||||
|
return { raise: 5, call: 20, fold: 75 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPositionName(playerIndex: number, dealerIndex: number, totalPlayers: number): PositionName {
|
||||||
|
const seatsLeft = (totalPlayers - dealerIndex + playerIndex - 1 + totalPlayers) % totalPlayers;
|
||||||
|
|
||||||
|
if (totalPlayers <= 6) {
|
||||||
|
if (seatsLeft === 0) return 'UTG';
|
||||||
|
if (seatsLeft === 1) return totalPlayers <= 4 ? 'BTN' : 'MP';
|
||||||
|
if (seatsLeft === 2) return totalPlayers <= 4 ? 'SB' : 'CO';
|
||||||
|
if (seatsLeft === 3) return totalPlayers <= 4 ? 'BB' : 'BTN';
|
||||||
|
if (seatsLeft === 4) return 'SB';
|
||||||
|
return 'BB';
|
||||||
|
} else {
|
||||||
|
if (seatsLeft === 0) return 'UTG';
|
||||||
|
if (seatsLeft === 1) return 'Hijack';
|
||||||
|
if (seatsLeft === 2) return 'MP';
|
||||||
|
if (seatsLeft === 3) return 'CO';
|
||||||
|
if (seatsLeft >= totalPlayers - 2) return 'BTN';
|
||||||
|
if (seatsLeft === totalPlayers - 2) return 'SB';
|
||||||
|
return 'BB';
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/lib/game/bot-decision-engine.test.ts
Normal file
66
src/lib/game/bot-decision-engine.test.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { makeBotDecision } from '$lib/game/bot-decision-engine';
|
||||||
|
import { createInitialState } from '$lib/game/state';
|
||||||
|
import { startNewHand } from '$lib/game/hand';
|
||||||
|
import type { BotArchetype } from '$lib/types/bot-archetype';
|
||||||
|
import type { SkillLevel } from '$lib/types/skill-level';
|
||||||
|
|
||||||
|
describe('Bot Decision Engine', () => {
|
||||||
|
function setupGameState(
|
||||||
|
archetype: BotArchetype = 'TAG',
|
||||||
|
skill: SkillLevel = 'Medium'
|
||||||
|
) {
|
||||||
|
const state = createInitialState(4, 1000);
|
||||||
|
const handState = startNewHand(state);
|
||||||
|
// Set personality for player at index 1
|
||||||
|
const newState = { ...handState };
|
||||||
|
newState.players[1] = {
|
||||||
|
...newState.players[1],
|
||||||
|
personality: archetype,
|
||||||
|
skillLevel: skill
|
||||||
|
};
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('makes a valid pre-flop decision for TAG bot', () => {
|
||||||
|
const state = setupGameState('TAG', 'Medium');
|
||||||
|
// Move turn to player 1
|
||||||
|
const modifiedState = { ...state, currentTurn: 1 };
|
||||||
|
const decision = makeBotDecision(modifiedState, 1);
|
||||||
|
|
||||||
|
expect(decision.action).toBeOneOf(['check', 'call', 'raise', 'fold']);
|
||||||
|
expect(decision.decisionTime).toBeDefined();
|
||||||
|
expect(decision.decisionTime > 0 && decision.decisionTime <= 10).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Maniac bot has higher aggression than Nit', () => {
|
||||||
|
const maniacRaises = Array.from({ length: 50 }, () => {
|
||||||
|
const s = setupGameState('Maniac', 'Hard');
|
||||||
|
return makeBotDecision({ ...s, currentTurn: 1 }, 1);
|
||||||
|
});
|
||||||
|
const nitRaises = Array.from({ length: 50 }, () => {
|
||||||
|
const s = setupGameState('Nit', 'Hard');
|
||||||
|
return makeBotDecision({ ...s, currentTurn: 1 }, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const maniacAggression = maniacRaises.filter(d => d.action === 'raise' || d.action === 'call').length / 50;
|
||||||
|
const nitAggression = nitRaises.filter(d => d.action === 'raise' || d.action === 'call').length / 50;
|
||||||
|
|
||||||
|
expect(maniacAggression).toBeGreaterThan(nitAggression);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Novice skill level produces more mistakes', () => {
|
||||||
|
const state = setupGameState('TAG', 'Novice');
|
||||||
|
const decisions = Array.from({ length: 20 }, () => {
|
||||||
|
const freshState = createInitialState(4, 1000);
|
||||||
|
const handState = startNewHand(freshState);
|
||||||
|
handState.players[1]!.personality = 'TAG' as BotArchetype;
|
||||||
|
handState.players[1]!.skillLevel = 'Novice' as SkillLevel;
|
||||||
|
return makeBotDecision({ ...handState, currentTurn: 1 }, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Novice should have varied decision times (uncertainty)
|
||||||
|
const avgTime = decisions.reduce((sum, d) => sum + d.decisionTime!, 0) / decisions.length;
|
||||||
|
expect(avgTime).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
242
src/lib/game/bot-decision-engine.ts
Normal file
242
src/lib/game/bot-decision-engine.ts
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
import type { ActionType } from '$lib/types/action';
|
||||||
|
import type { BotArchetype } from '$lib/types/bot-archetype';
|
||||||
|
import type { SkillLevel } from '$lib/types/skill-level';
|
||||||
|
import type { PersonalityProfile } from '$lib/types/personality-profile';
|
||||||
|
import type { PlayerSeat } from '$lib/types/player';
|
||||||
|
import { ALL_PERSONALITY_PROFILES } from '$lib/types/personality-profile';
|
||||||
|
import { getPositionName, evaluatePreFlop } from './base-strategy';
|
||||||
|
import { evaluateHandStrength, calculateBluffFrequency, recommendBetSize, shouldCallDraw, calculatePotOdds } from './post-flop-strategy';
|
||||||
|
import { shouldInjectMistake, pickMistakeForArchetype, type MistakeType } from './mistake-library';
|
||||||
|
|
||||||
|
export interface DecisionResult {
|
||||||
|
action: ActionType;
|
||||||
|
amount?: number;
|
||||||
|
decisionTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeBotDecision(state: GameState, playerIndex: number): DecisionResult {
|
||||||
|
const player = state.players[playerIndex];
|
||||||
|
if (!player.personality || !player.skillLevel) {
|
||||||
|
return fallbackRandomDecision(state, playerIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = ALL_PERSONALITY_PROFILES[player.personality];
|
||||||
|
const baseDecisions = computeBaseStrategy(state, playerIndex, player);
|
||||||
|
const filteredDecisions = applyPersonalityFilter(baseDecisions, profile);
|
||||||
|
const finalDecision = applySkillNoise(filteredDecisions, player.personality, player.skillLevel);
|
||||||
|
|
||||||
|
const decisionTime = computeDecisionTime(player.personality, player.skillLevel, finalDecision.action);
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: finalDecision.action,
|
||||||
|
amount: finalDecision.amount,
|
||||||
|
decisionTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBaseStrategy(state: GameState, playerIndex: number, player: PlayerSeat): { raise: number; call: number; fold: number; check: number } {
|
||||||
|
const position = getPositionName(playerIndex, state.dealerPosition, state.players.length);
|
||||||
|
const handStrength = evaluateHandStrength(player.holeCards, state.communityCards);
|
||||||
|
|
||||||
|
if (state.bettingRound === 'pre-flop') {
|
||||||
|
const preFlop = evaluatePreFlop(player.holeCards, position);
|
||||||
|
return {
|
||||||
|
raise: preFlop.raise,
|
||||||
|
call: preFlop.call,
|
||||||
|
fold: preFlop.fold,
|
||||||
|
check: state.currentBet === 0 ? 20 : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCheck = state.currentBet === player.currentBet;
|
||||||
|
const callAmount = state.currentBet - player.currentBet;
|
||||||
|
const bluffFreq = calculateBluffFrequency(state.communityCards, position);
|
||||||
|
|
||||||
|
let raise = handStrength > 60 ? 40 : handStrength > 35 ? 20 : bluffFreq * 100;
|
||||||
|
let fold = handStrength < 20 ? 50 : handStrength < 35 ? 25 : 5;
|
||||||
|
let call = canCheck ? 0 : (handStrength > 30 ? 40 : 20);
|
||||||
|
let check = canCheck ? (handStrength > 50 ? 20 : 60) : 0;
|
||||||
|
|
||||||
|
if (callAmount > 0) {
|
||||||
|
const potOdds = calculatePotOdds(callAmount, state.pot);
|
||||||
|
if (handStrength < 30 && shouldCallDraw(handStrength / 100, callAmount, state.pot)) {
|
||||||
|
call += 15;
|
||||||
|
} else if (handStrength < 20 && !shouldCallDraw(handStrength / 100, callAmount, state.pot)) {
|
||||||
|
fold += 20;
|
||||||
|
call -= 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = raise + call + fold + check;
|
||||||
|
const norm = total || 1;
|
||||||
|
return {
|
||||||
|
raise: (raise / norm) * 100,
|
||||||
|
call: (call / norm) * 100,
|
||||||
|
fold: (fold / norm) * 100,
|
||||||
|
check: (check / norm) * 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPersonalityFilter(
|
||||||
|
decisions: { raise: number; call: number; fold: number; check: number },
|
||||||
|
profile: PersonalityProfile
|
||||||
|
): { raise: number; call: number; fold: number; check: number } {
|
||||||
|
const result = { ...decisions };
|
||||||
|
|
||||||
|
switch (profile.aggressionPattern) {
|
||||||
|
case 'tight':
|
||||||
|
result.fold *= 1.3;
|
||||||
|
result.raise *= 0.7;
|
||||||
|
result.call *= 0.9;
|
||||||
|
break;
|
||||||
|
case 'aggressive':
|
||||||
|
result.raise *= 1.4;
|
||||||
|
result.fold *= 0.6;
|
||||||
|
result.check *= 0.5;
|
||||||
|
break;
|
||||||
|
case 'passive':
|
||||||
|
result.raise *= 0.3;
|
||||||
|
result.call *= 1.3;
|
||||||
|
result.check *= 1.2;
|
||||||
|
result.fold *= 0.8;
|
||||||
|
break;
|
||||||
|
case 'balanced':
|
||||||
|
result.raise *= 1.1;
|
||||||
|
result.fold *= 0.95;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.bluffFrequency < 0.05) {
|
||||||
|
result.raise *= 0.7;
|
||||||
|
} else if (profile.bluffFrequency > 0.4) {
|
||||||
|
result.raise *= 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = result.raise + result.call + result.fold + result.check;
|
||||||
|
const norm = total || 1;
|
||||||
|
return {
|
||||||
|
raise: (result.raise / norm) * 100,
|
||||||
|
call: (result.call / norm) * 100,
|
||||||
|
fold: (result.fold / norm) * 100,
|
||||||
|
check: (result.check / norm) * 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySkillNoise(
|
||||||
|
decisions: { raise: number; call: number; fold: number; check: number },
|
||||||
|
archetype: BotArchetype,
|
||||||
|
skillLevel: SkillLevel
|
||||||
|
): { action: ActionType; amount?: number } {
|
||||||
|
if (shouldInjectMistake(skillLevel)) {
|
||||||
|
const mistake = pickMistakeForArchetype(archetype);
|
||||||
|
if (mistake) {
|
||||||
|
return applyMistake(mistake, decisions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sampleDecision(decisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMistake(mistake: MistakeType, _decisions: { raise: number; call: number; fold: number; check: number }): { action: ActionType; amount?: number } {
|
||||||
|
switch (mistake) {
|
||||||
|
case 'foldTooMuch':
|
||||||
|
case 'smoothCallThenFold':
|
||||||
|
return { action: 'fold' };
|
||||||
|
case 'missValueBet':
|
||||||
|
case 'missValueCheck':
|
||||||
|
return { action: 'check' };
|
||||||
|
case 'neverBluff':
|
||||||
|
case 'passiveToFault':
|
||||||
|
return { action: 'call' };
|
||||||
|
case 'overbluffRiver':
|
||||||
|
case 'bluffIntoStrength':
|
||||||
|
case 'wideEPRanges':
|
||||||
|
return { action: 'raise', amount: Math.round(Math.random() * 50 + 20) };
|
||||||
|
case 'cantExtractValue':
|
||||||
|
case 'tinyBets':
|
||||||
|
return { action: 'call' };
|
||||||
|
case 'zeroFoldAbility':
|
||||||
|
case 'neverFoldPostFlop':
|
||||||
|
case 'callAnyPair':
|
||||||
|
return { action: 'call' };
|
||||||
|
case 'playDominatedHand':
|
||||||
|
case 'slowPlayTooOften':
|
||||||
|
return { action: 'check' };
|
||||||
|
case 'inconsistentSizing':
|
||||||
|
return { action: 'raise', amount: Math.round(Math.random() * 100 + 5) };
|
||||||
|
case 'callTooWide':
|
||||||
|
return { action: 'call' };
|
||||||
|
case 'surpriseRaise':
|
||||||
|
return Math.random() > 0.5 ? { action: 'raise', amount: 30 } : { action: 'call' };
|
||||||
|
case 'getTrappedByBluff':
|
||||||
|
return { action: 'fold' };
|
||||||
|
default:
|
||||||
|
return { action: 'call' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleDecision(decisions: { raise: number; call: number; fold: number; check: number }): { action: ActionType; amount?: number } {
|
||||||
|
const rand = Math.random() * 100;
|
||||||
|
let cumulative = 0;
|
||||||
|
|
||||||
|
cumulative += decisions.raise;
|
||||||
|
if (rand < cumulative) return { action: 'raise', amount: recommendBetSize(50, 100, 'flop') };
|
||||||
|
|
||||||
|
cumulative += decisions.call;
|
||||||
|
if (rand < cumulative) return { action: 'call' };
|
||||||
|
|
||||||
|
cumulative += decisions.fold;
|
||||||
|
if (rand < cumulative) return { action: 'fold' };
|
||||||
|
|
||||||
|
return { action: 'check' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDecisionTime(archetype: BotArchetype, skillLevel: SkillLevel, action: ActionType): number {
|
||||||
|
const baseTimes: Record<BotArchetype, number> = {
|
||||||
|
Nit: 5,
|
||||||
|
TAG: 4,
|
||||||
|
LAG: 3.5,
|
||||||
|
Maniac: 1.5,
|
||||||
|
CallingStation: 6,
|
||||||
|
LooseFish: 5.5,
|
||||||
|
OldMan: 7,
|
||||||
|
MonsterTAG: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionModifiers: Record<ActionType, number> = {
|
||||||
|
raise: -0.5,
|
||||||
|
call: -1,
|
||||||
|
fold: 1.5,
|
||||||
|
check: -0.5,
|
||||||
|
'all-in': -1
|
||||||
|
};
|
||||||
|
|
||||||
|
const skillNoiseMap: Record<SkillLevel, number> = {
|
||||||
|
Novice: 3,
|
||||||
|
Beginner: 2,
|
||||||
|
Medium: 1,
|
||||||
|
Hard: 0.5,
|
||||||
|
Ultra: 0.3
|
||||||
|
};
|
||||||
|
|
||||||
|
let time = baseTimes[archetype] + (actionModifiers[action] || 0);
|
||||||
|
time += (Math.random() - 0.5) * 2 * skillNoiseMap[skillLevel];
|
||||||
|
|
||||||
|
if (skillLevel === 'Ultra') {
|
||||||
|
time = Math.random() * 8 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0.5, Math.min(10, time));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackRandomDecision(state: GameState, playerIndex: number): DecisionResult {
|
||||||
|
const actions: ActionType[] = ['check', 'call', 'fold'];
|
||||||
|
if (state.currentBet > state.players[playerIndex].currentBet) actions.push('raise');
|
||||||
|
const action = actions[Math.floor(Math.random() * actions.length)];
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
amount: action === 'raise' ? Math.round(Math.random() * 50 + 20) : undefined,
|
||||||
|
decisionTime: Math.random() * 5 + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
136
src/lib/game/bot-turn.ts
Normal file
136
src/lib/game/bot-turn.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
import { makeBotDecision, type DecisionResult } from './bot-decision-engine';
|
||||||
|
import { applyCheck, applyCall, applyRaise, applyFold, applyAllIn } from './actions';
|
||||||
|
import { ALL_PERSONALITY_PROFILES } from '$lib/types/personality-profile';
|
||||||
|
import { isRoundComplete } from './betting-round';
|
||||||
|
|
||||||
|
export interface TurnResult {
|
||||||
|
state: GameState;
|
||||||
|
decision?: DecisionResult;
|
||||||
|
roundCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executePlayerTurn(state: GameState): TurnResult {
|
||||||
|
const player = state.players[state.currentTurn];
|
||||||
|
|
||||||
|
if (player.status !== 'active') {
|
||||||
|
return advanceToNextActive(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.personality && player.skillLevel) {
|
||||||
|
return executeBotTurn(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, roundCompleted: isRoundComplete(state) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeBotTurn(state: GameState): TurnResult {
|
||||||
|
const decision = makeBotDecision(state, state.currentTurn);
|
||||||
|
let newState = state;
|
||||||
|
|
||||||
|
switch (decision.action) {
|
||||||
|
case 'check':
|
||||||
|
newState = applyCheck(newState, state.players[state.currentTurn].id);
|
||||||
|
break;
|
||||||
|
case 'call':
|
||||||
|
newState = applyCall(newState, state.players[state.currentTurn].id);
|
||||||
|
break;
|
||||||
|
case 'raise': {
|
||||||
|
const player = newState.players[state.currentTurn];
|
||||||
|
const raiseAmount = decision.amount || (newState.currentBet + newState.bigBlind);
|
||||||
|
const clampedAmount = Math.min(raiseAmount, player.chips + player.currentBet);
|
||||||
|
newState = applyRaise(newState, player.id, clampedAmount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'fold':
|
||||||
|
newState = applyFold(newState, state.players[state.currentTurn].id);
|
||||||
|
break;
|
||||||
|
case 'all-in':
|
||||||
|
newState = applyAllIn(newState, state.players[state.currentTurn].id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.decisionTime !== undefined) {
|
||||||
|
const actionIdx = newState.actionHistory.length - 1;
|
||||||
|
if (actionIdx >= 0) {
|
||||||
|
newState.actionHistory[actionIdx] = {
|
||||||
|
...newState.actionHistory[actionIdx],
|
||||||
|
decisionTime: decision.decisionTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: newState, decision, roundCompleted: isRoundComplete(newState) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceToNextActive(state: GameState): TurnResult {
|
||||||
|
const numPlayers = state.players.length;
|
||||||
|
let next = (state.currentTurn + 1) % numPlayers;
|
||||||
|
let checked = 0;
|
||||||
|
|
||||||
|
while (checked < numPlayers) {
|
||||||
|
if (state.players[next].status === 'active') {
|
||||||
|
return {
|
||||||
|
state: { ...state, currentTurn: next },
|
||||||
|
roundCompleted: isRoundComplete(state)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
next = (next + 1) % numPlayers;
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, roundCompleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeTimeoutAction(state: GameState): TurnResult {
|
||||||
|
const player = state.players[state.currentTurn];
|
||||||
|
if (!player.personality) {
|
||||||
|
return advanceToNextActive(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = ALL_PERSONALITY_PROFILES[player.personality];
|
||||||
|
let newState = state;
|
||||||
|
|
||||||
|
switch (profile.timeoutDefault) {
|
||||||
|
case 'fold':
|
||||||
|
newState = applyFold(newState, player.id);
|
||||||
|
break;
|
||||||
|
case 'check':
|
||||||
|
newState = applyCheck(newState, player.id);
|
||||||
|
if (newState === state) {
|
||||||
|
newState = applyFold(newState, player.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'call': {
|
||||||
|
const callAmount = newState.currentBet - player.currentBet;
|
||||||
|
if (callAmount > 0 && player.chips >= callAmount) {
|
||||||
|
newState = applyCall(newState, player.id);
|
||||||
|
} else {
|
||||||
|
newState = applyCheck(newState, player.id);
|
||||||
|
if (newState === state) {
|
||||||
|
newState = applyFold(newState, player.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'raise': {
|
||||||
|
const raiseAmount = newState.currentBet + newState.bigBlind;
|
||||||
|
if (player.chips >= raiseAmount - player.currentBet) {
|
||||||
|
newState = applyRaise(newState, player.id, Math.min(raiseAmount, player.chips + player.currentBet));
|
||||||
|
} else {
|
||||||
|
newState = applyCall(newState, player.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionIdx = newState.actionHistory.length - 1;
|
||||||
|
if (actionIdx >= 0) {
|
||||||
|
newState.actionHistory[actionIdx] = {
|
||||||
|
...newState.actionHistory[actionIdx],
|
||||||
|
decisionTime: state.tableConfig.timerDuration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: newState, roundCompleted: isRoundComplete(newState) };
|
||||||
|
}
|
||||||
50
src/lib/game/mistake-library.test.ts
Normal file
50
src/lib/game/mistake-library.test.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { shouldInjectMistake, pickMistakeForArchetype, MISTAKE_LIBRARIES, SKILL_ERROR_RATES } from '$lib/game/mistake-library';
|
||||||
|
import type { BotArchetype } from '$lib/types/bot-archetype';
|
||||||
|
|
||||||
|
describe('Mistake Library', () => {
|
||||||
|
it('has mistake entries for all archetypes', () => {
|
||||||
|
const archetypes: BotArchetype[] = ['Nit', 'TAG', 'LAG', 'Maniac', 'CallingStation', 'LooseFish', 'OldMan', 'MonsterTAG'];
|
||||||
|
for (const archetype of archetypes) {
|
||||||
|
expect(MISTAKE_LIBRARIES[archetype]).toBeDefined();
|
||||||
|
expect(MISTAKE_LIBRARIES[archetype].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Novice has highest error rate', () => {
|
||||||
|
expect(SKILL_ERROR_RATES.Novice).toBeGreaterThan(SKILL_ERROR_RATES.Beginner);
|
||||||
|
expect(SKILL_ERROR_RATES.Beginner).toBeGreaterThan(SKILL_ERROR_RATES.Medium);
|
||||||
|
expect(SKILL_ERROR_RATES.Medium).toBeGreaterThan(SKILL_ERROR_RATES.Hard);
|
||||||
|
expect(SKILL_ERROR_RATES.Hard).toBeGreaterThan(SKILL_ERROR_RATES.Ultra);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ultra skill level rarely injects mistakes', () => {
|
||||||
|
let mistakeCount = 0;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (shouldInjectMistake('Ultra')) mistakeCount++;
|
||||||
|
}
|
||||||
|
expect(mistakeCount / 100).toBeLessThan(0.1); // Less than 10% error rate
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Novice skill level frequently injects mistakes', () => {
|
||||||
|
let mistakeCount = 0;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (shouldInjectMistake('Novice')) mistakeCount++;
|
||||||
|
}
|
||||||
|
expect(mistakeCount / 100).toBeGreaterThan(0.2); // More than 20% error rate
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks valid mistake for archetype', () => {
|
||||||
|
const mistake = pickMistakeForArchetype('Nit');
|
||||||
|
if (mistake) {
|
||||||
|
const library = MISTAKE_LIBRARIES.Nit;
|
||||||
|
expect(library.map(m => m.type)).toContain(mistake);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MonsterTAG has fewest mistakes', () => {
|
||||||
|
const monsterCount = MISTAKE_LIBRARIES.MonsterTAG.length;
|
||||||
|
const maniacCount = MISTAKE_LIBRARIES.Maniac.length;
|
||||||
|
expect(monsterCount).toBeLessThanOrEqual(maniacCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/lib/game/mistake-library.ts
Normal file
98
src/lib/game/mistake-library.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import type { BotArchetype } from '$lib/types/bot-archetype';
|
||||||
|
import type { SkillLevel } from '$lib/types/skill-level';
|
||||||
|
|
||||||
|
export type MistakeType =
|
||||||
|
| 'foldTooMuch'
|
||||||
|
| 'missValueBet'
|
||||||
|
| 'neverBluff'
|
||||||
|
| 'playDominatedHand'
|
||||||
|
| 'slowPlayTooOften'
|
||||||
|
| 'overbluffRiver'
|
||||||
|
| 'wideEPRanges'
|
||||||
|
| 'inconsistentSizing'
|
||||||
|
| 'bluffIntoStrength'
|
||||||
|
| 'cantExtractValue'
|
||||||
|
| 'zeroFoldAbility'
|
||||||
|
| 'callAnyPair'
|
||||||
|
| 'neverFoldPostFlop'
|
||||||
|
| 'tinyBets'
|
||||||
|
| 'callTooWide'
|
||||||
|
| 'surpriseRaise'
|
||||||
|
| 'smoothCallThenFold'
|
||||||
|
| 'missValueCheck'
|
||||||
|
| 'passiveToFault'
|
||||||
|
| 'getTrappedByBluff';
|
||||||
|
|
||||||
|
export interface MistakeEntry {
|
||||||
|
type: MistakeType;
|
||||||
|
description: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MISTAKE_LIBRARIES: Record<BotArchetype, MistakeEntry[]> = {
|
||||||
|
Nit: [
|
||||||
|
{ type: 'foldTooMuch', description: 'Folds marginal hands that could profitably continue', weight: 0.4 },
|
||||||
|
{ type: 'missValueBet', description: 'Checks with strong hand instead of betting for value', weight: 0.35 },
|
||||||
|
{ type: 'neverBluff', description: 'Never attempts a bluff, even in profitable situations', weight: 0.25 }
|
||||||
|
],
|
||||||
|
TAG: [
|
||||||
|
{ type: 'playDominatedHand', description: 'Plays AJ/TJ against a raiser (dominated by higher hands)', weight: 0.3 },
|
||||||
|
{ type: 'slowPlayTooOften', description: 'Slow-plays strong hand unnecessarily, gets bluffed off', weight: 0.35 },
|
||||||
|
{ type: 'missValueBet', description: 'Checks instead of betting on favorable board', weight: 0.35 }
|
||||||
|
],
|
||||||
|
LAG: [
|
||||||
|
{ type: 'overbluffRiver', description: 'Bluffs river too often against calling stations', weight: 0.3 },
|
||||||
|
{ type: 'wideEPRanges', description: 'Plays too many hands from early position', weight: 0.3 },
|
||||||
|
{ type: 'inconsistentSizing', description: 'Bet sizing varies unpredictably, gives away hand strength', weight: 0.4 }
|
||||||
|
],
|
||||||
|
Maniac: [
|
||||||
|
{ type: 'bluffIntoStrength', description: 'Bluffs against obvious strong hands', weight: 0.3 },
|
||||||
|
{ type: 'cantExtractValue', description: 'Checks/raises poorly with strong hands, misses value', weight: 0.35 },
|
||||||
|
{ type: 'zeroFoldAbility', description: 'Never folds regardless of board or action', weight: 0.35 }
|
||||||
|
],
|
||||||
|
CallingStation: [
|
||||||
|
{ type: 'callAnyPair', description: 'Calls with any paired card pre-flop', weight: 0.35 },
|
||||||
|
{ type: 'neverFoldPostFlop', description: 'Rarely folds post-flop once invested', weight: 0.4 },
|
||||||
|
{ type: 'tinyBets', description: 'Bets tiny amounts (1/3 pot or less), fails to extract value', weight: 0.25 }
|
||||||
|
],
|
||||||
|
LooseFish: [
|
||||||
|
{ type: 'callTooWide', description: 'Calls raises with too wide a range pre-flop', weight: 0.35 },
|
||||||
|
{ type: 'surpriseRaise', description: 'Occasionally surprise-raises with unexpected hands', weight: 0.15 },
|
||||||
|
{ type: 'tinyBets', description: 'Bets small amounts, fails to build pot with strong hands', weight: 0.5 }
|
||||||
|
],
|
||||||
|
OldMan: [
|
||||||
|
{ type: 'smoothCallThenFold', description: 'Smooth-calls then folds to aggression, showing weakness', weight: 0.35 },
|
||||||
|
{ type: 'missValueCheck', description: 'Checks behind with strong hand, misses value', weight: 0.4 },
|
||||||
|
{ type: 'passiveToFault', description: 'Never raises or bets aggressively, even with strong hands', weight: 0.25 }
|
||||||
|
],
|
||||||
|
MonsterTAG: [
|
||||||
|
{ type: 'getTrappedByBluff', description: 'Rarely misreads extreme bluffs from maniacs', weight: 0.15 },
|
||||||
|
{ type: 'missValueBet', description: 'Occasionally checks strong hand on scary boards', weight: 0.2 },
|
||||||
|
{ type: 'slowPlayTooOften', description: 'Slow-plays premium hands, risks getting outdrawn', weight: 0.65 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SKILL_ERROR_RATES: Record<SkillLevel, number> = {
|
||||||
|
Novice: 0.40,
|
||||||
|
Beginner: 0.25,
|
||||||
|
Medium: 0.15,
|
||||||
|
Hard: 0.10,
|
||||||
|
Ultra: 0.05
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shouldInjectMistake(skillLevel: SkillLevel): boolean {
|
||||||
|
return Math.random() < SKILL_ERROR_RATES[skillLevel];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickMistakeForArchetype(archetype: BotArchetype): MistakeType | null {
|
||||||
|
const library = MISTAKE_LIBRARIES[archetype];
|
||||||
|
if (!library || library.length === 0) return null;
|
||||||
|
|
||||||
|
const totalWeight = library.reduce((sum, m) => sum + m.weight, 0);
|
||||||
|
let random = Math.random() * totalWeight;
|
||||||
|
for (const mistake of library) {
|
||||||
|
random -= mistake.weight;
|
||||||
|
if (random <= 0) return mistake.type;
|
||||||
|
}
|
||||||
|
return library[0].type;
|
||||||
|
}
|
||||||
69
src/lib/game/observation-tracker.test.ts
Normal file
69
src/lib/game/observation-tracker.test.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { updateObservationsAfterHand, updateBetSizing, updateTimingStats } from '$lib/game/observation-tracker';
|
||||||
|
import { createInitialState } from '$lib/game/state';
|
||||||
|
import { createEmptyObservationStats } from '$lib/types/observation-stats';
|
||||||
|
|
||||||
|
describe('Observation Tracker', () => {
|
||||||
|
it('tracks VPIP correctly when player raises', () => {
|
||||||
|
const state = createInitialState(4, 1000);
|
||||||
|
state.observationData['player-1']!.handsPlayed = 10;
|
||||||
|
state.observationData['player-1']!.volPotEntries = 3;
|
||||||
|
|
||||||
|
// Simulate player-1 raising (voluntarily entering pot)
|
||||||
|
state.actionHistory.push({
|
||||||
|
playerId: 'player-1',
|
||||||
|
type: 'raise',
|
||||||
|
amount: 20,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const newState = updateObservationsAfterHand(state);
|
||||||
|
expect(newState.observationData['player-1']!.volPotEntries).toBe(4);
|
||||||
|
expect(newState.observationData['player-1']!.handsPlayed).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks PFR correctly when player raises pre-flop', () => {
|
||||||
|
const state = createInitialState(4, 1000);
|
||||||
|
state.observationData['player-1']!.handsPlayed = 10;
|
||||||
|
state.observationData['player-1']!.preFlopRaises = 2;
|
||||||
|
|
||||||
|
state.actionHistory.push({
|
||||||
|
playerId: 'player-1',
|
||||||
|
type: 'raise',
|
||||||
|
amount: 30,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const newState = updateObservationsAfterHand(state);
|
||||||
|
expect(newState.observationData['player-1']!.preFlopRaises).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count check as VPIP entry', () => {
|
||||||
|
const state = createInitialState(4, 1000);
|
||||||
|
state.observationData['player-1']!.handsPlayed = 10;
|
||||||
|
state.observationData['player-1']!.volPotEntries = 5;
|
||||||
|
|
||||||
|
state.actionHistory.push({
|
||||||
|
playerId: 'player-1',
|
||||||
|
type: 'check',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const newState = updateObservationsAfterHand(state);
|
||||||
|
expect(newState.observationData['player-1']!.volPotEntries).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates bet sizing profile', () => {
|
||||||
|
const stats = createEmptyObservationStats();
|
||||||
|
updateBetSizing(stats, 'flop', 30, 100);
|
||||||
|
expect(stats.betSizingFlop.averagePercent).toBeGreaterThan(0);
|
||||||
|
expect(stats.betSizingFlop.pattern).toBe('small');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates timing stats', () => {
|
||||||
|
const stats = createEmptyObservationStats();
|
||||||
|
updateTimingStats(stats, 'call', 2.5);
|
||||||
|
expect(stats.timing.avgCallTime).toBe(2.5);
|
||||||
|
expect(stats.timing.avgDecisionTime).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
src/lib/game/observation-tracker.ts
Normal file
121
src/lib/game/observation-tracker.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
import type { ActionRecord } from '$lib/types/action';
|
||||||
|
import type { ObservationStats, BetSizingProfile } from '$lib/types/observation-stats';
|
||||||
|
import { createEmptyObservationStats } from '$lib/types/observation-stats';
|
||||||
|
|
||||||
|
export function updateObservationsAfterHand(state: GameState): GameState {
|
||||||
|
const updatedData = { ...state.observationData };
|
||||||
|
|
||||||
|
for (const player of state.players) {
|
||||||
|
if (!updatedData[player.id]) {
|
||||||
|
updatedData[player.id] = createEmptyObservationStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = { ...updatedData[player.id] };
|
||||||
|
stats.handsPlayed++;
|
||||||
|
|
||||||
|
const playerActions = state.actionHistory.filter(a => a.playerId === player.id);
|
||||||
|
updateVPIP(stats, playerActions, state.bettingRound);
|
||||||
|
updatePFR(stats, playerActions);
|
||||||
|
updateFoldToCBet(stats, playerActions, state);
|
||||||
|
updateWTSD(stats, playerActions, state);
|
||||||
|
|
||||||
|
updatedData[player.id] = stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...state, observationData: updatedData };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVPIP(stats: ObservationStats, actions: ActionRecord[], _bettingRound: string): void {
|
||||||
|
const voluntaryBet = actions.some(a => a.type === 'raise' || (a.type === 'call' && a.amount && a.amount > 0));
|
||||||
|
if (voluntaryBet) {
|
||||||
|
stats.volPotEntries++;
|
||||||
|
}
|
||||||
|
stats.vpip = stats.handsPlayed > 0 ? (stats.volPotEntries / stats.handsPlayed) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePFR(stats: ObservationStats, actions: ActionRecord[]): void {
|
||||||
|
const preFlopRaise = actions.some(a => a.type === 'raise');
|
||||||
|
if (preFlopRaise) {
|
||||||
|
stats.preFlopRaises++;
|
||||||
|
}
|
||||||
|
stats.pfr = stats.handsPlayed > 0 ? (stats.preFlopRaises / stats.handsPlayed) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFoldToCBet(stats: ObservationStats, actions: ActionRecord[], _state: GameState): void {
|
||||||
|
const foldAction = actions.find(a => a.type === 'fold');
|
||||||
|
if (foldAction && actions.length > 1) {
|
||||||
|
stats.foldsToCBet++;
|
||||||
|
stats.cbetFaces++;
|
||||||
|
} else if (actions.length > 0 && !actions.some(a => a.type === 'fold')) {
|
||||||
|
stats.cbetFaces++;
|
||||||
|
}
|
||||||
|
stats.foldToCBet = stats.cbetFaces > 0 ? (stats.foldsToCBet / stats.cbetFaces) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWTSD(stats: ObservationStats, actions: ActionRecord[], _state: GameState): void {
|
||||||
|
if (actions.length >= 2 && !actions.some(a => a.type === 'fold')) {
|
||||||
|
stats.showdowns++;
|
||||||
|
stats.postFlopParticipations++;
|
||||||
|
} else if (actions.length > 0) {
|
||||||
|
stats.postFlopParticipations++;
|
||||||
|
}
|
||||||
|
stats.wtsd = stats.postFlopParticipations > 0 ? (stats.showdowns / stats.postFlopParticipations) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBetSizing(stats: ObservationStats, street: string, betAmount: number, potSize: number): void {
|
||||||
|
const percent = potSize > 0 ? (betAmount / potSize) * 100 : 0;
|
||||||
|
let profile: BetSizingProfile;
|
||||||
|
|
||||||
|
switch (street) {
|
||||||
|
case 'pre-flop': profile = { ...stats.betSizingPreflop }; break;
|
||||||
|
case 'flop': profile = { ...stats.betSizingFlop }; break;
|
||||||
|
case 'turn': profile = { ...stats.betSizingTurn }; break;
|
||||||
|
case 'river': profile = { ...stats.betSizingRiver }; break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Math.floor(profile.averagePercent) + 1;
|
||||||
|
profile.averagePercent = (profile.averagePercent * (count - 1) + percent) / count;
|
||||||
|
|
||||||
|
if (percent < 40) profile.pattern = 'small';
|
||||||
|
else if (percent > 90) profile.pattern = 'large';
|
||||||
|
else if (percent < 30 || percent > 90) profile.pattern = 'polarized';
|
||||||
|
else profile.pattern = 'standard';
|
||||||
|
|
||||||
|
switch (street) {
|
||||||
|
case 'pre-flop': stats.betSizingPreflop = profile; break;
|
||||||
|
case 'flop': stats.betSizingFlop = profile; break;
|
||||||
|
case 'turn': stats.betSizingTurn = profile; break;
|
||||||
|
case 'river': stats.betSizingRiver = profile; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTimingStats(stats: ObservationStats, actionType: string, decisionTime: number): void {
|
||||||
|
const timing = { ...stats.timing };
|
||||||
|
const totalActions = timing.avgDecisionTime > 0 ? Math.floor(timing.avgDecisionTime * 10) : 0;
|
||||||
|
const newCount = totalActions + 1;
|
||||||
|
|
||||||
|
timing.avgDecisionTime = (timing.avgDecisionTime * totalActions + decisionTime) / newCount;
|
||||||
|
|
||||||
|
if (decisionTime < 3) timing.fastActionsPercent = Math.min(100, (timing.fastActionsPercent + 5));
|
||||||
|
if (decisionTime > 7) timing.slowActionsPercent = Math.min(100, (timing.slowActionsPercent + 5));
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case 'call': timing.avgCallTime = averageTime(timing.avgCallTime, decisionTime); break;
|
||||||
|
case 'fold': timing.avgFoldTime = averageTime(timing.avgFoldTime, decisionTime); break;
|
||||||
|
case 'raise': timing.avgRaiseTime = averageTime(timing.avgRaiseTime, decisionTime); break;
|
||||||
|
case 'check': timing.avgCheckTime = averageTime(timing.avgCheckTime, decisionTime); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = Math.abs(timing.avgCallTime - timing.avgFoldTime);
|
||||||
|
timing.consistencyScore = Math.min(90, diff * 15 + 30);
|
||||||
|
|
||||||
|
stats.timing = timing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageTime(current: number, newValue: number): number {
|
||||||
|
if (current === 0) return newValue;
|
||||||
|
const count = Math.floor(current * 10) + 1;
|
||||||
|
return (current * (count - 1) + newValue) / count;
|
||||||
|
}
|
||||||
83
src/lib/game/player-classifier.test.ts
Normal file
83
src/lib/game/player-classifier.test.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { classifyPlayer, detectSecondaryPatterns } from '$lib/game/player-classifier';
|
||||||
|
import { createEmptyObservationStats } from '$lib/types/observation-stats';
|
||||||
|
|
||||||
|
describe('Player Classifier', () => {
|
||||||
|
it('requires minimum 10 hands before classification', () => {
|
||||||
|
const stats = createEmptyObservationStats();
|
||||||
|
stats.handsPlayed = 5;
|
||||||
|
stats.volPotEntries = 2;
|
||||||
|
|
||||||
|
const result = classifyPlayer(stats);
|
||||||
|
expect(result.inferredType).toBeNull();
|
||||||
|
expect(result.confidence).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies high VPIP very low PFR high WTSD as CallingStation', () => {
|
||||||
|
const stats = createEmptyObservationStats();
|
||||||
|
stats.handsPlayed = 25;
|
||||||
|
stats.volPotEntries = 18; // 72% -> but capped at 55 for max score
|
||||||
|
stats.vpip = 45; // within 30-55 range
|
||||||
|
stats.preFlopRaises = 2;
|
||||||
|
stats.pfr = 8; // < 18, good for CallingStation
|
||||||
|
stats.foldToCBet = 5; // < 20, excellent for CallingStation
|
||||||
|
stats.postFlopParticipations = 20;
|
||||||
|
stats.showdowns = 16;
|
||||||
|
stats.wtsd = 80; // > 60, great for CallingStation
|
||||||
|
|
||||||
|
const result = classifyPlayer(stats);
|
||||||
|
expect(result.inferredType).toBe('CallingStation');
|
||||||
|
expect(result.confidence).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies low VPIP low aggression as Nit or MonsterTAG', () => {
|
||||||
|
const stats = createEmptyObservationStats();
|
||||||
|
stats.handsPlayed = 30;
|
||||||
|
stats.volPotEntries = 3;
|
||||||
|
stats.vpip = 10; // very tight
|
||||||
|
stats.preFlopRaises = 2;
|
||||||
|
stats.pfr = 6.7; // tight raising
|
||||||
|
stats.foldToCBet = 55; // folds often to CBets
|
||||||
|
stats.postFlopParticipations = 10;
|
||||||
|
stats.showdowns = 3;
|
||||||
|
stats.wtsd = 30;
|
||||||
|
|
||||||
|
const result = classifyPlayer(stats);
|
||||||
|
expect(result.inferredType).not.toBeNull();
|
||||||
|
expect(['Nit', 'MonsterTAG', 'OldMan']).toContain(result.inferredType as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps confidence at 60% for 10-20 hands', () => {
|
||||||
|
const stats = createEmptyObservationStats();
|
||||||
|
stats.handsPlayed = 15;
|
||||||
|
stats.vpip = 45;
|
||||||
|
stats.pfr = 8;
|
||||||
|
stats.foldToCBet = 5;
|
||||||
|
stats.wtsd = 80;
|
||||||
|
|
||||||
|
const result = classifyPlayer(stats);
|
||||||
|
expect(result.confidence).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects secondary patterns for stubborn aggressive players', () => {
|
||||||
|
const stats = createEmptyObservationStats();
|
||||||
|
stats.handsPlayed = 30;
|
||||||
|
stats.vpip = 40;
|
||||||
|
stats.pfr = 35;
|
||||||
|
stats.foldToCBet = 10;
|
||||||
|
|
||||||
|
const patterns = detectSecondaryPatterns(stats);
|
||||||
|
expect(patterns).toContain('aggressive and stubborn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects showdown pattern for calling stations', () => {
|
||||||
|
const stats = createEmptyObservationStats();
|
||||||
|
stats.handsPlayed = 25;
|
||||||
|
stats.vpip = 40;
|
||||||
|
stats.pfr = 10;
|
||||||
|
stats.wtsd = 70;
|
||||||
|
|
||||||
|
const patterns = detectSecondaryPatterns(stats);
|
||||||
|
expect(patterns).toContain('sees many showdowns, rarely raises');
|
||||||
|
});
|
||||||
|
});
|
||||||
194
src/lib/game/player-classifier.ts
Normal file
194
src/lib/game/player-classifier.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import type { BotArchetype } from '$lib/types/bot-archetype';
|
||||||
|
import type { ObservationStats } from '$lib/types/observation-stats';
|
||||||
|
|
||||||
|
export interface ClassificationResult {
|
||||||
|
inferredType: BotArchetype | null;
|
||||||
|
confidence: number;
|
||||||
|
scores: Record<BotArchetype, number>;
|
||||||
|
secondaryPatterns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_HANDS_FOR_CLASSIFICATION = 10;
|
||||||
|
|
||||||
|
const CONFIDENCE_CAPS = [
|
||||||
|
{ minHands: 0, maxHands: 9, cap: 0 },
|
||||||
|
{ minHands: 10, maxHands: 20, cap: 60 },
|
||||||
|
{ minHands: 21, maxHands: 40, cap: 80 },
|
||||||
|
{ minHands: 41, maxHands: Infinity, cap: 100 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function classifyPlayer(stats: ObservationStats): ClassificationResult {
|
||||||
|
if (stats.handsPlayed < MIN_HANDS_FOR_CLASSIFICATION) {
|
||||||
|
const emptyScores: Record<BotArchetype, number> = {
|
||||||
|
Nit: 0, TAG: 0, LAG: 0, Maniac: 0,
|
||||||
|
CallingStation: 0, LooseFish: 0, OldMan: 0, MonsterTAG: 0
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
inferredType: null,
|
||||||
|
confidence: 0,
|
||||||
|
scores: emptyScores,
|
||||||
|
secondaryPatterns: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores = computeAllScores(stats);
|
||||||
|
const maxScore = Math.max(...Object.values(scores));
|
||||||
|
let bestType: BotArchetype = 'TAG';
|
||||||
|
for (const [type, score] of Object.entries(scores)) {
|
||||||
|
if (score > scores[bestType]) {
|
||||||
|
bestType = type as BotArchetype;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inferredType = bestType;
|
||||||
|
|
||||||
|
const rawConfidence = Math.min(95, (maxScore / 100) * 100);
|
||||||
|
const cap = getConfidenceCap(stats.handsPlayed);
|
||||||
|
const confidence = Math.min(rawConfidence, cap);
|
||||||
|
|
||||||
|
const secondaryPatterns = detectSecondaryPatterns(stats);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inferredType,
|
||||||
|
confidence: Math.round(confidence),
|
||||||
|
scores,
|
||||||
|
secondaryPatterns
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAllScores(stats: ObservationStats): Record<BotArchetype, number> {
|
||||||
|
return {
|
||||||
|
Nit: scoreNit(stats),
|
||||||
|
TAG: scoreTAG(stats),
|
||||||
|
LAG: scoreLAG(stats),
|
||||||
|
Maniac: scoreManiac(stats),
|
||||||
|
CallingStation: scoreCallingStation(stats),
|
||||||
|
LooseFish: scoreLooseFish(stats),
|
||||||
|
OldMan: scoreOldMan(stats),
|
||||||
|
MonsterTAG: scoreMonsterTAG(stats)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreNit(s: ObservationStats): number {
|
||||||
|
let score = 0;
|
||||||
|
if (s.vpip < 20) score += 30;
|
||||||
|
else if (s.vpip < 25) score += 15;
|
||||||
|
if (s.pfr < 15) score += 25;
|
||||||
|
else if (s.pfr < 20) score += 10;
|
||||||
|
if (s.foldToCBet > 50) score += 25;
|
||||||
|
else if (s.foldToCBet > 35) score += 10;
|
||||||
|
if (s.wtsd < 35) score += 20;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreTAG(s: ObservationStats): number {
|
||||||
|
let score = 0;
|
||||||
|
if (s.vpip >= 15 && s.vpip <= 28) score += 30;
|
||||||
|
else if (s.vpip >= 10 && s.vpip <= 32) score += 15;
|
||||||
|
if (s.pfr >= 10 && s.pfr <= 22) score += 25;
|
||||||
|
else if (s.pfr >= 5 && s.pfr <= 28) score += 10;
|
||||||
|
if (s.foldToCBet >= 30 && s.foldToCBet <= 55) score += 20;
|
||||||
|
if (s.wtsd >= 30 && s.wtsd <= 55) score += 25;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreLAG(s: ObservationStats): number {
|
||||||
|
let score = 0;
|
||||||
|
if (s.vpip >= 25 && s.vpip <= 42) score += 30;
|
||||||
|
else if (s.vpip >= 20 && s.vpip <= 48) score += 15;
|
||||||
|
if (s.pfr >= 20 && s.pfr <= 38) score += 25;
|
||||||
|
else if (s.pfr >= 15 && s.pfr <= 42) score += 10;
|
||||||
|
if (s.foldToCBet >= 25 && s.foldToCBet <= 50) score += 20;
|
||||||
|
if (s.wtsd >= 45 && s.wtsd <= 70) score += 25;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreManiac(s: ObservationStats): number {
|
||||||
|
let score = 0;
|
||||||
|
if (s.vpip > 40) score += 30;
|
||||||
|
else if (s.vpip > 35) score += 15;
|
||||||
|
if (s.pfr > 35) score += 25;
|
||||||
|
else if (s.pfr > 28) score += 10;
|
||||||
|
if (s.foldToCBet < 25) score += 25;
|
||||||
|
if (s.wtsd > 60) score += 20;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCallingStation(s: ObservationStats): number {
|
||||||
|
let score = 0;
|
||||||
|
if (s.vpip >= 30 && s.vpip <= 55) score += 20;
|
||||||
|
else if (s.vpip >= 25) score += 10;
|
||||||
|
if (s.pfr < 18) score += 25;
|
||||||
|
else if (s.pfr < 25) score += 10;
|
||||||
|
if (s.foldToCBet < 20) score += 30;
|
||||||
|
if (s.wtsd > 60) score += 25;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreLooseFish(s: ObservationStats): number {
|
||||||
|
let score = 0;
|
||||||
|
if (s.vpip >= 35 && s.vpip <= 55) score += 25;
|
||||||
|
else if (s.vpip >= 30) score += 10;
|
||||||
|
if (s.pfr >= 8 && s.pfr <= 22) score += 25;
|
||||||
|
else if (s.pfr >= 5 && s.pfr <= 28) score += 10;
|
||||||
|
if (s.foldToCBet < 30) score += 20;
|
||||||
|
if (s.wtsd > 50) score += 30;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreOldMan(s: ObservationStats): number {
|
||||||
|
let score = 0;
|
||||||
|
if (s.vpip >= 8 && s.vpip <= 22) score += 25;
|
||||||
|
else if (s.vpip < 28) score += 10;
|
||||||
|
if (s.pfr < 10) score += 30;
|
||||||
|
else if (s.pfr < 15) score += 10;
|
||||||
|
if (s.foldToCBet > 40) score += 20;
|
||||||
|
if (s.wtsd >= 30 && s.wtsd <= 55) score += 25;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreMonsterTAG(s: ObservationStats): number {
|
||||||
|
let score = 0;
|
||||||
|
if (s.vpip >= 8 && s.vpip <= 20) score += 30;
|
||||||
|
else if (s.vpip < 25) score += 10;
|
||||||
|
if (s.pfr >= 8 && s.pfr <= 18) score += 25;
|
||||||
|
else if (s.pfr >= 5 && s.pfr <= 22) score += 10;
|
||||||
|
if (s.foldToCBet >= 35 && s.foldToCBet <= 55) score += 20;
|
||||||
|
if (s.wtsd >= 25 && s.wtsd <= 45) score += 25;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidenceCap(handsPlayed: number): number {
|
||||||
|
for (const bracket of CONFIDENCE_CAPS) {
|
||||||
|
if (handsPlayed >= bracket.minHands && handsPlayed <= bracket.maxHands) {
|
||||||
|
return bracket.cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectSecondaryPatterns(stats: ObservationStats): string[] {
|
||||||
|
const patterns: string[] = [];
|
||||||
|
|
||||||
|
if (stats.betSizingFlop.averagePercent < 40 && stats.handsPlayed > 15) {
|
||||||
|
patterns.push('consistently small bets');
|
||||||
|
}
|
||||||
|
if (stats.betSizingRiver.averagePercent > 90 || stats.betSizingRiver.pattern === 'polarized') {
|
||||||
|
patterns.push('polarized river betting');
|
||||||
|
}
|
||||||
|
|
||||||
|
const timing = stats.timing;
|
||||||
|
if (timing.avgCallTime > 0 && timing.avgFoldTime > 0) {
|
||||||
|
const diff = timing.avgFoldTime - timing.avgCallTime;
|
||||||
|
if (diff > 3) patterns.push('fast on comfort, slow on discomfort');
|
||||||
|
if (diff < -2) patterns.push('reverse timing pattern');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.wtsd > 65 && stats.pfr < 15) {
|
||||||
|
patterns.push('sees many showdowns, rarely raises');
|
||||||
|
}
|
||||||
|
if (stats.foldToCBet < 15 && stats.pfr > 30) {
|
||||||
|
patterns.push('aggressive and stubborn');
|
||||||
|
}
|
||||||
|
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
112
src/lib/game/post-flop-strategy.ts
Normal file
112
src/lib/game/post-flop-strategy.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import type { Card } from '$lib/types/card';
|
||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
import { RANK_VALUES } from '$lib/types/card';
|
||||||
|
|
||||||
|
export function evaluateHandStrength(holeCards: Card[], communityCards: Card[]): number {
|
||||||
|
if (communityCards.length === 0) {
|
||||||
|
return evaluatePreFlopStrength(holeCards);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCards = [...holeCards, ...communityCards];
|
||||||
|
const pairs = countPairs(allCards);
|
||||||
|
const trips = countTrips(allCards);
|
||||||
|
const suited = holeCards[0]?.suit === holeCards[1]?.suit;
|
||||||
|
const flushDraw = countSuit(allCards, suited ? holeCards[0].suit : undefined) >= 4;
|
||||||
|
const straightDraw = hasStraightDraw(allCards);
|
||||||
|
|
||||||
|
let strength = 0;
|
||||||
|
if (trips >= 1) strength += 60;
|
||||||
|
else if (pairs >= 2) strength += 45;
|
||||||
|
else if (pairs >= 1) strength += 25;
|
||||||
|
|
||||||
|
if (flushDraw) strength += 15;
|
||||||
|
if (straightDraw) strength += 10;
|
||||||
|
|
||||||
|
const highCard = Math.max(...holeCards.map(c => RANK_VALUES[c.rank]));
|
||||||
|
strength += (highCard / 14) * 10;
|
||||||
|
|
||||||
|
return Math.min(100, strength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluatePreFlopStrength(holeCards: Card[]): number {
|
||||||
|
if (holeCards.length < 2) return 0;
|
||||||
|
const r1 = RANK_VALUES[holeCards[0].rank];
|
||||||
|
const r2 = RANK_VALUES[holeCards[1].rank];
|
||||||
|
const suited = holeCards[0].suit === holeCards[1].suit;
|
||||||
|
const paired = r1 === r2;
|
||||||
|
|
||||||
|
let score = (Math.max(r1, r2) / 14) * 50 + (Math.min(r1, r2) / 14) * 30;
|
||||||
|
if (suited) score += 10;
|
||||||
|
if (paired) score += 20;
|
||||||
|
return Math.min(100, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPairs(cards: Card[]): number {
|
||||||
|
const ranks = cards.map(c => RANK_VALUES[c.rank]);
|
||||||
|
let pairs = 0;
|
||||||
|
for (let i = 0; i < ranks.length; i++) {
|
||||||
|
for (let j = i + 1; j < ranks.length; j++) {
|
||||||
|
if (ranks[i] === ranks[j]) pairs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTrips(cards: Card[]): number {
|
||||||
|
const rankCounts: Record<number, number> = {};
|
||||||
|
for (const card of cards) {
|
||||||
|
const r = RANK_VALUES[card.rank];
|
||||||
|
rankCounts[r] = (rankCounts[r] || 0) + 1;
|
||||||
|
}
|
||||||
|
return Object.values(rankCounts).filter(c => c >= 3).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSuit(cards: Card[], suit?: string): number {
|
||||||
|
if (!suit) return 0;
|
||||||
|
return cards.filter(c => c.suit === suit).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStraightDraw(cards: Card[]): boolean {
|
||||||
|
const uniqueRanks = [...new Set(cards.map(c => RANK_VALUES[c.rank]))].sort((a, b) => a - b);
|
||||||
|
let consecutive = 1;
|
||||||
|
for (let i = 1; i < uniqueRanks.length; i++) {
|
||||||
|
if (uniqueRanks[i] === uniqueRanks[i - 1] + 1) consecutive++;
|
||||||
|
else consecutive = 1;
|
||||||
|
if (consecutive >= 4) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculatePotOdds(callAmount: number, potSize: number): number {
|
||||||
|
return callAmount / (potSize + callAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCallDraw(equity: number, callAmount: number, potSize: number): boolean {
|
||||||
|
const potOdds = calculatePotOdds(callAmount, potSize);
|
||||||
|
return equity >= potOdds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateBluffFrequency(board: Card[], position: string): number {
|
||||||
|
let baseFreq = 0.15;
|
||||||
|
|
||||||
|
if (position === 'BTN' || position === 'LP') baseFreq += 0.1;
|
||||||
|
if (board.length >= 3) {
|
||||||
|
const pairedBoard = countPairs(board) > 0;
|
||||||
|
const flushPossible = board.filter(c => c.suit === board[0]?.suit).length >= 2;
|
||||||
|
if (pairedBoard || flushPossible) baseFreq -= 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0.05, Math.min(0.4, baseFreq));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recommendBetSize(strength: number, potSize: number, street: string): number {
|
||||||
|
const sizeFactors: Record<string, number> = {
|
||||||
|
'pre-flop': 0.3,
|
||||||
|
'flop': strength > 60 ? 0.75 : strength > 30 ? 0.5 : 0.33,
|
||||||
|
'turn': strength > 60 ? 0.8 : strength > 30 ? 0.6 : 0.4,
|
||||||
|
'river': strength > 70 ? 1.0 : strength > 40 ? 0.66 : 0.33
|
||||||
|
};
|
||||||
|
|
||||||
|
const factor = sizeFactors[street] || 0.5;
|
||||||
|
return Math.round(potSize * factor);
|
||||||
|
}
|
||||||
@ -1,8 +1,23 @@
|
|||||||
import type { GameState, BettingRound } from '$lib/types/game-state';
|
import type { GameState, BettingRound, TableConfig } from '$lib/types/game-state';
|
||||||
import type { PlayerSeat } from '$lib/types/player';
|
import type { PlayerSeat } from '$lib/types/player';
|
||||||
import type { ActionRecord } from '$lib/types/action';
|
import type { ActionRecord } from '$lib/types/action';
|
||||||
|
import type { ObservationStats } from '$lib/types/observation-stats';
|
||||||
|
import { createEmptyObservationStats } from '$lib/types/observation-stats';
|
||||||
|
|
||||||
export function createInitialState(numPlayers: number, startingStack: number): GameState {
|
export const DEFAULT_TABLE_CONFIG: TableConfig = {
|
||||||
|
infoLevel: 'none',
|
||||||
|
feedbackLevel: 'off',
|
||||||
|
timerEnabled: false,
|
||||||
|
timerDuration: 10,
|
||||||
|
humanTimerMode: 'noLimit',
|
||||||
|
humanTimerDuration: 30
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createInitialState(
|
||||||
|
numPlayers: number,
|
||||||
|
startingStack: number,
|
||||||
|
tableConfig: Partial<TableConfig> = {}
|
||||||
|
): GameState {
|
||||||
const players: PlayerSeat[] = [];
|
const players: PlayerSeat[] = [];
|
||||||
for (let i = 0; i < numPlayers; i++) {
|
for (let i = 0; i < numPlayers; i++) {
|
||||||
players.push({
|
players.push({
|
||||||
@ -13,10 +28,17 @@ export function createInitialState(numPlayers: number, startingStack: number): G
|
|||||||
betMatched: false,
|
betMatched: false,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
holeCards: [],
|
holeCards: [],
|
||||||
position: i
|
position: i,
|
||||||
|
personality: null,
|
||||||
|
skillLevel: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const observationData: Record<string, ObservationStats> = {};
|
||||||
|
for (const player of players) {
|
||||||
|
observationData[player.id] = createEmptyObservationStats();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deck: [],
|
deck: [],
|
||||||
players,
|
players,
|
||||||
@ -31,7 +53,9 @@ export function createInitialState(numPlayers: number, startingStack: number): G
|
|||||||
smallBlind: 10,
|
smallBlind: 10,
|
||||||
bigBlind: 20,
|
bigBlind: 20,
|
||||||
lastAggressorIndex: -1,
|
lastAggressorIndex: -1,
|
||||||
sidePots: []
|
sidePots: [],
|
||||||
|
observationData,
|
||||||
|
tableConfig: { ...DEFAULT_TABLE_CONFIG, ...tableConfig }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
163
src/lib/game/table-presets.ts
Normal file
163
src/lib/game/table-presets.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import type { BotArchetype } from '$lib/types/bot-archetype';
|
||||||
|
import type { SkillLevel } from '$lib/types/skill-level';
|
||||||
|
import type { InfoLevel, FeedbackLevel } from '$lib/types/game-state';
|
||||||
|
|
||||||
|
export interface SeatConfig {
|
||||||
|
archetype: BotArchetype;
|
||||||
|
skillLevel: SkillLevel;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableSetupConfig {
|
||||||
|
numPlayers: number;
|
||||||
|
smallBlind: number;
|
||||||
|
bigBlind: number;
|
||||||
|
startingStack: number;
|
||||||
|
seats: SeatConfig[];
|
||||||
|
infoLevel: InfoLevel;
|
||||||
|
feedbackLevel: FeedbackLevel;
|
||||||
|
timerEnabled: boolean;
|
||||||
|
timerDuration: number;
|
||||||
|
humanTimerMode: 'same' | 'noLimit' | 'custom';
|
||||||
|
humanTimerDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresetData {
|
||||||
|
numPlayers: number;
|
||||||
|
smallBlind: number;
|
||||||
|
bigBlind: number;
|
||||||
|
startingStack: number;
|
||||||
|
infoLevel: InfoLevel;
|
||||||
|
feedbackLevel: FeedbackLevel;
|
||||||
|
timerEnabled: boolean;
|
||||||
|
timerDuration: number;
|
||||||
|
humanTimerMode: 'same' | 'noLimit' | 'custom';
|
||||||
|
humanTimerDuration: number;
|
||||||
|
seatConfigs: SeatConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TablePreset = 'fishTable' | 'regularGrind' | 'highStakes' | 'trainingMix' | 'custom';
|
||||||
|
|
||||||
|
export const PRESETS: Record<TablePreset, PresetData> = {
|
||||||
|
fishTable: {
|
||||||
|
numPlayers: 6,
|
||||||
|
smallBlind: 10,
|
||||||
|
bigBlind: 20,
|
||||||
|
startingStack: 2000,
|
||||||
|
infoLevel: 'hints',
|
||||||
|
feedbackLevel: 'postHand',
|
||||||
|
timerEnabled: true,
|
||||||
|
timerDuration: 15,
|
||||||
|
humanTimerMode: 'noLimit',
|
||||||
|
humanTimerDuration: 30,
|
||||||
|
seatConfigs: [
|
||||||
|
{ archetype: 'CallingStation', skillLevel: 'Novice' },
|
||||||
|
{ archetype: 'LooseFish', skillLevel: 'Beginner' },
|
||||||
|
{ archetype: 'CallingStation', skillLevel: 'Beginner' },
|
||||||
|
{ archetype: 'LooseFish', skillLevel: 'Novice' },
|
||||||
|
{ archetype: 'CallingStation', skillLevel: 'Medium' },
|
||||||
|
{ archetype: 'LooseFish', skillLevel: 'Novice' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
regularGrind: {
|
||||||
|
numPlayers: 6,
|
||||||
|
smallBlind: 10,
|
||||||
|
bigBlind: 20,
|
||||||
|
startingStack: 2000,
|
||||||
|
infoLevel: 'stats',
|
||||||
|
feedbackLevel: 'postHand',
|
||||||
|
timerEnabled: true,
|
||||||
|
timerDuration: 10,
|
||||||
|
humanTimerMode: 'same',
|
||||||
|
humanTimerDuration: 10,
|
||||||
|
seatConfigs: [
|
||||||
|
{ archetype: 'TAG', skillLevel: 'Medium' },
|
||||||
|
{ archetype: 'LAG', skillLevel: 'Hard' },
|
||||||
|
{ archetype: 'TAG', skillLevel: 'Hard' },
|
||||||
|
{ archetype: 'LAG', skillLevel: 'Medium' },
|
||||||
|
{ archetype: 'TAG', skillLevel: 'Medium' },
|
||||||
|
{ archetype: 'LAG', skillLevel: 'Hard' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
highStakes: {
|
||||||
|
numPlayers: 6,
|
||||||
|
smallBlind: 50,
|
||||||
|
bigBlind: 100,
|
||||||
|
startingStack: 10000,
|
||||||
|
infoLevel: 'none',
|
||||||
|
feedbackLevel: 'off',
|
||||||
|
timerEnabled: true,
|
||||||
|
timerDuration: 10,
|
||||||
|
humanTimerMode: 'same',
|
||||||
|
humanTimerDuration: 10,
|
||||||
|
seatConfigs: [
|
||||||
|
{ archetype: 'MonsterTAG', skillLevel: 'Ultra' },
|
||||||
|
{ archetype: 'LAG', skillLevel: 'Ultra' },
|
||||||
|
{ archetype: 'TAG', skillLevel: 'Hard' },
|
||||||
|
{ archetype: 'LAG', skillLevel: 'Ultra' },
|
||||||
|
{ archetype: 'MonsterTAG', skillLevel: 'Hard' },
|
||||||
|
{ archetype: 'TAG', skillLevel: 'Ultra' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
trainingMix: {
|
||||||
|
numPlayers: 6,
|
||||||
|
smallBlind: 10,
|
||||||
|
bigBlind: 20,
|
||||||
|
startingStack: 2000,
|
||||||
|
infoLevel: 'hints',
|
||||||
|
feedbackLevel: 'realTime',
|
||||||
|
timerEnabled: true,
|
||||||
|
timerDuration: 15,
|
||||||
|
humanTimerMode: 'noLimit',
|
||||||
|
humanTimerDuration: 30,
|
||||||
|
seatConfigs: [
|
||||||
|
{ archetype: 'Nit', skillLevel: 'Medium' },
|
||||||
|
{ archetype: 'LAG', skillLevel: 'Beginner' },
|
||||||
|
{ archetype: 'CallingStation', skillLevel: 'Medium' },
|
||||||
|
{ archetype: 'TAG', skillLevel: 'Hard' },
|
||||||
|
{ archetype: 'Maniac', skillLevel: 'Novice' },
|
||||||
|
{ archetype: 'OldMan', skillLevel: 'Medium' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
numPlayers: 6,
|
||||||
|
smallBlind: 10,
|
||||||
|
bigBlind: 20,
|
||||||
|
startingStack: 2000,
|
||||||
|
infoLevel: 'none',
|
||||||
|
feedbackLevel: 'off',
|
||||||
|
timerEnabled: false,
|
||||||
|
timerDuration: 10,
|
||||||
|
humanTimerMode: 'noLimit',
|
||||||
|
humanTimerDuration: 30,
|
||||||
|
seatConfigs: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyPreset(preset: TablePreset, numPlayers?: number): TableSetupConfig {
|
||||||
|
const data = PRESETS[preset];
|
||||||
|
const count = numPlayers || data.numPlayers;
|
||||||
|
const seats: SeatConfig[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count - 1; i++) {
|
||||||
|
if (data.seatConfigs[i]) {
|
||||||
|
seats.push({ ...data.seatConfigs[i] });
|
||||||
|
} else {
|
||||||
|
seats.push({ archetype: 'TAG', skillLevel: 'Medium' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
numPlayers: count,
|
||||||
|
smallBlind: data.smallBlind,
|
||||||
|
bigBlind: data.bigBlind,
|
||||||
|
startingStack: data.startingStack,
|
||||||
|
seats,
|
||||||
|
infoLevel: data.infoLevel,
|
||||||
|
feedbackLevel: data.feedbackLevel,
|
||||||
|
timerEnabled: data.timerEnabled,
|
||||||
|
timerDuration: data.timerDuration,
|
||||||
|
humanTimerMode: data.humanTimerMode,
|
||||||
|
humanTimerDuration: data.humanTimerDuration
|
||||||
|
};
|
||||||
|
}
|
||||||
245
src/lib/game/teaching-coach.ts
Normal file
245
src/lib/game/teaching-coach.ts
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
import type { Card } from '$lib/types/card';
|
||||||
|
import { RANK_VALUES } from '$lib/types/card';
|
||||||
|
import { classifyPlayer } from './player-classifier';
|
||||||
|
|
||||||
|
export interface CoachingSuggestion {
|
||||||
|
text: string;
|
||||||
|
type: 'hint' | 'warning' | 'tip' | 'lesson';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostHandAnalysis {
|
||||||
|
optimalAction: string;
|
||||||
|
playerAction: string;
|
||||||
|
assessment: string;
|
||||||
|
opponentRead?: string;
|
||||||
|
lesson?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRealTimeSuggestion(
|
||||||
|
state: GameState,
|
||||||
|
humanIndex: number,
|
||||||
|
infoLevel: GameState['tableConfig']['infoLevel']
|
||||||
|
): CoachingSuggestion | null {
|
||||||
|
const player = state.players[humanIndex];
|
||||||
|
if (player.status !== 'active') return null;
|
||||||
|
|
||||||
|
for (const opponent of state.players) {
|
||||||
|
if (opponent.id === player.id || opponent.status === 'folded') continue;
|
||||||
|
|
||||||
|
const stats = state.observationData[opponent.id];
|
||||||
|
if (!stats || stats.handsPlayed < 10) continue;
|
||||||
|
|
||||||
|
const classification = classifyPlayer(stats);
|
||||||
|
|
||||||
|
if (infoLevel === 'none') {
|
||||||
|
return generateGenericSuggestion(state, humanIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification.inferredType && classification.confidence > 50) {
|
||||||
|
return suggestExploitation(classification.inferredType, opponent.name, infoLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.vpip > 40) {
|
||||||
|
return {
|
||||||
|
text: infoLevel === 'hints'
|
||||||
|
? `${opponent.name} plays a lot of hands — consider raising more`
|
||||||
|
: `${opponent.name} has VPIP ${Math.round(stats.vpip)}% (loose) — raise more for value`,
|
||||||
|
type: 'tip'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.foldToCBet > 55) {
|
||||||
|
return {
|
||||||
|
text: infoLevel === 'hints'
|
||||||
|
? `${opponent.name} folds often to aggression — good bluff spot`
|
||||||
|
: `${opponent.name} folds ${Math.round(stats.foldToCBet)}% to CBets — bluff profitably`,
|
||||||
|
type: 'tip'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateGenericSuggestion(state: GameState, _humanIndex: number): CoachingSuggestion | null {
|
||||||
|
if (state.bettingRound === 'pre-flop') {
|
||||||
|
return {
|
||||||
|
text: 'Pre-flop: Consider your position and hand strength before acting',
|
||||||
|
type: 'hint'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state.currentBet > 0) {
|
||||||
|
return {
|
||||||
|
text: 'Someone has raised. Do you have a strong enough hand to continue?',
|
||||||
|
type: 'hint'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestExploitation(type: string, name: string, infoLevel: string): CoachingSuggestion | null {
|
||||||
|
const suggestions: Record<string, { hints: string; stats: string }> = {
|
||||||
|
Nit: {
|
||||||
|
hints: `${name} plays very tight — bluff more often when they show weakness`,
|
||||||
|
stats: `${name} looks like a Nit (VPIP low) — steal blinds and bluff aggressively`
|
||||||
|
},
|
||||||
|
TAG: {
|
||||||
|
hints: `${name} plays solid poker — respect their raises, don't bluff lightly`,
|
||||||
|
stats: `${name} appears to be a TAG — play straightforward, value bet your strong hands`
|
||||||
|
},
|
||||||
|
LAG: {
|
||||||
|
hints: `${name} plays aggressively and bluffs often — call wider to catch them`,
|
||||||
|
stats: `${name} looks like a LAG — trap them with strong hands, call their bluffs`
|
||||||
|
},
|
||||||
|
Maniac: {
|
||||||
|
hints: `${name} raises everything — just call with decent hands, they'll bluff you off`,
|
||||||
|
stats: `${name} is a Maniac (very high VPIP/PFR) — loose-call, they overbluff`
|
||||||
|
},
|
||||||
|
CallingStation: {
|
||||||
|
hints: `${name} calls too much — never bluff, only bet for value`,
|
||||||
|
stats: `${name} is a Calling Station (high WTSD, low PFR) — value bet only, zero bluffs`
|
||||||
|
},
|
||||||
|
LooseFish: {
|
||||||
|
hints: `${name} plays many hands passively — build big pots with good hands`,
|
||||||
|
stats: `${name} is a Loose Fish — value bet heavily, watch for rare surprise raises`
|
||||||
|
},
|
||||||
|
OldMan: {
|
||||||
|
hints: `${name} plays tight and passive — bluff when they check, they won't fight back`,
|
||||||
|
stats: `${name} looks like an Old Man (tight-passive) — exploit with bluffs and steals`
|
||||||
|
},
|
||||||
|
MonsterTAG: {
|
||||||
|
hints: `${name} plays extremely tight but aggressive — fold unless you're confident`,
|
||||||
|
stats: `${name} is a Monster TAG — respect all aggression, only confront with premium hands`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestion = suggestions[type];
|
||||||
|
if (!suggestion) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: infoLevel === 'hints' || infoLevel === 'none' ? suggestion.hints : suggestion.stats,
|
||||||
|
type: 'tip'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePostHandAnalysis(
|
||||||
|
state: GameState,
|
||||||
|
humanIndex: number,
|
||||||
|
feedbackLevel: GameState['tableConfig']['feedbackLevel']
|
||||||
|
): PostHandAnalysis | null {
|
||||||
|
if (feedbackLevel === 'off') return null;
|
||||||
|
|
||||||
|
const player = state.players[humanIndex];
|
||||||
|
const playerActions = state.actionHistory.filter(a => a.playerId === player.id);
|
||||||
|
if (playerActions.length === 0) return null;
|
||||||
|
|
||||||
|
const lastAction = playerActions[playerActions.length - 1];
|
||||||
|
let assessment = '';
|
||||||
|
let lesson = '';
|
||||||
|
|
||||||
|
switch (lastAction.type) {
|
||||||
|
case 'fold': {
|
||||||
|
const hadFoldEquity = state.players.some(p =>
|
||||||
|
p.id !== player.id && p.status === 'active' && p.currentBet > 0
|
||||||
|
);
|
||||||
|
assessment = hadFoldEquity ? 'Good fold — you identified weakness and cut losses' : 'Consider whether folding was necessary';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'raise': {
|
||||||
|
const handStrength = evaluateQuickStrength(player.holeCards, state.communityCards);
|
||||||
|
if (handStrength > 60) assessment = 'Strong value raise — good aggression with a strong hand';
|
||||||
|
else if (handStrength < 30) assessment = 'Bluff raise — make sure the opponent folds often enough to make this profitable';
|
||||||
|
else assessment = 'Marginal raise — consider if you have enough equity for this bet size';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'call': {
|
||||||
|
assessment = 'You called — make sure you had enough pot odds or implied odds to continue';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'check': {
|
||||||
|
const couldValueBet = evaluateQuickStrength(player.holeCards, state.communityCards) > 50;
|
||||||
|
if (couldValueBet && state.currentBet === player.currentBet) {
|
||||||
|
assessment = 'You checked behind with a strong hand — consider betting for value next time';
|
||||||
|
lesson = 'Checking behind with a strong hand misses value. Opponents might fold on later streets.';
|
||||||
|
} else {
|
||||||
|
assessment = 'Check was appropriate given the situation';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let opponentRead: string | undefined;
|
||||||
|
for (const opponent of state.players) {
|
||||||
|
if (opponent.id === player.id || opponent.status === 'folded') continue;
|
||||||
|
const stats = state.observationData[opponent.id];
|
||||||
|
if (!stats || stats.handsPlayed < 10) continue;
|
||||||
|
|
||||||
|
const classification = classifyPlayer(stats);
|
||||||
|
if (classification.inferredType && classification.confidence > 60) {
|
||||||
|
opponentRead = `${opponent.name} appears to be a ${classification.inferredType} (${classification.confidence}% confidence)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
optimalAction: '',
|
||||||
|
playerAction: lastAction.type,
|
||||||
|
assessment,
|
||||||
|
opponentRead,
|
||||||
|
lesson
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateQuickStrength(holeCards: Card[], communityCards: Card[]): number {
|
||||||
|
if (holeCards.length < 2) return 0;
|
||||||
|
const r1 = RANK_VALUES[holeCards[0].rank];
|
||||||
|
const r2 = RANK_VALUES[holeCards[1].rank];
|
||||||
|
return Math.max(r1, r2) * 3 + (communityCards.length > 0 ? 20 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectReadConfirmation(
|
||||||
|
state: GameState,
|
||||||
|
humanIndex: number,
|
||||||
|
consecutiveExploits: Map<string, number>
|
||||||
|
): { opponentId: string; type: string; lesson: string } | null {
|
||||||
|
const player = state.players[humanIndex];
|
||||||
|
const playerActions = state.actionHistory.filter(a => a.playerId === player.id);
|
||||||
|
const lastAction = playerActions[playerActions.length - 1];
|
||||||
|
|
||||||
|
if (!lastAction || (lastAction.type !== 'raise' && lastAction.type !== 'call')) return null;
|
||||||
|
|
||||||
|
for (const opponent of state.players) {
|
||||||
|
if (opponent.id === player.id) continue;
|
||||||
|
const stats = state.observationData[opponent.id];
|
||||||
|
if (!stats || stats.handsPlayed < 10) continue;
|
||||||
|
|
||||||
|
const classification = classifyPlayer(stats);
|
||||||
|
if (!classification.inferredType || classification.confidence < 60) continue;
|
||||||
|
|
||||||
|
const key = opponent.id;
|
||||||
|
const count = (consecutiveExploits.get(key) || 0) + 1;
|
||||||
|
consecutiveExploits.set(key, count);
|
||||||
|
|
||||||
|
if (count >= 3) {
|
||||||
|
consecutiveExploits.delete(key);
|
||||||
|
const lessons: Record<string, string> = {
|
||||||
|
Nit: 'Nits fold frequently to aggression. Bluffing them is profitable when they show weakness.',
|
||||||
|
TAG: 'TAGs respect position and hand strength. Trap them with premium hands in late position.',
|
||||||
|
LAG: 'LAGs bluff frequently. Calling wider against them pays off — they often miss with their bluffs.',
|
||||||
|
Maniac: 'Maniacs raise everything. Just call with decent hands — they\'ll bluff you off the best hand.',
|
||||||
|
CallingStation: 'Calling stations never fold. Never bluff them — only bet when you have real value.',
|
||||||
|
LooseFish: 'Loose fish play many hands passively. Build big pots with good hands, then value bet relentlessly.',
|
||||||
|
OldMan: 'Old men play tight-passive. Bluff when they check — they rarely fight back.',
|
||||||
|
MonsterTAG: 'Monster TAGs only raise with premium hands. Fold unless you\'re confident — respect their aggression.'
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
opponentId: opponent.id,
|
||||||
|
type: classification.inferredType,
|
||||||
|
lesson: lessons[classification.inferredType] || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -1 +1,33 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type { BotArchetype } from './types/bot-archetype';
|
||||||
|
export { ALL_ARCHETYPES, ARCHETYPE_LABELS } from './types/bot-archetype';
|
||||||
|
export type { SkillLevel } from './types/skill-level';
|
||||||
|
export { ALL_SKILL_LEVELS, SKILL_LABELS } from './types/skill-level';
|
||||||
|
export type { PersonalityProfile } from './types/personality-profile';
|
||||||
|
export { ALL_PERSONALITY_PROFILES } from './types/personality-profile';
|
||||||
|
export type { ObservationStats, BetSizingProfile, TimingProfile } from './types/observation-stats';
|
||||||
|
export { createEmptyObservationStats } from './types/observation-stats';
|
||||||
|
export type { InfoLevel, FeedbackLevel, TableConfig } from './types/game-state';
|
||||||
|
export { DEFAULT_TABLE_CONFIG } from './game/state';
|
||||||
|
|
||||||
|
// Game logic
|
||||||
|
export { makeBotDecision } from './game/bot-decision-engine';
|
||||||
|
export type { DecisionResult } from './game/bot-decision-engine';
|
||||||
|
export { executePlayerTurn, executeTimeoutAction } from './game/bot-turn';
|
||||||
|
export type { TurnResult } from './game/bot-turn';
|
||||||
|
export { shouldInjectMistake, pickMistakeForArchetype } from './game/mistake-library';
|
||||||
|
export { MISTAKE_LIBRARIES, SKILL_ERROR_RATES } from './game/mistake-library';
|
||||||
|
export type { MistakeType, MistakeEntry } from './game/mistake-library';
|
||||||
|
export { evaluatePreFlop, getPositionName } from './game/base-strategy';
|
||||||
|
export { evaluateHandStrength, calculatePotOdds, shouldCallDraw, calculateBluffFrequency, recommendBetSize } from './game/post-flop-strategy';
|
||||||
|
export { updateObservationsAfterHand, updateBetSizing, updateTimingStats } from './game/observation-tracker';
|
||||||
|
export { classifyPlayer, detectSecondaryPatterns } from './game/player-classifier';
|
||||||
|
export type { ClassificationResult } from './game/player-classifier';
|
||||||
|
export { applyPreset } from './game/table-presets';
|
||||||
|
export { PRESETS } from './game/table-presets';
|
||||||
|
export type { SeatConfig, TableSetupConfig, PresetData } from './game/table-presets';
|
||||||
|
export type { TablePreset } from './game/table-presets';
|
||||||
|
export { generateRealTimeSuggestion, generatePostHandAnalysis, detectReadConfirmation } from './game/teaching-coach';
|
||||||
|
export type { CoachingSuggestion, PostHandAnalysis } from './game/teaching-coach';
|
||||||
|
|||||||
@ -5,4 +5,5 @@ export interface ActionRecord {
|
|||||||
type: ActionType;
|
type: ActionType;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
decisionTime?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/lib/types/bot-archetype.ts
Normal file
14
src/lib/types/bot-archetype.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export type BotArchetype = 'Nit' | 'TAG' | 'LAG' | 'Maniac' | 'CallingStation' | 'LooseFish' | 'OldMan' | 'MonsterTAG';
|
||||||
|
|
||||||
|
export const ALL_ARCHETYPES: BotArchetype[] = ['Nit', 'TAG', 'LAG', 'Maniac', 'CallingStation', 'LooseFish', 'OldMan', 'MonsterTAG'];
|
||||||
|
|
||||||
|
export const ARCHETYPE_LABELS: Record<BotArchetype, string> = {
|
||||||
|
Nit: 'Nit (Rock)',
|
||||||
|
TAG: 'TAG (Solid)',
|
||||||
|
LAG: 'LAG (Shark)',
|
||||||
|
Maniac: 'Maniac (Cannon)',
|
||||||
|
CallingStation: 'Calling Station (Fish)',
|
||||||
|
LooseFish: 'Loose Fish',
|
||||||
|
OldMan: 'Old Man',
|
||||||
|
MonsterTAG: 'Monster TAG'
|
||||||
|
};
|
||||||
@ -1,14 +1,26 @@
|
|||||||
import type { Card } from './card';
|
import type { Card } from './card';
|
||||||
import type { PlayerSeat } from './player';
|
import type { PlayerSeat } from './player';
|
||||||
import type { ActionRecord } from './action';
|
import type { ActionRecord } from './action';
|
||||||
|
import type { ObservationStats } from './observation-stats';
|
||||||
|
|
||||||
export type BettingRound = 'pre-flop' | 'flop' | 'turn' | 'river' | 'showdown' | 'idle';
|
export type BettingRound = 'pre-flop' | 'flop' | 'turn' | 'river' | 'showdown' | 'idle';
|
||||||
|
export type InfoLevel = 'none' | 'hints' | 'stats' | 'fullReveal';
|
||||||
|
export type FeedbackLevel = 'off' | 'postHand' | 'realTime';
|
||||||
|
|
||||||
export interface SidePot {
|
export interface SidePot {
|
||||||
amount: number;
|
amount: number;
|
||||||
eligiblePlayerIds: string[];
|
eligiblePlayerIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TableConfig {
|
||||||
|
infoLevel: InfoLevel;
|
||||||
|
feedbackLevel: FeedbackLevel;
|
||||||
|
timerEnabled: boolean;
|
||||||
|
timerDuration: number;
|
||||||
|
humanTimerMode: 'same' | 'noLimit' | 'custom';
|
||||||
|
humanTimerDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
deck: Card[];
|
deck: Card[];
|
||||||
players: PlayerSeat[];
|
players: PlayerSeat[];
|
||||||
@ -24,4 +36,6 @@ export interface GameState {
|
|||||||
bigBlind: number;
|
bigBlind: number;
|
||||||
lastAggressorIndex: number;
|
lastAggressorIndex: number;
|
||||||
sidePots: SidePot[];
|
sidePots: SidePot[];
|
||||||
|
observationData: Record<string, ObservationStats>;
|
||||||
|
tableConfig: TableConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/lib/types/observation-stats.ts
Normal file
64
src/lib/types/observation-stats.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
export interface BetSizingProfile {
|
||||||
|
averagePercent: number;
|
||||||
|
pattern?: 'small' | 'standard' | 'large' | 'polarized';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimingProfile {
|
||||||
|
avgDecisionTime: number;
|
||||||
|
fastActionsPercent: number;
|
||||||
|
slowActionsPercent: number;
|
||||||
|
avgCallTime: number;
|
||||||
|
avgFoldTime: number;
|
||||||
|
avgRaiseTime: number;
|
||||||
|
avgCheckTime: number;
|
||||||
|
consistencyScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationStats {
|
||||||
|
handsPlayed: number;
|
||||||
|
vpip: number;
|
||||||
|
pfr: number;
|
||||||
|
foldToCBet: number;
|
||||||
|
wtsd: number;
|
||||||
|
volPotEntries: number;
|
||||||
|
preFlopRaises: number;
|
||||||
|
foldsToCBet: number;
|
||||||
|
cbetFaces: number;
|
||||||
|
showdowns: number;
|
||||||
|
postFlopParticipations: number;
|
||||||
|
betSizingPreflop: BetSizingProfile;
|
||||||
|
betSizingFlop: BetSizingProfile;
|
||||||
|
betSizingTurn: BetSizingProfile;
|
||||||
|
betSizingRiver: BetSizingProfile;
|
||||||
|
timing: TimingProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyObservationStats(): ObservationStats {
|
||||||
|
return {
|
||||||
|
handsPlayed: 0,
|
||||||
|
vpip: 0,
|
||||||
|
pfr: 0,
|
||||||
|
foldToCBet: 0,
|
||||||
|
wtsd: 0,
|
||||||
|
volPotEntries: 0,
|
||||||
|
preFlopRaises: 0,
|
||||||
|
foldsToCBet: 0,
|
||||||
|
cbetFaces: 0,
|
||||||
|
showdowns: 0,
|
||||||
|
postFlopParticipations: 0,
|
||||||
|
betSizingPreflop: { averagePercent: 0 },
|
||||||
|
betSizingFlop: { averagePercent: 0 },
|
||||||
|
betSizingTurn: { averagePercent: 0 },
|
||||||
|
betSizingRiver: { averagePercent: 0 },
|
||||||
|
timing: {
|
||||||
|
avgDecisionTime: 0,
|
||||||
|
fastActionsPercent: 0,
|
||||||
|
slowActionsPercent: 0,
|
||||||
|
avgCallTime: 0,
|
||||||
|
avgFoldTime: 0,
|
||||||
|
avgRaiseTime: 0,
|
||||||
|
avgCheckTime: 0,
|
||||||
|
consistencyScore: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
96
src/lib/types/personality-profile.ts
Normal file
96
src/lib/types/personality-profile.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { BotArchetype } from './bot-archetype';
|
||||||
|
import type { ActionType } from './action';
|
||||||
|
|
||||||
|
export interface PersonalityProfile {
|
||||||
|
archetype: BotArchetype;
|
||||||
|
vpipMin: number;
|
||||||
|
vpipMax: number;
|
||||||
|
pfrMin: number;
|
||||||
|
pfrMax: number;
|
||||||
|
bluffFrequency: number;
|
||||||
|
aggressionPattern: 'tight' | 'balanced' | 'aggressive' | 'passive';
|
||||||
|
timeoutDefault: ActionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_PERSONALITY_PROFILES: Record<BotArchetype, PersonalityProfile> = {
|
||||||
|
Nit: {
|
||||||
|
archetype: 'Nit',
|
||||||
|
vpipMin: 5,
|
||||||
|
vpipMax: 15,
|
||||||
|
pfrMin: 4,
|
||||||
|
pfrMax: 12,
|
||||||
|
bluffFrequency: 0.02,
|
||||||
|
aggressionPattern: 'tight',
|
||||||
|
timeoutDefault: 'fold'
|
||||||
|
},
|
||||||
|
TAG: {
|
||||||
|
archetype: 'TAG',
|
||||||
|
vpipMin: 15,
|
||||||
|
vpipMax: 25,
|
||||||
|
pfrMin: 10,
|
||||||
|
pfrMax: 20,
|
||||||
|
bluffFrequency: 0.15,
|
||||||
|
aggressionPattern: 'balanced',
|
||||||
|
timeoutDefault: 'check'
|
||||||
|
},
|
||||||
|
LAG: {
|
||||||
|
archetype: 'LAG',
|
||||||
|
vpipMin: 25,
|
||||||
|
vpipMax: 40,
|
||||||
|
pfrMin: 20,
|
||||||
|
pfrMax: 35,
|
||||||
|
bluffFrequency: 0.35,
|
||||||
|
aggressionPattern: 'aggressive',
|
||||||
|
timeoutDefault: 'call'
|
||||||
|
},
|
||||||
|
Maniac: {
|
||||||
|
archetype: 'Maniac',
|
||||||
|
vpipMin: 40,
|
||||||
|
vpipMax: 60,
|
||||||
|
pfrMin: 35,
|
||||||
|
pfrMax: 55,
|
||||||
|
bluffFrequency: 0.6,
|
||||||
|
aggressionPattern: 'aggressive',
|
||||||
|
timeoutDefault: 'raise'
|
||||||
|
},
|
||||||
|
CallingStation: {
|
||||||
|
archetype: 'CallingStation',
|
||||||
|
vpipMin: 30,
|
||||||
|
vpipMax: 50,
|
||||||
|
pfrMin: 5,
|
||||||
|
pfrMax: 15,
|
||||||
|
bluffFrequency: 0.0,
|
||||||
|
aggressionPattern: 'passive',
|
||||||
|
timeoutDefault: 'call'
|
||||||
|
},
|
||||||
|
LooseFish: {
|
||||||
|
archetype: 'LooseFish',
|
||||||
|
vpipMin: 35,
|
||||||
|
vpipMax: 50,
|
||||||
|
pfrMin: 10,
|
||||||
|
pfrMax: 20,
|
||||||
|
bluffFrequency: 0.05,
|
||||||
|
aggressionPattern: 'passive',
|
||||||
|
timeoutDefault: 'call'
|
||||||
|
},
|
||||||
|
OldMan: {
|
||||||
|
archetype: 'OldMan',
|
||||||
|
vpipMin: 10,
|
||||||
|
vpipMax: 20,
|
||||||
|
pfrMin: 3,
|
||||||
|
pfrMax: 8,
|
||||||
|
bluffFrequency: 0.0,
|
||||||
|
aggressionPattern: 'passive',
|
||||||
|
timeoutDefault: 'call'
|
||||||
|
},
|
||||||
|
MonsterTAG: {
|
||||||
|
archetype: 'MonsterTAG',
|
||||||
|
vpipMin: 10,
|
||||||
|
vpipMax: 18,
|
||||||
|
pfrMin: 9,
|
||||||
|
pfrMax: 16,
|
||||||
|
bluffFrequency: 0.1,
|
||||||
|
aggressionPattern: 'tight',
|
||||||
|
timeoutDefault: 'check'
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import type { Card } from './card';
|
import type { Card } from './card';
|
||||||
|
import type { BotArchetype } from './bot-archetype';
|
||||||
|
import type { SkillLevel } from './skill-level';
|
||||||
|
|
||||||
export type PlayerStatus = 'active' | 'folded' | 'all-in';
|
export type PlayerStatus = 'active' | 'folded' | 'all-in';
|
||||||
|
|
||||||
@ -11,4 +13,6 @@ export interface PlayerSeat {
|
|||||||
status: PlayerStatus;
|
status: PlayerStatus;
|
||||||
holeCards: Card[];
|
holeCards: Card[];
|
||||||
position: number;
|
position: number;
|
||||||
|
personality: BotArchetype | null;
|
||||||
|
skillLevel: SkillLevel | null;
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/lib/types/skill-level.ts
Normal file
11
src/lib/types/skill-level.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export type SkillLevel = 'Novice' | 'Beginner' | 'Medium' | 'Hard' | 'Ultra';
|
||||||
|
|
||||||
|
export const ALL_SKILL_LEVELS: SkillLevel[] = ['Novice', 'Beginner', 'Medium', 'Hard', 'Ultra'];
|
||||||
|
|
||||||
|
export const SKILL_LABELS: Record<SkillLevel, string> = {
|
||||||
|
Novice: 'Novice',
|
||||||
|
Beginner: 'Beginner',
|
||||||
|
Medium: 'Medium',
|
||||||
|
Hard: 'Hard',
|
||||||
|
Ultra: 'Ultra'
|
||||||
|
};
|
||||||
@ -1,18 +1,61 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PokerTable from '$lib/components/PokerTable.svelte';
|
import PokerTable from '$lib/components/PokerTable.svelte';
|
||||||
import BetControls from '$lib/components/BetControls.svelte';
|
import BetControls from '$lib/components/BetControls.svelte';
|
||||||
import { createInitialState, rotateDealer } from '$lib/game/state';
|
import DecisionTimer from '$lib/components/DecisionTimer.svelte';
|
||||||
|
import PostHandAnalysis from '$lib/components/PostHandAnalysis.svelte';
|
||||||
|
import ReadConfirmed from '$lib/components/ReadConfirmed.svelte';
|
||||||
|
|
||||||
|
import { createInitialState } from '$lib/game/state';
|
||||||
import { startNewHand } from '$lib/game/hand';
|
import { startNewHand } from '$lib/game/hand';
|
||||||
import { applyCheck, applyCall, applyRaise, applyFold, applyAllIn } from '$lib/game/actions';
|
import { applyCheck, applyCall, applyRaise, applyFold, applyAllIn } from '$lib/game/actions';
|
||||||
import { completeBettingRound, isRoundComplete } from '$lib/game/betting-round';
|
import { completeBettingRound, isRoundComplete } from '$lib/game/betting-round';
|
||||||
import { dealCommunityCards } from '$lib/game/dealing';
|
import { dealCommunityCards } from '$lib/game/dealing';
|
||||||
import { determineWinner, awardPot } from '$lib/game/showdown';
|
import { determineWinner, awardPot } from '$lib/game/showdown';
|
||||||
|
import { executePlayerTurn, executeTimeoutAction } from '$lib/game/bot-turn';
|
||||||
|
import { updateObservationsAfterHand } from '$lib/game/observation-tracker';
|
||||||
|
import { classifyPlayer } from '$lib/game/player-classifier';
|
||||||
|
import { generatePostHandAnalysis, detectReadConfirmation } from '$lib/game/teaching-coach';
|
||||||
|
import { applyPreset, PRESETS } from '$lib/game/table-presets';
|
||||||
|
|
||||||
let initialState = createInitialState(6, 1000);
|
import type { GameState } from '$lib/types/game-state';
|
||||||
let gameState = $state(startNewHand(initialState));
|
import type { PostHandAnalysis as PostHandAnalysisType } from '$lib/game/teaching-coach';
|
||||||
|
import type { BotArchetype } from '$lib/types/bot-archetype';
|
||||||
|
import type { TableSetupConfig } from '$lib/game/table-presets';
|
||||||
|
|
||||||
|
// Initialize with training mix preset
|
||||||
|
const preset = applyPreset('trainingMix');
|
||||||
|
let baseState = createInitialState(preset.numPlayers, preset.startingStack, {
|
||||||
|
infoLevel: preset.infoLevel,
|
||||||
|
feedbackLevel: preset.feedbackLevel,
|
||||||
|
timerEnabled: preset.timerEnabled,
|
||||||
|
timerDuration: preset.timerDuration,
|
||||||
|
humanTimerMode: preset.humanTimerMode,
|
||||||
|
humanTimerDuration: preset.humanTimerDuration
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply bot personalities from preset
|
||||||
|
const seatConfigs = PRESETS.trainingMix.seatConfigs;
|
||||||
|
for (let i = 0; i < preset.numPlayers - 1 && i < seatConfigs.length; i++) {
|
||||||
|
baseState.players[i + 1]!.personality = seatConfigs[i].archetype;
|
||||||
|
baseState.players[i + 1]!.skillLevel = seatConfigs[i].skillLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gameState = $state<GameState>(startNewHand(baseState));
|
||||||
|
|
||||||
let message = $state('');
|
let message = $state('');
|
||||||
let aiActing = $state(false);
|
let aiActing = $state(false);
|
||||||
|
let timerActive = $state(false);
|
||||||
|
let currentTimerPlayer = $state('');
|
||||||
|
let currentTimerArchetype = $state<BotArchetype | undefined>(undefined);
|
||||||
|
|
||||||
|
// Teaching coach state
|
||||||
|
let postHandAnalysis = $state<PostHandAnalysisType | undefined>(undefined);
|
||||||
|
let showPostHand = $state(false);
|
||||||
|
let readConfirmed = $state(false);
|
||||||
|
let readData = $state({ opponentName: '', inferredType: '', lesson: '' });
|
||||||
|
|
||||||
|
// Track consecutive exploitations for read confirmation
|
||||||
|
let consecutiveExploits = $state(new Map<string, number>());
|
||||||
|
|
||||||
function checkInitialTurn() {
|
function checkInitialTurn() {
|
||||||
if (gameState.currentTurn !== 0) {
|
if (gameState.currentTurn !== 0) {
|
||||||
@ -25,7 +68,7 @@
|
|||||||
if (aiActing) return;
|
if (aiActing) return;
|
||||||
if (gameState.bettingRound === 'idle' || gameState.bettingRound === 'showdown') return;
|
if (gameState.bettingRound === 'idle' || gameState.bettingRound === 'showdown') return;
|
||||||
|
|
||||||
let newState: typeof gameState;
|
let newState: GameState;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'check':
|
case 'check':
|
||||||
newState = applyCheck(gameState, 'player-0');
|
newState = applyCheck(gameState, 'player-0');
|
||||||
@ -58,108 +101,181 @@
|
|||||||
const activeCount = gameState.players.filter(p => p.status === 'active' || p.status === 'all-in').length;
|
const activeCount = gameState.players.filter(p => p.status === 'active' || p.status === 'all-in').length;
|
||||||
|
|
||||||
if (activeCount <= 1) {
|
if (activeCount <= 1) {
|
||||||
const winner = gameState.players.find(p => p.status !== 'folded')!;
|
handleUncontestedPot();
|
||||||
gameState = awardPot(gameState, [winner.id]);
|
|
||||||
gameState.bettingRound = 'showdown';
|
|
||||||
message = `${winner.name} wins the pot!`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRoundComplete(gameState)) {
|
if (isRoundComplete(gameState)) {
|
||||||
const processed = completeBettingRound(gameState);
|
handleRoundCompletion();
|
||||||
gameState = { ...processed };
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (gameState.bettingRound === 'flop') {
|
const nextPlayer = gameState.players[gameState.currentTurn];
|
||||||
const { deck, cards } = dealCommunityCards(gameState.deck, 3);
|
if (nextPlayer && nextPlayer.status === 'active' && gameState.currentTurn !== 0) {
|
||||||
gameState.deck = deck;
|
aiAct();
|
||||||
gameState.communityCards = cards;
|
} else if (nextPlayer && nextPlayer.status !== 'active') {
|
||||||
} else if (gameState.bettingRound === 'turn' || gameState.bettingRound === 'river') {
|
advanceTurn();
|
||||||
const { deck, cards } = dealCommunityCards(gameState.deck, 1);
|
}
|
||||||
gameState.deck = deck;
|
}
|
||||||
gameState.communityCards = [...gameState.communityCards, ...cards];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gameState.bettingRound === 'showdown') {
|
function handleUncontestedPot(winnerId?: string) {
|
||||||
const { winners } = determineWinner(gameState);
|
const winner = gameState.players.find(p => p.status !== 'folded')!;
|
||||||
gameState = awardPot(gameState, winners);
|
const beforePot = gameState.pot;
|
||||||
const winnerNames = winners.map(id => gameState.players.find(p => p.id === id)?.name).filter(Boolean);
|
gameState = awardPot(gameState, [winner.id]);
|
||||||
message = `${winnerNames.join(', ')} wins!`;
|
gameState.bettingRound = 'showdown';
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gameState.currentTurn !== 0) {
|
onHandComplete(winner.id, beforePot);
|
||||||
aiAct();
|
message = `${winner.name} wins the pot!`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRoundCompletion() {
|
||||||
|
const processed = completeBettingRound(gameState);
|
||||||
|
gameState = { ...processed };
|
||||||
|
|
||||||
|
if (gameState.bettingRound === 'flop') {
|
||||||
|
const { deck, cards } = dealCommunityCards(gameState.deck, 3);
|
||||||
|
gameState.deck = deck;
|
||||||
|
gameState.communityCards = cards;
|
||||||
|
} else if (gameState.bettingRound === 'turn' || gameState.bettingRound === 'river') {
|
||||||
|
const { deck, cards } = dealCommunityCards(gameState.deck, 1);
|
||||||
|
gameState.deck = deck;
|
||||||
|
gameState.communityCards = [...gameState.communityCards, ...cards];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.bettingRound === 'showdown') {
|
||||||
|
const { winners } = determineWinner(gameState);
|
||||||
|
const beforePot = gameState.pot;
|
||||||
|
gameState = awardPot(gameState, winners);
|
||||||
|
const winnerNames = winners.map(id => gameState.players.find(p => p.id === id)?.name).filter(Boolean);
|
||||||
|
message = `${winnerNames.join(', ')} wins!`;
|
||||||
|
|
||||||
|
onHandComplete(winners[0], beforePot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if only one player remains after round completion
|
||||||
|
const activeCount = gameState.players.filter(p => p.status === 'active' || p.status === 'all-in').length;
|
||||||
|
if (activeCount <= 1) {
|
||||||
|
handleUncontestedPot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameState.currentTurn !== 0) {
|
if (gameState.currentTurn !== 0) {
|
||||||
const nextPlayer = gameState.players[gameState.currentTurn];
|
aiAct();
|
||||||
if (nextPlayer && nextPlayer.status === 'active') {
|
|
||||||
aiAct();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function aiAct() {
|
function advanceTurn() {
|
||||||
aiActing = true;
|
const numPlayers = gameState.players.length;
|
||||||
|
let next = (gameState.currentTurn + 1) % numPlayers;
|
||||||
|
let checked = 0;
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 400));
|
while (checked < numPlayers) {
|
||||||
|
if (gameState.players[next].status === 'active') {
|
||||||
const aiIndex = gameState.currentTurn;
|
gameState = { ...gameState, currentTurn: next };
|
||||||
if (aiIndex === 0) {
|
processTurn();
|
||||||
aiActing = false;
|
return;
|
||||||
return;
|
}
|
||||||
|
next = (next + 1) % numPlayers;
|
||||||
|
checked++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiPlayer = gameState.players[aiIndex];
|
const winner = gameState.players.find(p => p.status !== 'folded')!;
|
||||||
|
gameState = awardPot(gameState, [winner.id]);
|
||||||
|
gameState.bettingRound = 'showdown';
|
||||||
|
message = `${winner.name} wins the pot!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aiAct() {
|
||||||
|
aiActing = true;
|
||||||
|
|
||||||
|
const aiPlayer = gameState.players[gameState.currentTurn];
|
||||||
if (!aiPlayer || aiPlayer.status !== 'active') {
|
if (!aiPlayer || aiPlayer.status !== 'active') {
|
||||||
aiActing = false;
|
aiActing = false;
|
||||||
|
timerActive = false;
|
||||||
processTurn();
|
processTurn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions: string[] = [];
|
// Start or update timer for bot decision
|
||||||
if (aiPlayer.currentBet >= gameState.currentBet) actions.push('check');
|
if (gameState.tableConfig.timerEnabled) {
|
||||||
else actions.push('call');
|
if (!timerActive) {
|
||||||
|
timerActive = true;
|
||||||
|
}
|
||||||
|
currentTimerPlayer = aiPlayer.name;
|
||||||
|
currentTimerArchetype = aiPlayer.personality || undefined;
|
||||||
|
|
||||||
if (aiPlayer.chips > gameState.currentBet - aiPlayer.currentBet + gameState.bigBlind) {
|
const duration = gameState.tableConfig.timerDuration;
|
||||||
actions.push('raise');
|
const thinkTime = Math.min(duration - 1, 3);
|
||||||
}
|
await new Promise(resolve => setTimeout(resolve, thinkTime * 1000));
|
||||||
if (Math.random() < 0.15) actions.push('fold');
|
} else {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 400));
|
||||||
const action = actions[Math.floor(Math.random() * actions.length)];
|
|
||||||
|
|
||||||
let newState: typeof gameState;
|
|
||||||
switch (action) {
|
|
||||||
case 'check':
|
|
||||||
newState = applyCheck(gameState, aiPlayer.id);
|
|
||||||
break;
|
|
||||||
case 'call':
|
|
||||||
newState = applyCall(gameState, aiPlayer.id);
|
|
||||||
break;
|
|
||||||
case 'raise':
|
|
||||||
const raiseAmt = Math.min(gameState.currentBet + gameState.bigBlind, aiPlayer.chips + aiPlayer.currentBet);
|
|
||||||
newState = applyRaise(gameState, aiPlayer.id, raiseAmt);
|
|
||||||
break;
|
|
||||||
case 'fold':
|
|
||||||
newState = applyFold(gameState, aiPlayer.id);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
newState = applyCall(gameState, aiPlayer.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newState !== gameState) {
|
// Execute bot turn using personality-driven decision engine
|
||||||
gameState = { ...newState };
|
const result = executePlayerTurn(gameState);
|
||||||
|
if (result.state !== gameState) {
|
||||||
|
gameState = { ...result.state };
|
||||||
}
|
}
|
||||||
|
|
||||||
aiActing = false;
|
aiActing = false;
|
||||||
|
timerActive = false;
|
||||||
processTurn();
|
processTurn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTimerTimeout() {
|
||||||
|
if (!aiActing && gameState.currentTurn !== 0) {
|
||||||
|
timerActive = false;
|
||||||
|
const result = executeTimeoutAction(gameState);
|
||||||
|
if (result.state !== gameState) {
|
||||||
|
gameState = { ...result.state };
|
||||||
|
}
|
||||||
|
aiActing = false;
|
||||||
|
processTurn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHandComplete(winnerId: string, potWon: number) {
|
||||||
|
// Update observations
|
||||||
|
gameState = updateObservationsAfterHand(gameState);
|
||||||
|
|
||||||
|
// Classify all bot players
|
||||||
|
for (let i = 1; i < gameState.players.length; i++) {
|
||||||
|
const player = gameState.players[i];
|
||||||
|
if (player.personality && gameState.observationData[player.id]) {
|
||||||
|
classifyPlayer(gameState.observationData[player.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teaching coach - post hand analysis
|
||||||
|
if (gameState.tableConfig.feedbackLevel === 'postHand' || gameState.tableConfig.feedbackLevel === 'realTime') {
|
||||||
|
const analysis = generatePostHandAnalysis(gameState, 0, gameState.tableConfig.feedbackLevel);
|
||||||
|
if (analysis) {
|
||||||
|
postHandAnalysis = analysis;
|
||||||
|
showPostHand = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for read confirmation
|
||||||
|
const readResult = detectReadConfirmation(gameState, 0, consecutiveExploits);
|
||||||
|
if (readResult) {
|
||||||
|
readConfirmed = true;
|
||||||
|
readData = {
|
||||||
|
opponentName: gameState.players.find(p => p.id === readResult.opponentId)?.name || 'Opponent',
|
||||||
|
inferredType: readResult.type,
|
||||||
|
lesson: readResult.lesson
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startNextHand() {
|
function startNextHand() {
|
||||||
message = '';
|
message = '';
|
||||||
aiActing = false;
|
aiActing = false;
|
||||||
|
timerActive = false;
|
||||||
|
showPostHand = false;
|
||||||
|
postHandAnalysis = undefined;
|
||||||
|
readConfirmed = false;
|
||||||
|
|
||||||
gameState = startNewHand(gameState);
|
gameState = startNewHand(gameState);
|
||||||
|
|
||||||
const initialTurn = gameState.currentTurn;
|
const initialTurn = gameState.currentTurn;
|
||||||
@ -167,6 +283,21 @@
|
|||||||
setTimeout(() => aiAct(), 100);
|
setTimeout(() => aiAct(), 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismissPostHand() {
|
||||||
|
showPostHand = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReadConfirmed() {
|
||||||
|
readConfirmed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHumanTimerDuration(): number {
|
||||||
|
const mode = gameState.tableConfig.humanTimerMode;
|
||||||
|
if (mode === 'same') return gameState.tableConfig.timerDuration;
|
||||||
|
if (mode === 'custom') return gameState.tableConfig.humanTimerDuration;
|
||||||
|
return 999; // noLimit
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="game-container">
|
<main class="game-container">
|
||||||
@ -175,8 +306,27 @@
|
|||||||
<div class="round-indicator">{gameState.bettingRound}</div>
|
<div class="round-indicator">{gameState.bettingRound}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if gameState.tableConfig.timerEnabled && !aiActing && gameState.currentTurn === 0 && gameState.bettingRound !== 'idle' && gameState.bettingRound !== 'showdown'}
|
||||||
|
<DecisionTimer
|
||||||
|
duration={getHumanTimerDuration()}
|
||||||
|
active={true}
|
||||||
|
playerName="You"
|
||||||
|
onTimeout={() => handleAction('fold')}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<PokerTable {gameState} />
|
<PokerTable {gameState} />
|
||||||
|
|
||||||
|
{#if aiActing && timerActive}
|
||||||
|
<DecisionTimer
|
||||||
|
duration={gameState.tableConfig.timerDuration}
|
||||||
|
active={timerActive}
|
||||||
|
playerName={currentTimerPlayer}
|
||||||
|
archetype={currentTimerArchetype || undefined}
|
||||||
|
onTimeout={handleTimerTimeout}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<BetControls {gameState} onAction={handleAction} disabled={aiActing} />
|
<BetControls {gameState} onAction={handleAction} disabled={aiActing} />
|
||||||
|
|
||||||
{#if message}
|
{#if message}
|
||||||
@ -186,6 +336,20 @@
|
|||||||
{#if gameState.bettingRound === 'showdown'}
|
{#if gameState.bettingRound === 'showdown'}
|
||||||
<button class="btn-new-hand" onclick={startNextHand}>New Hand</button>
|
<button class="btn-new-hand" onclick={startNextHand}>New Hand</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<PostHandAnalysis
|
||||||
|
analysis={postHandAnalysis}
|
||||||
|
visible={showPostHand}
|
||||||
|
onDismiss={dismissPostHand}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReadConfirmed
|
||||||
|
opponentName={readData.opponentName}
|
||||||
|
inferredType={readData.inferredType}
|
||||||
|
lesson={readData.lesson}
|
||||||
|
visible={readConfirmed}
|
||||||
|
onDismiss={dismissReadConfirmed}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -241,4 +405,4 @@
|
|||||||
background: #27ae60;
|
background: #27ae60;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
125
src/routes/setup/+page.svelte
Normal file
125
src/routes/setup/+page.svelte
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TableSettings from '$lib/components/TableSettings.svelte';
|
||||||
|
import { PRESETS } from '$lib/game/table-presets';
|
||||||
|
import type { TablePreset } from '$lib/game/table-presets';
|
||||||
|
|
||||||
|
let preset = $state<TablePreset>('trainingMix');
|
||||||
|
let infoLevel = $state(PRESETS.trainingMix.infoLevel);
|
||||||
|
let feedbackLevel = $state(PRESETS.trainingMix.feedbackLevel);
|
||||||
|
let timerEnabled = $state(PRESETS.trainingMix.timerEnabled);
|
||||||
|
let timerDuration = $state(PRESETS.trainingMix.timerDuration);
|
||||||
|
let humanTimerMode = $state(PRESETS.trainingMix.humanTimerMode);
|
||||||
|
let humanTimerDuration = $state(PRESETS.trainingMix.humanTimerDuration);
|
||||||
|
let smallBlind = $state(PRESETS.trainingMix.smallBlind);
|
||||||
|
let bigBlind = $state(PRESETS.trainingMix.bigBlind);
|
||||||
|
let startingStack = $state(PRESETS.trainingMix.startingStack);
|
||||||
|
let numPlayers = $state(PRESETS.trainingMix.numPlayers);
|
||||||
|
|
||||||
|
function handleSettingUpdate(data: { key: string; value: unknown }) {
|
||||||
|
const key = data.key as string;
|
||||||
|
switch (key) {
|
||||||
|
case 'preset':
|
||||||
|
preset = data.value as TablePreset;
|
||||||
|
const p = PRESETS[preset];
|
||||||
|
infoLevel = p.infoLevel;
|
||||||
|
feedbackLevel = p.feedbackLevel;
|
||||||
|
timerEnabled = p.timerEnabled;
|
||||||
|
timerDuration = p.timerDuration;
|
||||||
|
humanTimerMode = p.humanTimerMode;
|
||||||
|
humanTimerDuration = p.humanTimerDuration;
|
||||||
|
smallBlind = p.smallBlind;
|
||||||
|
bigBlind = p.bigBlind;
|
||||||
|
startingStack = p.startingStack;
|
||||||
|
numPlayers = p.numPlayers;
|
||||||
|
break;
|
||||||
|
case 'infoLevel': infoLevel = data.value as typeof infoLevel; break;
|
||||||
|
case 'feedbackLevel': feedbackLevel = data.value as typeof feedbackLevel; break;
|
||||||
|
case 'timerEnabled': timerEnabled = data.value as boolean; break;
|
||||||
|
case 'timerDuration': timerDuration = data.value as number; break;
|
||||||
|
case 'humanTimerMode': humanTimerMode = data.value as typeof humanTimerMode; break;
|
||||||
|
case 'humanTimerDuration': humanTimerDuration = data.value as number; break;
|
||||||
|
case 'smallBlind': smallBlind = data.value as number; break;
|
||||||
|
case 'bigBlind': bigBlind = data.value as number; break;
|
||||||
|
case 'startingStack': startingStack = data.value as number; break;
|
||||||
|
case 'numPlayers': numPlayers = data.value as number; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
sessionStorage.setItem('poker-config', JSON.stringify({
|
||||||
|
preset,
|
||||||
|
numPlayers,
|
||||||
|
startingStack,
|
||||||
|
smallBlind,
|
||||||
|
bigBlind,
|
||||||
|
infoLevel,
|
||||||
|
feedbackLevel,
|
||||||
|
timerEnabled,
|
||||||
|
timerDuration,
|
||||||
|
humanTimerMode,
|
||||||
|
humanTimerDuration
|
||||||
|
}));
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="setup-container">
|
||||||
|
<h1>Table Setup</h1>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<TableSettings
|
||||||
|
{infoLevel}
|
||||||
|
{feedbackLevel}
|
||||||
|
{timerEnabled}
|
||||||
|
{timerDuration}
|
||||||
|
{humanTimerMode}
|
||||||
|
{humanTimerDuration}
|
||||||
|
{smallBlind}
|
||||||
|
{bigBlind}
|
||||||
|
{startingStack}
|
||||||
|
{numPlayers}
|
||||||
|
{preset}
|
||||||
|
onUpdate={handleSettingUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-start" onclick={startGame}>Start Game</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.setup-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #ecf0f1;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-start {
|
||||||
|
align-self: center;
|
||||||
|
padding: 14px 48px;
|
||||||
|
background: #2ecc71;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn-start:hover {
|
||||||
|
background: #27ae60;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [sveltekit()],
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
|
||||||
|
environment: 'jsdom'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user