Artvv commited on
Commit
989f50b
·
verified ·
1 Parent(s): 7aa16f0

Upload src/persistentpoker_bench/hand_runner.py with huggingface_hub

Browse files
src/persistentpoker_bench/hand_runner.py ADDED
@@ -0,0 +1,658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from random import Random
6
+ from typing import Any, Protocol
7
+
8
+ from persistentpoker_bench.betting import apply_action, get_legal_actions, is_betting_round_complete
9
+ from persistentpoker_bench.cards import Card, standard_deck
10
+ from persistentpoker_bench.game_state import Action, ActionType, HandState, Street, create_hand_state
11
+ from persistentpoker_bench.memory_check import MemoryCheckResult, evaluate_memory
12
+ from persistentpoker_bench.pool import PersistentPool
13
+ from persistentpoker_bench.prompting import build_decision_prompt
14
+ from persistentpoker_bench.schemas import LLMDecision, WinnerPoolDecision
15
+ from persistentpoker_bench.serialization import serialize_hand_state, serialize_legal_actions
16
+ from persistentpoker_bench.showdown import ShowdownResult, resolve_showdown
17
+ from persistentpoker_bench.spec import DEFAULT_DETERMINISTIC_SEED
18
+ from persistentpoker_bench.tiebreak import D6TieBreaker, serialize_tiebreak_result
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class HandRunnerConfig:
23
+ seed: int = DEFAULT_DETERMINISTIC_SEED
24
+ hand_id_prefix: str = "hand"
25
+ starting_stack: int = 2000
26
+ small_blind: int = 10
27
+ big_blind: int = 20
28
+ game_mode: str = "holdem"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class DecisionEnvelope:
33
+ decision: LLMDecision
34
+ raw_text: str
35
+ parse_mode: str
36
+ attempts: int
37
+ provider: str | None = None
38
+ model_id: str | None = None
39
+ latency_seconds: float | None = None
40
+ usage: dict[str, Any] | None = None
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class HandRunResult:
45
+ hand_id: str
46
+ seed: int
47
+ hand_state: HandState
48
+ starting_stacks_snapshot: tuple[int, ...]
49
+ ending_stacks_snapshot: tuple[int, ...]
50
+ persistent_pool_before: tuple[str, ...]
51
+ persistent_pool_after: tuple[str, ...]
52
+ showdown_result: ShowdownResult | None
53
+ winner_pool_decision: str
54
+ transcript: tuple[dict[str, Any], ...]
55
+ tiebreak_events: tuple[dict[str, Any], ...] = ()
56
+
57
+
58
+ class DecisionAgent(Protocol):
59
+ def decide(
60
+ self,
61
+ *,
62
+ prompt_bundle: Any,
63
+ game_snapshot: dict[str, Any],
64
+ legal_actions_snapshot: dict[str, Any],
65
+ player_index: int,
66
+ hand_state: HandState,
67
+ persistent_pool: PersistentPool,
68
+ ) -> DecisionEnvelope:
69
+ ...
70
+
71
+
72
+ class HandObserver(Protocol):
73
+ def on_hand_started(
74
+ self,
75
+ *,
76
+ hand_id: str,
77
+ seed: int,
78
+ hand_state: HandState,
79
+ persistent_pool: PersistentPool,
80
+ ) -> None:
81
+ ...
82
+
83
+ def on_action(
84
+ self,
85
+ *,
86
+ hand_state: HandState,
87
+ persistent_pool: PersistentPool,
88
+ event: dict[str, Any],
89
+ ) -> None:
90
+ ...
91
+
92
+ def on_street_advanced(
93
+ self,
94
+ *,
95
+ hand_state: HandState,
96
+ persistent_pool: PersistentPool,
97
+ ) -> None:
98
+ ...
99
+
100
+ def on_hand_completed(
101
+ self,
102
+ *,
103
+ result: HandRunResult,
104
+ ) -> None:
105
+ ...
106
+
107
+
108
+ def run_seeded_hand(
109
+ *,
110
+ player_names: list[str] | tuple[str, ...],
111
+ decision_agents: dict[int, DecisionAgent],
112
+ persistent_pool: PersistentPool,
113
+ config: HandRunnerConfig | None = None,
114
+ starting_stacks: list[int] | tuple[int, ...] | None = None,
115
+ button_index: int = 0,
116
+ hand_number: int = 1,
117
+ observer: HandObserver | None = None,
118
+ ) -> HandRunResult:
119
+ runner_config = config or HandRunnerConfig()
120
+ hand_id = f"{runner_config.hand_id_prefix}-{hand_number:06d}"
121
+ effective_starting_stacks = tuple(
122
+ runner_config.starting_stack for _ in player_names
123
+ ) if starting_stacks is None else tuple(int(stack) for stack in starting_stacks)
124
+ if runner_config.game_mode == "horse_v2":
125
+ from persistentpoker_bench.horse.rotation import HorseRotationManager
126
+ from persistentpoker_bench.horse.horse_runner import setup_horse_hand, advance_horse_street
127
+ variant_manager = HorseRotationManager()
128
+ variant = variant_manager.get_current_variant(hand_number).value
129
+ else:
130
+ variant = "holdem"
131
+
132
+ hand_state = create_hand_state(
133
+ player_names,
134
+ button_index=button_index,
135
+ starting_stack=runner_config.starting_stack,
136
+ starting_stacks=effective_starting_stacks,
137
+ small_blind=runner_config.small_blind,
138
+ big_blind=runner_config.big_blind,
139
+ game_mode=runner_config.game_mode,
140
+ variant=variant,
141
+ )
142
+ persistent_pool_before = persistent_pool.notation_snapshot()
143
+ participating_indices = tuple(index for index, player in enumerate(hand_state.players) if not player.eliminated)
144
+ if len(participating_indices) < 2:
145
+ raise ValueError("A hand requires at least two non-eliminated players.")
146
+
147
+ if runner_config.game_mode == "horse_v2":
148
+ # Override deal logic for HORSE
149
+ from persistentpoker_bench.cards import standard_deck
150
+ deck = list(standard_deck())
151
+ Random(runner_config.seed + hand_number).shuffle(deck)
152
+ for player in hand_state.players:
153
+ if player.eliminated: continue
154
+ if variant == "holdem":
155
+ player.hole_cards = tuple(deck[:2]); del deck[:2]
156
+ elif variant == "omaha_8b":
157
+ player.hole_cards = tuple(deck[:4]); del deck[:4]
158
+ else:
159
+ player.hole_cards = tuple(deck[:2]); del deck[:2]
160
+ player.up_cards = tuple(deck[:1]); del deck[:1]
161
+ hand_state.deck = deck # Save deck for advancing streets
162
+ # Initialisation du Stud
163
+ if variant not in ("holdem", "omaha_8b"):
164
+ from persistentpoker_bench.horse.horse_runner import determine_bring_in
165
+ bring_in_idx = determine_bring_in(hand_state)
166
+ hand_state.start_stud_round(bring_in_idx)
167
+
168
+ # In V2, we don't have a fixed pre-dealt board. We draw it on demand.
169
+ full_board = ()
170
+ else:
171
+ hole_cards, board = _deal_seeded_cards(
172
+ seed=runner_config.seed + hand_number,
173
+ player_count=len(participating_indices),
174
+ )
175
+ hand_state.assign_hole_cards(
176
+ {
177
+ seat_index: cards
178
+ for seat_index, cards in zip(participating_indices, hole_cards, strict=True)
179
+ }
180
+ )
181
+ hand_state.deck = list(board)
182
+ full_board = tuple(board)
183
+
184
+ transcript: list[dict[str, Any]] = []
185
+ tie_breaker = D6TieBreaker(seed=runner_config.seed + hand_number, namespace=hand_id)
186
+ _notify_hand_started(
187
+ observer,
188
+ hand_id=hand_id,
189
+ seed=runner_config.seed + hand_number,
190
+ hand_state=hand_state,
191
+ persistent_pool=persistent_pool,
192
+ )
193
+
194
+ while hand_state.street is not Street.SHOWDOWN:
195
+ if len(hand_state.live_player_indices) <= 1:
196
+ hand_state.mark_showdown_if_terminal()
197
+ break
198
+
199
+ if hand_state.pending_actor_indices:
200
+ player_index = hand_state.actor_index
201
+ legal_actions = get_legal_actions(hand_state, player_index)
202
+ legal_actions_snapshot = serialize_legal_actions(legal_actions)
203
+ game_snapshot = serialize_hand_state(
204
+ hand_state,
205
+ persistent_pool,
206
+ hand_id=hand_id,
207
+ acting_player_index=player_index,
208
+ )
209
+ prompt_bundle = build_decision_prompt(
210
+ game_snapshot=game_snapshot,
211
+ legal_actions=legal_actions_snapshot,
212
+ seat_metadata={
213
+ "player_name": hand_state.players[player_index].name,
214
+ "seat": player_index,
215
+ },
216
+ game_variant=hand_state.variant if hand_state.game_mode == "horse_v2" else None,
217
+ )
218
+ print(f"[debug] Requesting action from {hand_state.players[player_index].name} ({envelope.model_id if 'envelope' in locals() else 'starting...'})", flush=True)
219
+ envelope, memory_result, action = _obtain_action(
220
+ decision_agents=decision_agents,
221
+ player_index=player_index,
222
+ prompt_bundle=prompt_bundle,
223
+ game_snapshot=game_snapshot,
224
+ legal_actions_snapshot=legal_actions_snapshot,
225
+ hand_state=hand_state,
226
+ persistent_pool=persistent_pool,
227
+ )
228
+ print(f"[debug] {hand_state.players[player_index].name} decided {action.action_type.value} in {envelope.latency_seconds:.2f}s", flush=True)
229
+ apply_action(hand_state, player_index, action)
230
+ transcript.append(
231
+ {
232
+ "hand_id": hand_id,
233
+ "street": hand_state.street.value,
234
+ "player_index": player_index,
235
+ "player_name": hand_state.players[player_index].name,
236
+ "provider": envelope.provider,
237
+ "model_id": envelope.model_id,
238
+ "raw_text": envelope.raw_text,
239
+ "parse_mode": envelope.parse_mode,
240
+ "attempts": envelope.attempts,
241
+ "latency_seconds": envelope.latency_seconds,
242
+ "usage": envelope.usage,
243
+ "believed_pool": envelope.decision.believed_pool,
244
+ "winner_pool_decision": envelope.decision.winner_pool_decision.value,
245
+ "normalized_decision": {
246
+ "action": envelope.decision.action,
247
+ "amount": envelope.decision.amount,
248
+ },
249
+ "executed_action": {
250
+ "action": action.action_type.value,
251
+ "amount": action.amount,
252
+ },
253
+ "memory": _serialize_memory_result(memory_result),
254
+ "legal_actions": legal_actions_snapshot,
255
+ }
256
+ )
257
+ _notify_action(
258
+ observer,
259
+ hand_state=hand_state,
260
+ persistent_pool=persistent_pool,
261
+ event=transcript[-1],
262
+ )
263
+ continue
264
+
265
+ if is_betting_round_complete(hand_state):
266
+ old_street = hand_state.street.value
267
+ if hand_state.game_mode == "horse_v2":
268
+ from persistentpoker_bench.horse.horse_runner import advance_horse_street
269
+ advance_horse_street(hand_state, hand_state.deck)
270
+ else:
271
+ _advance_to_next_street(hand_state, full_board)
272
+ print(f"[debug] Street advanced: {old_street} -> {hand_state.street.value}", flush=True)
273
+ _notify_street_advanced(observer, hand_state=hand_state, persistent_pool=persistent_pool)
274
+ else:
275
+ break
276
+
277
+ if hand_state.game_mode == "horse_v2":
278
+ # In V2, run out board is manual if all-in
279
+ while hand_state.street is not Street.SHOWDOWN:
280
+ from persistentpoker_bench.horse.horse_runner import advance_horse_street
281
+ advance_horse_street(hand_state, hand_state.deck)
282
+ # Important: Clear pending actors when running out the board to avoid infinite prompts
283
+ hand_state.pending_actor_indices = ()
284
+ else:
285
+ _run_out_board_if_needed(hand_state, full_board)
286
+
287
+ showdown_result = _resolve_terminal_hand(hand_state, persistent_pool, tie_breaker=tie_breaker)
288
+ winner_pool_decision, pool_tiebreak_events = _resolve_winner_pool_decision(
289
+ showdown_result,
290
+ transcript,
291
+ tie_breaker=tie_breaker,
292
+ )
293
+ _award_payouts(hand_state, showdown_result)
294
+
295
+ if hand_state.game_mode == "horse_v2":
296
+ from persistentpoker_bench.horse.horse_runner import update_persistent_pool_from_horse
297
+ update_persistent_pool_from_horse(hand_state, persistent_pool)
298
+ else:
299
+ persistent_pool.append_community_cards(full_board[:5])
300
+
301
+ persistent_pool.resolve_for_next_hand(winner_pool_decision)
302
+ tiebreak_events = tuple((showdown_result.tiebreak_events if showdown_result is not None else ())) + tuple(
303
+ pool_tiebreak_events
304
+ )
305
+
306
+ result = HandRunResult(
307
+ hand_id=hand_id,
308
+ seed=runner_config.seed + hand_number,
309
+ hand_state=hand_state,
310
+ starting_stacks_snapshot=effective_starting_stacks,
311
+ ending_stacks_snapshot=tuple(player.stack for player in hand_state.players),
312
+ persistent_pool_before=persistent_pool_before,
313
+ persistent_pool_after=persistent_pool.notation_snapshot(),
314
+ showdown_result=showdown_result,
315
+ winner_pool_decision=winner_pool_decision,
316
+ transcript=tuple(transcript),
317
+ tiebreak_events=tiebreak_events,
318
+ )
319
+ _notify_hand_completed(observer, result=result)
320
+ return result
321
+
322
+
323
+ def _deal_seeded_cards(*, seed: int, player_count: int) -> tuple[tuple[tuple[Card, Card], ...], tuple[Card, ...]]:
324
+ deck = list(standard_deck())
325
+ Random(seed).shuffle(deck)
326
+ hole_cards = tuple(
327
+ (deck[player_index * 2], deck[player_index * 2 + 1]) for player_index in range(player_count)
328
+ )
329
+ board_start = player_count * 2
330
+ board = tuple(deck[board_start : board_start + 5])
331
+ return hole_cards, board
332
+
333
+
334
+ def _obtain_action(
335
+ *,
336
+ decision_agents: dict[int, DecisionAgent],
337
+ player_index: int,
338
+ prompt_bundle: Any,
339
+ game_snapshot: dict[str, Any],
340
+ legal_actions_snapshot: dict[str, Any],
341
+ hand_state: HandState,
342
+ persistent_pool: PersistentPool,
343
+ ) -> tuple[DecisionEnvelope, MemoryCheckResult, Action]:
344
+ agent = decision_agents[player_index]
345
+ try:
346
+ envelope = agent.decide(
347
+ prompt_bundle=prompt_bundle,
348
+ game_snapshot=game_snapshot,
349
+ legal_actions_snapshot=legal_actions_snapshot,
350
+ player_index=player_index,
351
+ hand_state=hand_state,
352
+ persistent_pool=persistent_pool,
353
+ )
354
+ except Exception as exc:
355
+ envelope = _build_agent_error_envelope(agent=agent, persistent_pool=persistent_pool, exc=exc)
356
+ memory_result = evaluate_memory(envelope.decision.believed_pool, persistent_pool.snapshot())
357
+ action = _validate_or_fallback_action(envelope.decision, hand_state, player_index)
358
+ return envelope, memory_result, action
359
+
360
+
361
+ def _build_agent_error_envelope(
362
+ *,
363
+ agent: Any,
364
+ persistent_pool: PersistentPool,
365
+ exc: Exception,
366
+ ) -> DecisionEnvelope:
367
+ provider = getattr(agent, "provider", None)
368
+ model_id = None
369
+ config = getattr(agent, "config", None)
370
+ if config is not None:
371
+ model_id = getattr(config, "model", None)
372
+
373
+ return DecisionEnvelope(
374
+ decision=LLMDecision(
375
+ action=ActionType.CHECK.value,
376
+ amount=None,
377
+ believed_pool=persistent_pool.notation_snapshot(),
378
+ winner_pool_decision=WinnerPoolDecision.CONTINUE,
379
+ reasoning=None,
380
+ ),
381
+ raw_text=f"{type(exc).__name__}: {exc}",
382
+ parse_mode="",
383
+ attempts=0,
384
+ provider=provider,
385
+ model_id=model_id,
386
+ latency_seconds=None,
387
+ usage=None,
388
+ )
389
+
390
+
391
+ def _validate_or_fallback_action(
392
+ decision: LLMDecision,
393
+ hand_state: HandState,
394
+ player_index: int,
395
+ ) -> Action:
396
+ legal_actions = get_legal_actions(hand_state, player_index)
397
+ try:
398
+ action_type = ActionType(decision.action)
399
+ action = Action(action_type=action_type, amount=decision.amount)
400
+ _assert_action_legal(action, legal_actions)
401
+ return action
402
+ except Exception:
403
+ return _fallback_action(legal_actions)
404
+
405
+
406
+ def _assert_action_legal(action: Action, legal_actions: Any) -> None:
407
+ if action.action_type is ActionType.FOLD and not legal_actions.can_fold:
408
+ raise ValueError("Illegal fold.")
409
+ if action.action_type is ActionType.CHECK and not legal_actions.can_check:
410
+ raise ValueError("Illegal check.")
411
+ if action.action_type is ActionType.CALL and not legal_actions.can_call:
412
+ raise ValueError("Illegal call.")
413
+ if action.action_type is ActionType.ALL_IN and not legal_actions.can_all_in:
414
+ raise ValueError("Illegal all-in.")
415
+ if action.action_type is ActionType.BET:
416
+ if not legal_actions.can_bet or action.amount is None:
417
+ raise ValueError("Illegal bet.")
418
+ if legal_actions.min_bet_to is None or not legal_actions.min_bet_to <= action.amount <= legal_actions.max_to:
419
+ raise ValueError("Illegal bet target.")
420
+ if action.action_type is ActionType.RAISE:
421
+ if not legal_actions.can_raise or action.amount is None:
422
+ raise ValueError("Illegal raise.")
423
+ if legal_actions.min_raise_to is None or not legal_actions.min_raise_to <= action.amount <= legal_actions.max_to:
424
+ raise ValueError("Illegal raise target.")
425
+
426
+
427
+ def _fallback_action(legal_actions: Any) -> Action:
428
+ if legal_actions.can_check:
429
+ return Action(ActionType.CHECK)
430
+ if legal_actions.can_call:
431
+ return Action(ActionType.CALL)
432
+ return Action(ActionType.FOLD)
433
+
434
+
435
+ def _advance_to_next_street(hand_state: HandState, full_board: tuple[Card, ...]) -> None:
436
+ if hand_state.street is Street.PREFLOP:
437
+ hand_state.advance_street(full_board[:3])
438
+ elif hand_state.street is Street.FLOP:
439
+ hand_state.advance_street(full_board[:4])
440
+ elif hand_state.street is Street.TURN:
441
+ hand_state.advance_street(full_board[:5])
442
+ elif hand_state.street is Street.RIVER:
443
+ hand_state.advance_street(full_board[:5])
444
+
445
+
446
+ def _run_out_board_if_needed(hand_state: HandState, full_board: tuple[Card, ...]) -> None:
447
+ if len(hand_state.live_player_indices) <= 1:
448
+ return
449
+ if len(hand_state.community_cards) < 5:
450
+ hand_state.set_community_cards(full_board[:5])
451
+ hand_state.street = Street.SHOWDOWN
452
+ hand_state.pending_actor_indices = ()
453
+
454
+
455
+ def _resolve_terminal_hand(
456
+ hand_state: HandState,
457
+ persistent_pool: PersistentPool,
458
+ *,
459
+ tie_breaker: D6TieBreaker,
460
+ ) -> ShowdownResult | None:
461
+ if hand_state.game_mode == "horse_v2":
462
+ from persistentpoker_bench.horse.evaluators import HorseEvaluator
463
+ from persistentpoker_bench.showdown import ShowdownResult
464
+
465
+ live_indices = [i for i, p in enumerate(hand_state.players) if not p.folded and not p.eliminated]
466
+ if not live_indices: return None
467
+
468
+ variant = hand_state.variant
469
+ best_player_idx = live_indices[0]
470
+
471
+ if variant == "razz":
472
+ best_score = None
473
+ for i in live_indices:
474
+ score = HorseEvaluator.evaluate_razz(hand_state.players[i].hole_cards, hand_state.players[i].up_cards)
475
+ if best_score is None or score < best_score:
476
+ best_score = score
477
+ best_player_idx = i
478
+ elif variant == "omaha_8b":
479
+ best_rank = None
480
+ for i in live_indices:
481
+ eval_h = HorseEvaluator.evaluate_omaha(hand_state.players[i].hole_cards, hand_state.community_cards, persistent_pool.snapshot())
482
+ if best_rank is None or eval_h.sort_key > best_rank:
483
+ best_rank = eval_h.sort_key
484
+ best_player_idx = i
485
+ else:
486
+ best_rank = None
487
+ for i in live_indices:
488
+ all_cards = hand_state.players[i].hole_cards + hand_state.players[i].up_cards + hand_state.community_cards + persistent_pool.snapshot()
489
+ from persistentpoker_bench.hand_evaluator import evaluate_hand
490
+ eval_h = evaluate_hand(all_cards)
491
+ if best_rank is None or eval_h.sort_key > best_rank:
492
+ best_rank = eval_h.sort_key
493
+ best_player_idx = i
494
+
495
+ return ShowdownResult(
496
+ winning_player_indices=(best_player_idx,),
497
+ payouts=tuple(hand_state.pot_total if i == best_player_idx else 0 for i in range(len(hand_state.players))),
498
+ evaluated_hands={},
499
+ pot_allocations=(),
500
+ tiebreak_events=(),
501
+ )
502
+
503
+ from persistentpoker_bench.showdown import resolve_showdown
504
+ return resolve_showdown(hand_state, persistent_pool, tiebreaker=tie_breaker)
505
+ if len(live_players) == 1:
506
+ winner = live_players[0]
507
+ payouts = [0 for _ in hand_state.players]
508
+ payouts[winner] = hand_state.pot_total
509
+ return ShowdownResult(
510
+ payouts=tuple(payouts),
511
+ winning_player_indices=(winner,),
512
+ evaluated_hands={},
513
+ pot_allocations=(),
514
+ tiebreak_events=(),
515
+ )
516
+ if len(live_players) > 1:
517
+ return resolve_showdown(hand_state, persistent_pool, tiebreaker=tie_breaker)
518
+ return None
519
+
520
+
521
+ def _award_payouts(hand_state: HandState, showdown_result: ShowdownResult | None) -> None:
522
+ if showdown_result is None:
523
+ return
524
+ for player_index, amount in enumerate(showdown_result.payouts):
525
+ if amount > 0:
526
+ hand_state.players[player_index].stack += amount
527
+ for player in hand_state.players:
528
+ player.eliminated = player.stack <= 0
529
+
530
+
531
+ def _resolve_winner_pool_decision(
532
+ showdown_result: ShowdownResult | None,
533
+ transcript: list[dict[str, Any]],
534
+ *,
535
+ tie_breaker: D6TieBreaker,
536
+ ) -> tuple[str, tuple[dict[str, Any], ...]]:
537
+ if showdown_result is None or not showdown_result.winning_player_indices:
538
+ return WinnerPoolDecision.CONTINUE.value, ()
539
+
540
+ winner_indices = set(showdown_result.winning_player_indices)
541
+ decision_by_player = {
542
+ int(event["player_index"]): str(event["winner_pool_decision"])
543
+ for event in transcript
544
+ if event["player_index"] in winner_indices
545
+ }
546
+ if not decision_by_player:
547
+ return WinnerPoolDecision.CONTINUE.value, ()
548
+ distinct_decisions = set(decision_by_player.values())
549
+ if len(distinct_decisions) == 1:
550
+ return distinct_decisions.pop(), ()
551
+
552
+ tiebreak_result = tie_breaker.choose_one(
553
+ context="winner-pool-decision",
554
+ contenders=tuple(sorted(decision_by_player)),
555
+ )
556
+ selected_decision = decision_by_player.get(tiebreak_result.winner, WinnerPoolDecision.CONTINUE.value)
557
+ return selected_decision, (serialize_tiebreak_result(tiebreak_result),)
558
+
559
+
560
+ def _serialize_memory_result(result: MemoryCheckResult) -> dict[str, Any]:
561
+ return {
562
+ "exact_match": result.exact_match,
563
+ "matched_instances": result.matched_instances,
564
+ "actual_count": result.actual_count,
565
+ "believed_count": result.believed_count,
566
+ "precision": result.precision,
567
+ "recall": result.recall,
568
+ "multiset_accuracy": result.multiset_accuracy,
569
+ "missing_cards": result.missing_cards,
570
+ "extra_cards": result.extra_cards,
571
+ }
572
+
573
+
574
+ class StaticDecisionAgent:
575
+ def __init__(self, decisions: list[LLMDecision]) -> None:
576
+ self._decisions = list(decisions)
577
+
578
+ def decide(
579
+ self,
580
+ *,
581
+ prompt_bundle: Any,
582
+ game_snapshot: dict[str, Any],
583
+ legal_actions_snapshot: dict[str, Any],
584
+ player_index: int,
585
+ hand_state: HandState,
586
+ persistent_pool: PersistentPool,
587
+ ) -> DecisionEnvelope:
588
+ if not self._decisions:
589
+ raise ValueError("No scripted decisions remain for StaticDecisionAgent.")
590
+ decision = self._decisions.pop(0)
591
+ return DecisionEnvelope(
592
+ decision=decision,
593
+ raw_text=json.dumps(
594
+ {
595
+ "action": decision.action,
596
+ "amount": decision.amount,
597
+ "believed_pool": list(decision.believed_pool),
598
+ "winner_pool_decision": decision.winner_pool_decision.value,
599
+ }
600
+ ),
601
+ parse_mode="static",
602
+ attempts=1,
603
+ provider="static",
604
+ model_id="static-scripted-agent",
605
+ latency_seconds=0.0,
606
+ usage={
607
+ "prompt_tokens": 0,
608
+ "completion_tokens": 0,
609
+ "total_tokens": 0,
610
+ "cached_tokens": 0,
611
+ "cache_creation_input_tokens": 0,
612
+ "cache_read_input_tokens": 0,
613
+ "estimated_cost": 0.0,
614
+ },
615
+ )
616
+
617
+
618
+ def _notify_hand_started(
619
+ observer: HandObserver | None,
620
+ *,
621
+ hand_id: str,
622
+ seed: int,
623
+ hand_state: HandState,
624
+ persistent_pool: PersistentPool,
625
+ ) -> None:
626
+ if observer is not None and hasattr(observer, "on_hand_started"):
627
+ observer.on_hand_started(
628
+ hand_id=hand_id,
629
+ seed=seed,
630
+ hand_state=hand_state,
631
+ persistent_pool=persistent_pool,
632
+ )
633
+
634
+
635
+ def _notify_action(
636
+ observer: HandObserver | None,
637
+ *,
638
+ hand_state: HandState,
639
+ persistent_pool: PersistentPool,
640
+ event: dict[str, Any],
641
+ ) -> None:
642
+ if observer is not None and hasattr(observer, "on_action"):
643
+ observer.on_action(hand_state=hand_state, persistent_pool=persistent_pool, event=event)
644
+
645
+
646
+ def _notify_street_advanced(
647
+ observer: HandObserver | None,
648
+ *,
649
+ hand_state: HandState,
650
+ persistent_pool: PersistentPool,
651
+ ) -> None:
652
+ if observer is not None and hasattr(observer, "on_street_advanced"):
653
+ observer.on_street_advanced(hand_state=hand_state, persistent_pool=persistent_pool)
654
+
655
+
656
+ def _notify_hand_completed(observer: HandObserver | None, *, result: HandRunResult) -> None:
657
+ if observer is not None and hasattr(observer, "on_hand_completed"):
658
+ observer.on_hand_completed(result=result)