smpdl commited on
Commit
60daaa6
·
0 Parent(s):

Deploy ZeroGPU Space snapshot

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +237 -0
  2. LICENSE +21 -0
  3. README.md +123 -0
  4. app.py +222 -0
  5. configs/model_candidates.comparison.json +28 -0
  6. pyproject.toml +28 -0
  7. requirements.txt +11 -0
  8. scripts/evaluate_models.py +5 -0
  9. scripts/modal/evaluate_models.py +212 -0
  10. telltale/__init__.py +1 -0
  11. telltale/agents/__init__.py +26 -0
  12. telltale/agents/decision.py +218 -0
  13. telltale/agents/dialogue.py +20 -0
  14. telltale/agents/memory.py +135 -0
  15. telltale/agents/profiles.py +334 -0
  16. telltale/agents/prompts.py +117 -0
  17. telltale/agents/trace.py +45 -0
  18. telltale/game/__init__.py +23 -0
  19. telltale/game/cards.py +100 -0
  20. telltale/game/economy.py +329 -0
  21. telltale/game/engine.py +54 -0
  22. telltale/game/floors.py +112 -0
  23. telltale/game/holdem.py +626 -0
  24. telltale/game/perks.py +178 -0
  25. telltale/models/__init__.py +9 -0
  26. telltale/models/eval_cli.py +223 -0
  27. telltale/models/eval_prompts.py +238 -0
  28. telltale/models/eval_runner.py +456 -0
  29. telltale/models/llama_runtime.py +427 -0
  30. telltale/models/stt.py +25 -0
  31. telltale/models/tts.py +49 -0
  32. telltale/native/__init__.py +1 -0
  33. telltale/native/poker_solver/CMakeLists.txt +14 -0
  34. telltale/native/poker_solver/README.md +63 -0
  35. telltale/native/poker_solver/__init__.py +3 -0
  36. telltale/native/poker_solver/action_policy.cpp +394 -0
  37. telltale/native/poker_solver/action_policy.hpp +31 -0
  38. telltale/native/poker_solver/bindings.cpp +163 -0
  39. telltale/native/poker_solver/equity.cpp +380 -0
  40. telltale/native/poker_solver/equity.hpp +79 -0
  41. telltale/native/poker_solver/evaluator.cpp +259 -0
  42. telltale/native/poker_solver/evaluator.hpp +64 -0
  43. telltale/native/poker_solver/note.md +205 -0
  44. telltale/poker/__init__.py +3 -0
  45. telltale/poker/native.py +371 -0
  46. telltale/poker/policy.py +207 -0
  47. telltale/server/__init__.py +1 -0
  48. telltale/server/api.py +591 -0
  49. telltale/server/events.py +64 -0
  50. telltale/server/schemas.py +98 -0
.gitignore ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ *.lcov
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ # Pipfile.lock
97
+
98
+ # UV
99
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # uv.lock
103
+
104
+ # poetry
105
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
107
+ # commonly ignored for libraries.
108
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109
+ # poetry.lock
110
+ # poetry.toml
111
+
112
+ # pdm
113
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
114
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
115
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
116
+ # pdm.lock
117
+ # pdm.toml
118
+ .pdm-python
119
+ .pdm-build/
120
+
121
+ # pixi
122
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
123
+ # pixi.lock
124
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
125
+ # in the .venv directory. It is recommended not to include this directory in version control.
126
+ .pixi/*
127
+ !.pixi/config.toml
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule*
134
+ celerybeat.pid
135
+
136
+ # Redis
137
+ *.rdb
138
+ *.aof
139
+ *.pid
140
+
141
+ # RabbitMQ
142
+ mnesia/
143
+ rabbitmq/
144
+ rabbitmq-data/
145
+
146
+ # ActiveMQ
147
+ activemq-data/
148
+
149
+ # SageMath parsed files
150
+ *.sage.py
151
+
152
+ # Environments
153
+ .env
154
+ .envrc
155
+ .venv
156
+ env/
157
+ venv/
158
+ ENV/
159
+ env.bak/
160
+ venv.bak/
161
+
162
+ # Spyder project settings
163
+ .spyderproject
164
+ .spyproject
165
+
166
+ # Rope project settings
167
+ .ropeproject
168
+
169
+ # mkdocs documentation
170
+ /site
171
+
172
+ # mypy
173
+ .mypy_cache/
174
+ .dmypy.json
175
+ dmypy.json
176
+
177
+ # Pyre type checker
178
+ .pyre/
179
+
180
+ # pytype static type analyzer
181
+ .pytype/
182
+
183
+ # Cython debug symbols
184
+ cython_debug/
185
+
186
+ # PyCharm
187
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
188
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
189
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
190
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
191
+ # .idea/
192
+
193
+ # Abstra
194
+ # Abstra is an AI-powered process automation framework.
195
+ # Ignore directories containing user credentials, local state, and settings.
196
+ # Learn more at https://abstra.io/docs
197
+ .abstra/
198
+
199
+ # Visual Studio Code
200
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
201
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
202
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
203
+ # you could uncomment the following to ignore the entire vscode folder
204
+ # .vscode/
205
+ # Temporary file for partial code execution
206
+ tempCodeRunnerFile.py
207
+
208
+ # Ruff stuff:
209
+ .ruff_cache/
210
+
211
+ # PyPI configuration file
212
+ .pypirc
213
+
214
+ # Marimo
215
+ marimo/_static/
216
+ marimo/_lsp/
217
+ __marimo__/
218
+
219
+ # Streamlit
220
+ .streamlit/secrets.toml
221
+
222
+ # Telltale local artifacts
223
+ /models/
224
+ runs/
225
+ .modal/
226
+
227
+ docs/
228
+ node_modules/
229
+ *.tsbuildinfo
230
+ !telltale/web/dist/
231
+ !telltale/web/dist/**
232
+ telltale/web/dist/dream.mp3
233
+
234
+ .venv/
235
+ __pycache__/
236
+ .pytest_cache/
237
+ *.py[cod]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samip Paudel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Telltale
3
+ sdk: gradio
4
+ app_file: app.py
5
+ python_version: "3.10"
6
+ colorFrom: indigo
7
+ colorTo: gray
8
+ short_description: AI poker roguelike with table talk.
9
+ models:
10
+ - nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16
11
+ - nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF
12
+ tags:
13
+ - gradio
14
+ - zerogpu
15
+ - build-small
16
+ - thousand-token-wood
17
+ - off-brand
18
+ - best-agent
19
+ - tiny-titan
20
+ - nvidia-nemotron
21
+ - codex
22
+ ---
23
+
24
+ # Telltale
25
+
26
+ Telltale is a single-player Texas Hold'em roguelike about pressure, memory, and table talk. You climb five casino floors against fixed AI opponents who speak in character, remember the run, and make legal poker decisions from a solver-informed local model prompt.
27
+
28
+ ## Hackathon Fit
29
+
30
+ - Track: Thousand Token Wood.
31
+ - Deadline Space runtime: ZeroGPU with `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`, because the hackathon org currently cannot upgrade paid GPU hardware.
32
+ - Full standard-GPU runtime: actual `llama.cpp` through `llama-server`, not `llama-cpp-python`.
33
+ - ZeroGPU model: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`, under the 32B cap, Tiny Titan-friendly, and NVIDIA sponsor-aligned.
34
+ - Standard-GPU model target: `nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF`, under the 32B cap and eligible for the Nemotron sponsor path.
35
+ - Gradio: `app.py` exposes a `gr.Server` app with a custom React frontend instead of a stock Blocks UI.
36
+ - Target badges/prizes: Off Brand, Best Agent, Tiny Titan, Best Demo, Nemotron Hardware Prize, and Best Use of Codex.
37
+
38
+ ## Local Development
39
+
40
+ ```bash
41
+ uv sync
42
+ uv run pytest
43
+ cd telltale/web
44
+ npm ci
45
+ npm run build
46
+ ```
47
+
48
+ Text-only mock mode is useful for fast local tests:
49
+
50
+ ```bash
51
+ TELLTALE_MODEL_MODE=mock uv run python app.py
52
+ ```
53
+
54
+ ## Actual llama.cpp Runtime
55
+
56
+ Run a local `llama-server` first:
57
+
58
+ ```bash
59
+ llama-server \
60
+ --host 127.0.0.1 \
61
+ --port 8080 \
62
+ --hf-repo nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M \
63
+ --hf-file NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf \
64
+ --alias telltale-agent \
65
+ --ctx-size 4096 \
66
+ --n-gpu-layers 999
67
+ ```
68
+
69
+ Then launch Telltale:
70
+
71
+ ```bash
72
+ TELLTALE_MODEL_MODE=llama_server \
73
+ TELLTALE_LLAMA_SERVER_URL=http://127.0.0.1:8080 \
74
+ uv run python app.py
75
+ ```
76
+
77
+ The older `llama_cpp` mode string is accepted as a compatibility alias, but the runtime is now `llama_server`.
78
+
79
+ ## Hugging Face Space
80
+
81
+ Create the Space under the `build-small-hackathon` organization with:
82
+
83
+ - SDK: Gradio
84
+ - Hardware: ZeroGPU
85
+ - Visibility: Public
86
+
87
+ The frontend build is committed under `telltale/web/dist` because Gradio Spaces do not run the Vite build automatically.
88
+
89
+ On Hugging Face, the default runtime becomes `TELLTALE_MODEL_MODE=zero_gpu` automatically when `SPACE_ID` is present. If the model path has trouble close to submission, add this Space variable to force the emergency text-only demo:
90
+
91
+ ```text
92
+ TELLTALE_MODEL_MODE=mock
93
+ ```
94
+
95
+ When standard GPU hardware is available, switch to:
96
+
97
+ ```text
98
+ TELLTALE_MODEL_MODE=llama_server
99
+ ```
100
+
101
+ and run the actual `llama-server` setup described above.
102
+
103
+ ## Voice
104
+
105
+ Voice is progressive enhancement. Text gameplay remains fully playable.
106
+
107
+ - TTS target: OpenBMB VoxCPM2 for sponsor-aligned creative character voices, with a fallback to a lightweight local TTS if needed.
108
+ - STT target: Cohere Transcribe or Nemotron 3 ASR for sponsor-aligned speech input.
109
+ - Current API surface includes transcription and cached audio endpoints so the concrete model can be swapped without changing gameplay.
110
+
111
+ ## Traces
112
+
113
+ Every model-driven agent decision records prompt, model output, solver recommendation, repairs, memory changes, and runtime metadata. Use the trace export button in the UI or call:
114
+
115
+ ```bash
116
+ curl http://127.0.0.1:7860/api/trace/<run_id>
117
+ ```
118
+
119
+ ## Known Limitations
120
+
121
+ - TTS/STT model implementations are intentionally behind wrappers so the final sponsor model can be selected without touching game state.
122
+ - ZeroGPU mode is the deadline-compatible org deployment path; actual `llama.cpp` mode requires standard GPU hardware.
123
+ - Demo video and social-post links must be added before final submission.
app.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Literal
5
+
6
+ import gradio as gr
7
+ import uvicorn
8
+ from fastapi import HTTPException, Request
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import FileResponse, JSONResponse, Response
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import BaseModel, Field
13
+
14
+ from telltale.server import api
15
+
16
+
17
+ WEB_DIR = Path(__file__).parent / "telltale" / "web"
18
+ DIST_DIR = WEB_DIR / "dist"
19
+ ASSETS_DIR = DIST_DIR / "assets"
20
+ SOURCE_INDEX = WEB_DIR / "index.html"
21
+ ErrorCode = Literal["bad_request", "not_found", "illegal_action", "server_error"]
22
+
23
+
24
+ class ErrorPayload(BaseModel):
25
+ code: ErrorCode
26
+ message: str
27
+
28
+
29
+ class StartRequest(BaseModel):
30
+ seed: int | None = None
31
+ model_mode: Literal["mock", "zero_gpu", "llama_server", "llama_cpp"] | None = None
32
+ tts_enabled: bool = False
33
+ stt_enabled: bool = False
34
+
35
+
36
+ class ActionRequest(BaseModel):
37
+ run_id: str = Field(min_length=1)
38
+ action: Literal["fold", "check", "call", "bet", "raise", "all_in"]
39
+ amount: int = Field(default=0, ge=0)
40
+ utterance: str = ""
41
+
42
+
43
+ class RewardRequest(BaseModel):
44
+ run_id: str = Field(min_length=1)
45
+ perk_id: str = Field(min_length=1)
46
+
47
+
48
+ class ContinueRequest(BaseModel):
49
+ run_id: str = Field(min_length=1)
50
+
51
+
52
+ def create_gradio_app() -> gr.Blocks:
53
+ with gr.Blocks(title="Telltale API", fill_height=True) as demo:
54
+ gr.Markdown(
55
+ """
56
+ # Telltale backend
57
+
58
+ The player-facing game UI is served at `/`. This hidden Gradio mount exists for
59
+ Hugging Face/Gradio compatibility and lightweight backend inspection.
60
+ """
61
+ )
62
+ start = gr.Button("Start llama.cpp backend run", variant="primary")
63
+ state_json = gr.JSON(label="Public state")
64
+
65
+ def on_start() -> dict[str, Any]:
66
+ return api.start_run()
67
+
68
+ start.click(on_start, inputs=[], outputs=state_json)
69
+
70
+ return demo
71
+
72
+
73
+ def create_app() -> gr.Server:
74
+ app = gr.Server(title="Telltale", docs_url="/api/docs", redoc_url=None)
75
+ app.add_exception_handler(HTTPException, _http_exception_handler)
76
+ app.add_exception_handler(RequestValidationError, _validation_exception_handler)
77
+ app.add_exception_handler(Exception, _unhandled_exception_handler)
78
+
79
+ @app.post("/api/start")
80
+ def start_run(request: StartRequest):
81
+ settings: dict[str, Any] = {
82
+ "tts_enabled": request.tts_enabled,
83
+ "stt_enabled": request.stt_enabled,
84
+ }
85
+ if request.model_mode is not None:
86
+ settings["model_mode"] = request.model_mode
87
+ return _api_call(
88
+ lambda: api.start_run(
89
+ seed=request.seed,
90
+ settings=settings,
91
+ )
92
+ )
93
+
94
+ @app.get("/api/floors")
95
+ def list_floors():
96
+ return _api_call(api.list_floors)
97
+
98
+ @app.get("/api/state/{run_id}")
99
+ def get_state(run_id: str):
100
+ return _api_call(lambda: api.get_state(run_id))
101
+
102
+ @app.post("/api/action")
103
+ def submit_action(request: ActionRequest):
104
+ return _api_call(
105
+ lambda: api.submit_player_action(
106
+ request.run_id,
107
+ request.action,
108
+ amount=request.amount,
109
+ utterance=request.utterance,
110
+ )
111
+ )
112
+
113
+ @app.post("/api/reward")
114
+ def choose_reward(request: RewardRequest):
115
+ return _api_call(lambda: api.choose_reward(request.run_id, request.perk_id))
116
+
117
+ @app.post("/api/continue")
118
+ def continue_run(request: ContinueRequest):
119
+ return _api_call(lambda: api.continue_until_player_turn(request.run_id))
120
+
121
+ @app.get("/api/trace/{run_id}")
122
+ def export_trace(run_id: str):
123
+ return _api_call(lambda: api.export_trace(run_id))
124
+
125
+ @app.post("/api/transcribe/{run_id}")
126
+ async def transcribe_audio(run_id: str, request: Request):
127
+ audio = await request.body()
128
+ return _api_call(lambda: api.transcribe_player_audio(run_id, audio))
129
+
130
+ @app.get("/api/audio/{audio_id}")
131
+ def get_audio(audio_id: str):
132
+ result = _api_call(lambda: api.get_tts_audio(audio_id))
133
+ if isinstance(result, JSONResponse):
134
+ return result
135
+ return FileResponse(result["path"], media_type=result["mime_type"])
136
+
137
+ if ASSETS_DIR.exists():
138
+ app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
139
+ public_assets = WEB_DIR / "public" / "assets"
140
+ if public_assets.exists():
141
+ app.mount("/game-assets", StaticFiles(directory=public_assets), name="game-assets")
142
+
143
+ @app.get("/")
144
+ def index() -> Response:
145
+ index_path = DIST_DIR / "index.html"
146
+ if index_path.exists():
147
+ return FileResponse(index_path)
148
+ if SOURCE_INDEX.exists():
149
+ return FileResponse(SOURCE_INDEX)
150
+ return JSONResponse({"message": "Build the React frontend with `cd telltale/web && npm run build`."})
151
+
152
+ gr.mount_gradio_app(app, create_gradio_app(), path="/gradio", footer_links=[])
153
+
154
+ @app.get("/{path:path}")
155
+ def spa_fallback(path: str) -> Response:
156
+ if path.startswith(("api/", "gradio/")):
157
+ raise HTTPException(status_code=404, detail=f"Unknown path: /{path}")
158
+ static_path = DIST_DIR / path
159
+ if static_path.is_file():
160
+ return FileResponse(static_path)
161
+ public_path = WEB_DIR / "public" / path
162
+ if public_path.is_file():
163
+ return FileResponse(public_path)
164
+ index_path = DIST_DIR / "index.html"
165
+ if index_path.exists():
166
+ return FileResponse(index_path)
167
+ raise HTTPException(status_code=404, detail="React frontend has not been built.")
168
+
169
+ return app
170
+
171
+
172
+ def _error(code: ErrorCode, message: str, status_code: int) -> JSONResponse:
173
+ return JSONResponse(
174
+ status_code=status_code,
175
+ content={"error": ErrorPayload(code=code, message=message).model_dump()},
176
+ )
177
+
178
+
179
+ def _api_call(callback: Any) -> dict[str, Any] | JSONResponse:
180
+ try:
181
+ return callback()
182
+ except KeyError as error:
183
+ return _error("not_found", _clean_message(error), 404)
184
+ except ValueError as error:
185
+ message = _clean_message(error)
186
+ code: ErrorCode = "illegal_action" if "not legal" in message else "bad_request"
187
+ return _error(code, message, 400)
188
+ except Exception as error:
189
+ return _error("server_error", _clean_message(error), 500)
190
+
191
+
192
+ async def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
193
+ code: ErrorCode = "not_found" if exc.status_code == 404 else "bad_request"
194
+ return _error(code, str(exc.detail), exc.status_code)
195
+
196
+
197
+ async def _validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
198
+ return _error("bad_request", "Request validation failed.", 422)
199
+
200
+
201
+ async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
202
+ if isinstance(exc, KeyError):
203
+ return _error("not_found", _clean_message(exc), 404)
204
+ if isinstance(exc, ValueError):
205
+ message = _clean_message(exc)
206
+ code: ErrorCode = "illegal_action" if "not legal" in message else "bad_request"
207
+ return _error(code, message, 400)
208
+ return _error("server_error", _clean_message(exc), 500)
209
+
210
+
211
+ def _clean_message(error: Exception) -> str:
212
+ text = str(error)
213
+ if len(text) >= 2 and text[0] == text[-1] == "'":
214
+ return text[1:-1]
215
+ return text or error.__class__.__name__
216
+
217
+
218
+ app = create_app()
219
+
220
+
221
+ if __name__ == "__main__":
222
+ uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
configs/model_candidates.comparison.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "candidates": [
3
+ {
4
+ "label": "nemotron_3_nano_4b_q4_k_m",
5
+ "hf_repo_id": "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF",
6
+ "gguf_filename": "NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf",
7
+ "quantization": "Q4_K_M"
8
+ },
9
+ {
10
+ "label": "qwen3_4b_q4_k_m",
11
+ "hf_repo_id": "Qwen/Qwen3-4B-GGUF",
12
+ "gguf_filename": "Qwen3-4B-Q4_K_M.gguf",
13
+ "quantization": "Q4_K_M"
14
+ },
15
+ {
16
+ "label": "minicpm41_8b_q4_k_m",
17
+ "hf_repo_id": "openbmb/MiniCPM4.1-8B-GGUF",
18
+ "gguf_filename": "MiniCPM4.1-8B-Q4_K_M.gguf",
19
+ "quantization": "Q4_K_M"
20
+ },
21
+ {
22
+ "label": "qwen3_8b_q4_k_m",
23
+ "hf_repo_id": "Qwen/Qwen3-8B-GGUF",
24
+ "gguf_filename": "Qwen3-8B-Q4_K_M.gguf",
25
+ "quantization": "Q4_K_M"
26
+ }
27
+ ]
28
+ }
pyproject.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "telltale"
3
+ version = "0.1.0"
4
+ description = "A fun poker game"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "fastapi",
9
+ "gradio",
10
+ "numpy",
11
+ "pydantic",
12
+ "uvicorn",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "pytest",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=69"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [tool.setuptools.packages.find]
25
+ include = ["telltale*"]
26
+
27
+ [tool.setuptools]
28
+ py-modules = ["app"]
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ gradio
3
+ numpy
4
+ pydantic
5
+ uvicorn
6
+ spaces
7
+ torch
8
+ transformers>=4.48.3
9
+ accelerate
10
+ sentencepiece
11
+ protobuf
scripts/evaluate_models.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from telltale.models.eval_cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
scripts/modal/evaluate_models.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import modal
7
+
8
+
9
+ APP_NAME = "telltale-model-eval"
10
+ PROJECT_ROOT = None
11
+
12
+
13
+ def _find_project_root() -> Path:
14
+ current_file = Path(__file__).resolve()
15
+ for parent in current_file.parents:
16
+ if (parent / "telltale").is_dir():
17
+ return parent
18
+ return Path("/root")
19
+
20
+
21
+ PROJECT_ROOT = _find_project_root()
22
+
23
+ image = (
24
+ modal.Image.from_registry("nvidia/cuda:12.4.1-runtime-ubuntu22.04", add_python="3.11")
25
+ .apt_install("curl")
26
+ .env(
27
+ {
28
+ "HF_XET_HIGH_PERFORMANCE": "1",
29
+ "PYTHONPATH": "/root",
30
+ }
31
+ )
32
+ .pip_install("huggingface_hub[hf_transfer]", "pydantic", "numpy")
33
+ .run_commands(
34
+ "python -m pip install --upgrade pip",
35
+ "python -m pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124",
36
+ )
37
+ .add_local_dir(PROJECT_ROOT / "telltale", remote_path="/root/telltale")
38
+ )
39
+
40
+ app = modal.App(APP_NAME, image=image)
41
+ hf_cache = modal.Volume.from_name("telltale-hf-cache", create_if_missing=True)
42
+
43
+ @app.function(
44
+ gpu="L4",
45
+ cpu=4,
46
+ memory=24_576,
47
+ timeout=20 * 60,
48
+ secrets=[modal.Secret.from_name("huggingface-secret")],
49
+ volumes={"/root/.cache/huggingface": hf_cache},
50
+ )
51
+ def run_model_eval(
52
+ candidate_labels: list[str] | None = None,
53
+ candidate_file_json: str | None = None,
54
+ case_ids: list[str] | None = None,
55
+ max_cases: int | None = 3,
56
+ max_tokens: int = 320,
57
+ context_size: int = 2048,
58
+ temperature: float = 0.30,
59
+ seed: int = 17,
60
+ n_gpu_layers: int = -1,
61
+ speech_max_words: int = 16,
62
+ rationale_max_words: int = 24,
63
+ ) -> str:
64
+ from telltale.models.eval_prompts import (
65
+ COMPARISON_MODEL_CANDIDATES,
66
+ DEFAULT_MODEL_CANDIDATES,
67
+ EVAL_CASES_BY_ID,
68
+ MODEL_CANDIDATES_BY_LABEL,
69
+ ModelCandidate,
70
+ get_eval_case,
71
+ )
72
+ from telltale.models.eval_runner import (
73
+ EvalRunConfig,
74
+ build_runtime_for_candidate,
75
+ evaluate_candidate,
76
+ write_eval_bundle,
77
+ )
78
+
79
+ candidates_by_label = dict(MODEL_CANDIDATES_BY_LABEL)
80
+ if candidate_file_json:
81
+ data = json.loads(candidate_file_json)
82
+ if isinstance(data, dict):
83
+ data = data.get("candidates", [])
84
+ for item in data:
85
+ candidate = ModelCandidate.from_mapping(item)
86
+ candidates_by_label[candidate.label] = candidate
87
+
88
+ if candidate_labels and "all" in candidate_labels:
89
+ selected_candidates = list(COMPARISON_MODEL_CANDIDATES)
90
+ elif candidate_labels:
91
+ selected_candidates = []
92
+ for label in candidate_labels:
93
+ if label not in candidates_by_label:
94
+ known = ", ".join(sorted(candidates_by_label))
95
+ raise ValueError(f"unknown candidate {label!r}; known candidates: {known}")
96
+ selected_candidates.append(candidates_by_label[label])
97
+ else:
98
+ selected_candidates = list(DEFAULT_MODEL_CANDIDATES)
99
+
100
+ if case_ids:
101
+ selected_cases = tuple(get_eval_case(case_id) for case_id in case_ids)
102
+ else:
103
+ selected_cases = tuple(EVAL_CASES_BY_ID.values())
104
+ if max_cases is not None:
105
+ selected_cases = selected_cases[:max_cases]
106
+
107
+ config = EvalRunConfig(
108
+ context_size=context_size,
109
+ max_tokens=max_tokens,
110
+ temperature=temperature,
111
+ seed=seed,
112
+ output_dir="/tmp/telltale_model_evals",
113
+ hardware_profile="modal_l4",
114
+ n_gpu_layers=n_gpu_layers,
115
+ speech_max_words=speech_max_words,
116
+ rationale_max_words=rationale_max_words,
117
+ )
118
+ results_by_candidate = {}
119
+ for candidate in selected_candidates:
120
+ runtime = build_runtime_for_candidate(candidate, config)
121
+ results_by_candidate[candidate.label] = evaluate_candidate(
122
+ candidate,
123
+ runtime,
124
+ cases=selected_cases,
125
+ config=config,
126
+ )
127
+
128
+ bundle = write_eval_bundle(results_by_candidate, output_dir=config.output_dir)
129
+ return json.dumps(
130
+ {
131
+ "candidate_labels": [candidate.label for candidate in selected_candidates],
132
+ "case_ids": [case.case_id for case in selected_cases],
133
+ "bundle": bundle,
134
+ "results": {
135
+ label: [json.loads(result.to_json_line()) for result in results]
136
+ for label, results in results_by_candidate.items()
137
+ },
138
+ },
139
+ ensure_ascii=True,
140
+ indent=2,
141
+ )
142
+
143
+
144
+ @app.local_entrypoint()
145
+ def main(
146
+ candidates: str = "nemotron_3_nano_4b_q4_k_m",
147
+ candidate_file: str = "",
148
+ cases: str = "",
149
+ max_cases: int = 1,
150
+ max_tokens: int = 0,
151
+ context_size: int = 2048,
152
+ temperature: float = -1.0,
153
+ seed: int = 17,
154
+ n_gpu_layers: int = -1,
155
+ profile: str = "auto",
156
+ ) -> None:
157
+ candidate_labels = _split_csv(candidates)
158
+ case_ids = _split_csv(cases)
159
+ candidate_file_json = Path(candidate_file).read_text(encoding="utf-8") if candidate_file else None
160
+ if candidate_labels == ["all"]:
161
+ from telltale.models.eval_prompts import COMPARISON_MODEL_CANDIDATES
162
+
163
+ candidate_labels = [candidate.label for candidate in COMPARISON_MODEL_CANDIDATES]
164
+ resolved_profile = _resolve_profile(profile, candidate_labels)
165
+ if resolved_profile == "nemotron":
166
+ max_tokens = max_tokens if max_tokens > 0 else 520
167
+ temperature = temperature if temperature >= 0 else 0.55
168
+ speech_max_words = 36
169
+ rationale_max_words = 44
170
+ else:
171
+ max_tokens = max_tokens if max_tokens > 0 else 320
172
+ temperature = temperature if temperature >= 0 else 0.30
173
+ speech_max_words = 16
174
+ rationale_max_words = 24
175
+ outputs = []
176
+ for label in candidate_labels:
177
+ try:
178
+ output = run_model_eval.remote(
179
+ candidate_labels=[label],
180
+ candidate_file_json=candidate_file_json,
181
+ case_ids=case_ids,
182
+ max_cases=max_cases,
183
+ max_tokens=max_tokens,
184
+ context_size=context_size,
185
+ temperature=temperature,
186
+ seed=seed,
187
+ n_gpu_layers=n_gpu_layers,
188
+ speech_max_words=speech_max_words,
189
+ rationale_max_words=rationale_max_words,
190
+ )
191
+ outputs.append({"candidate": label, "ok": True, "output": json.loads(output)})
192
+ except Exception as error: # noqa: BLE001 - keep comparing after native/model crashes
193
+ outputs.append(
194
+ {
195
+ "candidate": label,
196
+ "ok": False,
197
+ "error": f"{type(error).__name__}: {error}",
198
+ }
199
+ )
200
+ print(json.dumps({"isolated_candidate_runs": outputs}, ensure_ascii=True, indent=2))
201
+
202
+
203
+ def _split_csv(value: str) -> list[str]:
204
+ return [item.strip() for item in value.split(",") if item.strip()]
205
+
206
+
207
+ def _resolve_profile(profile: str, candidate_labels: list[str]) -> str:
208
+ if profile != "auto":
209
+ return profile
210
+ if candidate_labels and all(label.startswith("nemotron") for label in candidate_labels):
211
+ return "nemotron"
212
+ return "default"
telltale/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __version__ = "0.1.0"
telltale/agents/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from telltale.agents.decision import AgentDecision, parse_agent_decision, validate_and_repair
2
+ from telltale.agents.dialogue import PlayerUtterance
3
+ from telltale.agents.memory import AgentMemory, MemoryDelta
4
+ from telltale.agents.profiles import (
5
+ AGENT_PROFILES,
6
+ DEFAULT_FINAL_BOSS_ID,
7
+ AgentProfile,
8
+ get_agent_profile,
9
+ profiles_for_floor,
10
+ )
11
+ from telltale.agents.prompts import build_agent_prompt
12
+
13
+ __all__ = [
14
+ "AGENT_PROFILES",
15
+ "DEFAULT_FINAL_BOSS_ID",
16
+ "AgentDecision",
17
+ "AgentMemory",
18
+ "AgentProfile",
19
+ "MemoryDelta",
20
+ "PlayerUtterance",
21
+ "build_agent_prompt",
22
+ "get_agent_profile",
23
+ "parse_agent_decision",
24
+ "profiles_for_floor",
25
+ "validate_and_repair",
26
+ ]
telltale/agents/decision.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parse, validate, and repair agent decisions from model output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from enum import Enum
7
+ import json
8
+ import re
9
+ from typing import Any, Iterable
10
+
11
+ from telltale.agents.dialogue import PlayerUtterance
12
+ from telltale.agents.memory import MemoryDelta
13
+ from telltale.agents.prompts import build_agent_prompt
14
+ from telltale.game.holdem import ActionType
15
+
16
+
17
+ @dataclass
18
+ class AgentDecision:
19
+ """
20
+ Describes a decision made by the agent. This is the output from the
21
+ model.
22
+ """
23
+ action: ActionType | str # The action the agent is taking.
24
+ amount: int
25
+ speech: str # the thing that the agent says when making this decision.
26
+ honest_rationale: str # the reason the agent gives for making this decision.
27
+ emotional_state: str
28
+ memory_delta: MemoryDelta
29
+ source: str = "model"
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ data = asdict(self)
33
+ data["action"] = self.action.value if isinstance(self.action, ActionType) else self.action
34
+ return data
35
+
36
+
37
+ def parse_agent_decision(raw_model_output: str) -> AgentDecision:
38
+ """
39
+ Parses the model output into an AgentDecision object.
40
+ """
41
+ data = _loads_model_json(raw_model_output)
42
+ missing = [field for field in ("action", "speech", "honest_rationale") if not data.get(field)]
43
+ if missing:
44
+ raise ValueError(f"model decision missing required field(s): {', '.join(missing)}")
45
+ raw_action = str(data["action"])
46
+ try:
47
+ action: ActionType | str = ActionType(raw_action)
48
+ except ValueError:
49
+ action = raw_action
50
+ return AgentDecision(
51
+ action=action,
52
+ amount=max(0, int(data.get("amount") or 0)),
53
+ speech=str(data["speech"]),
54
+ honest_rationale=str(data["honest_rationale"]),
55
+ emotional_state=str(data.get("emotional_state") or "composed"),
56
+ memory_delta=MemoryDelta.from_mapping(_normalize_memory_delta(data.get("memory_delta") or data.get("memory_updates"))),
57
+ source="model",
58
+ )
59
+
60
+
61
+ def _normalize_memory_delta(data: Any) -> dict[str, Any] | None:
62
+ if not isinstance(data, dict):
63
+ return data
64
+ normalized = dict(data)
65
+ aliases = {
66
+ "respect_delta": "respect_for_player",
67
+ "fear_delta": "fear_of_player",
68
+ "charm_delta": "charmed_by_player",
69
+ "grudge_delta": "grudge_against_player",
70
+ "note": "summary",
71
+ }
72
+ for source, target in aliases.items():
73
+ if source in normalized and target not in normalized:
74
+ normalized[target] = normalized[source]
75
+ return normalized
76
+
77
+
78
+ def validate_and_repair(
79
+ decision: AgentDecision,
80
+ legal_actions: Iterable[ActionType | str],
81
+ solver_recommendation: Any,
82
+ ) -> AgentDecision:
83
+ """
84
+ Validates whether the decision made by the model is valid.
85
+ If it is not, it will be repaired (check or the first legal action from the solver).
86
+ """
87
+
88
+ if not decision.speech.strip() or not decision.honest_rationale.strip():
89
+ raise ValueError("model decision requires non-empty speech and honest_rationale")
90
+
91
+ legal = _normalize_legal_actions(legal_actions)
92
+ if not legal:
93
+ repaired_action = ActionType.CHECK
94
+ elif decision.action not in legal:
95
+ repaired_action = _solver_action_or_first_legal(solver_recommendation, legal)
96
+ else:
97
+ repaired_action = decision.action
98
+
99
+ amount = _repair_amount(decision.amount, repaired_action, solver_recommendation)
100
+
101
+ decision.action = repaired_action
102
+ decision.amount = amount
103
+ decision.source = "model"
104
+ return decision
105
+
106
+
107
+ def _loads_model_json(raw_model_output: str) -> dict[str, Any]:
108
+ """
109
+ Loads the model output as a JSON object.
110
+ """
111
+ try:
112
+ data = json.loads(raw_model_output)
113
+ except json.JSONDecodeError:
114
+ match = re.search(r"\{.*\}", raw_model_output, flags=re.DOTALL)
115
+ if not match:
116
+ raise
117
+ data = json.loads(match.group(0))
118
+ if not isinstance(data, dict):
119
+ raise ValueError("model output must be a JSON object")
120
+ return data
121
+
122
+
123
+ def _normalize_legal_actions(legal_actions: Iterable[ActionType | str]) -> set[ActionType]:
124
+ """
125
+ Normalizes the legal actions to a set of ActionType objects.
126
+ """
127
+ legal: set[ActionType] = set()
128
+ for action in legal_actions:
129
+ if isinstance(action, ActionType):
130
+ legal.add(action)
131
+ else:
132
+ legal.add(ActionType(str(action)))
133
+ return legal
134
+
135
+
136
+ def _solver_action_or_first_legal(solver_recommendation: Any, legal: set[ActionType]) -> ActionType:
137
+ """
138
+ Returns the action from the solver if it is legal, otherwise returns the first legal action.
139
+ """
140
+ solver_action = _action_from_solver(solver_recommendation)
141
+ if solver_action in legal:
142
+ return solver_action
143
+ preferred_order = (
144
+ ActionType.CHECK,
145
+ ActionType.CALL,
146
+ ActionType.FOLD,
147
+ ActionType.BET,
148
+ ActionType.RAISE,
149
+ ActionType.ALL_IN,
150
+ )
151
+ for action in preferred_order:
152
+ if action in legal:
153
+ return action
154
+ return sorted(legal, key=lambda item: item.value)[0]
155
+
156
+
157
+ def _action_from_solver(solver_recommendation: Any) -> ActionType | None:
158
+ """
159
+ Returns the action from the solver.
160
+ """
161
+ value = None
162
+ if isinstance(solver_recommendation, dict):
163
+ value = solver_recommendation.get("action") or solver_recommendation.get("suggested_action")
164
+ else:
165
+ value = getattr(solver_recommendation, "action", None) or getattr(
166
+ solver_recommendation,
167
+ "suggested_action",
168
+ None,
169
+ )
170
+ if isinstance(value, ActionType):
171
+ return value
172
+ if isinstance(value, Enum):
173
+ value = value.value
174
+ if value is None:
175
+ return None
176
+ try:
177
+ return ActionType(str(value))
178
+ except ValueError:
179
+ return None
180
+
181
+
182
+ def _repair_amount(action_amount: int, action: ActionType, solver_recommendation: Any) -> int:
183
+ if action in {ActionType.FOLD, ActionType.CHECK, ActionType.CALL}:
184
+ return 0
185
+ if action == ActionType.ALL_IN:
186
+ solver_amount = _amount_from_solver(solver_recommendation)
187
+ return max(0, solver_amount if solver_amount is not None else action_amount)
188
+ solver_amount = _amount_from_solver(solver_recommendation)
189
+ if action_amount <= 0 and solver_amount is not None:
190
+ return max(0, solver_amount)
191
+ return max(0, action_amount)
192
+
193
+
194
+ def _amount_from_solver(solver_recommendation: Any) -> int | None:
195
+ """
196
+ Returns the amount from the solver.
197
+ """
198
+ if isinstance(solver_recommendation, dict):
199
+ value = solver_recommendation.get("amount") or solver_recommendation.get("suggested_amount")
200
+ else:
201
+ value = getattr(solver_recommendation, "amount", None)
202
+ if value is None:
203
+ value = getattr(solver_recommendation, "suggested_amount", None)
204
+ if value is None:
205
+ return None
206
+ try:
207
+ return int(value)
208
+ except (TypeError, ValueError):
209
+ return None
210
+
211
+
212
+ __all__ = [
213
+ "AgentDecision",
214
+ "PlayerUtterance",
215
+ "build_agent_prompt",
216
+ "parse_agent_decision",
217
+ "validate_and_repair",
218
+ ]
telltale/agents/dialogue.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ import json
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class PlayerUtterance:
9
+ raw_text: str = ""
10
+ target_agent_id: str | None = None
11
+
12
+ @property
13
+ def is_empty(self) -> bool:
14
+ return self.raw_text.strip() == ""
15
+
16
+ def to_dict(self) -> dict[str, str | None]:
17
+ return asdict(self)
18
+
19
+ def to_json(self) -> str:
20
+ return json.dumps(self.to_dict())
telltale/agents/memory.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ MAX_RECENT_ITEMS = 5
9
+
10
+
11
+ @dataclass
12
+ class MemoryDelta:
13
+ """
14
+ Describes a change in the agent's memory.
15
+ """
16
+ respect_for_player: float | None = None
17
+ fear_of_player: float | None = None
18
+ charmed_by_player: float | None = None
19
+ grudge_against_player: float | None = None
20
+ recent_player_patterns: list[str] = field(default_factory=list)
21
+ recent_dialogue_impressions: list[str] = field(default_factory=list)
22
+ summary: str | None = None
23
+
24
+ @classmethod
25
+ def from_mapping(cls, data: dict[str, Any] | None) -> "MemoryDelta":
26
+ if not data:
27
+ return cls()
28
+ return cls(
29
+ respect_for_player=_optional_float(data.get("respect_for_player")),
30
+ fear_of_player=_optional_float(data.get("fear_of_player")),
31
+ charmed_by_player=_optional_float(data.get("charmed_by_player")),
32
+ grudge_against_player=_optional_float(data.get("grudge_against_player")),
33
+ recent_player_patterns=_string_list(data.get("recent_player_patterns")),
34
+ recent_dialogue_impressions=_string_list(data.get("recent_dialogue_impressions")),
35
+ summary=_optional_string(data.get("summary")),
36
+ )
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ return asdict(self)
40
+
41
+
42
+ @dataclass
43
+ class AgentMemory:
44
+ """
45
+ Describes the memory of an agent.
46
+ """
47
+ agent_id: str
48
+ respect_for_player: float = 0.0 # how much the agent respects the player.
49
+ fear_of_player: float = 0.0 # how much the agent fears the player.
50
+ charmed_by_player: float = 0.0 # how much the agent is charmed by the player.
51
+ grudge_against_player: float = 0.0 # how much the agent is grudge against the player.
52
+ recent_player_patterns: list[str] = field(default_factory=list) # a list of recent patterns the player has exhibited.
53
+ recent_dialogue_impressions: list[str] = field(default_factory=list) # a list of recent dialogue impressions the player has given.
54
+ summary: str = "" # a summary of the agent's memory.
55
+ max_recent_items: int = MAX_RECENT_ITEMS # the maximum number of recent items to store.
56
+
57
+ def __post_init__(self) -> None:
58
+ self.respect_for_player = _clamp(self.respect_for_player, -1.0, 1.0)
59
+ self.fear_of_player = _clamp(self.fear_of_player, 0.0, 1.0)
60
+ self.charmed_by_player = _clamp(self.charmed_by_player, 0.0, 1.0)
61
+ self.grudge_against_player = _clamp(self.grudge_against_player, 0.0, 1.0)
62
+ self.recent_player_patterns = _bounded(_string_list(self.recent_player_patterns), self.max_recent_items)
63
+ self.recent_dialogue_impressions = _bounded(
64
+ _string_list(self.recent_dialogue_impressions),
65
+ self.max_recent_items,
66
+ )
67
+ self.summary = str(self.summary or "")
68
+
69
+ @classmethod
70
+ def neutral(cls, agent_id: str) -> "AgentMemory":
71
+ return cls(agent_id=agent_id)
72
+
73
+ @classmethod
74
+ def reset_for_run(cls, agent_id: str) -> "AgentMemory":
75
+ return cls.neutral(agent_id)
76
+
77
+ def apply_delta(self, delta: MemoryDelta | dict[str, Any] | None) -> None:
78
+ update = delta if isinstance(delta, MemoryDelta) else MemoryDelta.from_mapping(delta)
79
+ if update.respect_for_player is not None:
80
+ self.respect_for_player = _clamp(update.respect_for_player, -1.0, 1.0)
81
+ if update.fear_of_player is not None:
82
+ self.fear_of_player = _clamp(update.fear_of_player, 0.0, 1.0)
83
+ if update.charmed_by_player is not None:
84
+ self.charmed_by_player = _clamp(update.charmed_by_player, 0.0, 1.0)
85
+ if update.grudge_against_player is not None:
86
+ self.grudge_against_player = _clamp(update.grudge_against_player, 0.0, 1.0)
87
+ if update.recent_player_patterns:
88
+ self.recent_player_patterns = _bounded(
89
+ [*self.recent_player_patterns, *update.recent_player_patterns],
90
+ self.max_recent_items,
91
+ )
92
+ if update.recent_dialogue_impressions:
93
+ self.recent_dialogue_impressions = _bounded(
94
+ [*self.recent_dialogue_impressions, *update.recent_dialogue_impressions],
95
+ self.max_recent_items,
96
+ )
97
+ if update.summary is not None:
98
+ self.summary = update.summary
99
+
100
+ def to_dict(self) -> dict[str, Any]:
101
+ return asdict(self)
102
+
103
+ def to_json(self) -> str:
104
+ return json.dumps(self.to_dict())
105
+
106
+
107
+ def _bounded(items: list[str], limit: int) -> list[str]:
108
+ return items[-limit:]
109
+
110
+
111
+ def _clamp(value: float, lower: float, upper: float) -> float:
112
+ return max(lower, min(upper, float(value)))
113
+
114
+
115
+ def _optional_float(value: Any) -> float | None:
116
+ if value is None:
117
+ return None
118
+ try:
119
+ return float(value)
120
+ except (TypeError, ValueError):
121
+ return None
122
+
123
+
124
+ def _optional_string(value: Any) -> str | None:
125
+ if value is None:
126
+ return None
127
+ return str(value)
128
+
129
+
130
+ def _string_list(value: Any) -> list[str]:
131
+ if value is None:
132
+ return []
133
+ if not isinstance(value, list):
134
+ return [str(value)]
135
+ return [str(item) for item in value if item is not None]
telltale/agents/profiles.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, fields
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class AgentProfile:
8
+ agent_id: str
9
+ name: str
10
+ floor_min: int
11
+ floor_max: int
12
+ can_be_boss: bool
13
+ character_summary: str
14
+ poker_style: str
15
+ dialogue_style: str
16
+ honesty_style: str
17
+ speech_style: str
18
+ voice_id: str
19
+
20
+
21
+ AGENT_PROFILES: tuple[AgentProfile, ...] = (
22
+ AgentProfile(
23
+ agent_id="mike_mcdermott",
24
+ name="Mike McDermott",
25
+ floor_min=1,
26
+ floor_max=4,
27
+ can_be_boss=False,
28
+ character_summary=(
29
+ "Mike is a Rounders-style grinder who learned the game in back rooms and law-school basements. "
30
+ "He treats every pot like a solved argument and believes discipline beats talent over enough hands. "
31
+ "He is not flashy, but he notices when someone is playing scared or telling a story that does not add up."
32
+ ),
33
+ poker_style=(
34
+ "He plays patient, bluff-aware poker and rarely forces action without a credible narrative behind it. "
35
+ "When the board and betting line make sense, he is comfortable applying pressure, but he will release marginal spots rather than gamble on ego."
36
+ ),
37
+ dialogue_style=(
38
+ "He listens closely for overconfidence and respects opponents who keep their table talk disciplined. "
39
+ "Cheap needles and empty bravado annoy him, and he is more likely to engage with players who sound like they are actually thinking."
40
+ ),
41
+ honesty_style=(
42
+ "He is usually candid about his reads and what the action is telling him, even when he is bluffing. "
43
+ "He keeps exact hand strength behind a half-smile and rarely volunteers more information than the hand requires."
44
+ ),
45
+ speech_style=(
46
+ "His lines are measured, read-heavy, and dry, often sounding like notes taken at the table rather than performance. "
47
+ "He favors short sentences and lets a pause do the work when he wants someone to talk themselves into a mistake."
48
+ ),
49
+ voice_id="mike_measured_grinder",
50
+ ),
51
+ AgentProfile(
52
+ agent_id="molly_bloom",
53
+ name="Molly Bloom",
54
+ floor_min=1,
55
+ floor_max=5,
56
+ can_be_boss=True,
57
+ character_summary=(
58
+ "Molly is a Molly's Game-style host who has watched every kind of ego lose money in the same chair. "
59
+ "She runs the room like a professional operation and reads people the way a host reads a guest list. "
60
+ "She is calm under pressure because she has already seen the expensive version of whatever you are trying."
61
+ ),
62
+ poker_style=(
63
+ "She plays patient, selective poker and waits for opponents to donate chips through loose play or emotional sizing. "
64
+ "She punishes weak lines without needing to be loud about it, and she rarely spews just to prove a point."
65
+ ),
66
+ dialogue_style=(
67
+ "She is hard to intimidate, largely charm-resistant, and quick to notice performative confidence. "
68
+ "Players who try to own the room with noise usually find her quieter and more dangerous than they expected."
69
+ ),
70
+ honesty_style=(
71
+ "She is polished and direct, telling the truth in a way that still protects her leverage at the table. "
72
+ "She will explain what she thinks you did wrong without handing you the full ledger of what she holds."
73
+ ),
74
+ speech_style=(
75
+ "Her speech is elegant, clipped, and observant, like a host settling a room with one well-placed line. "
76
+ "She sounds composed even when she is applying pressure, and she rarely wastes words on empty theater."
77
+ ),
78
+ voice_id="molly_polished_host",
79
+ ),
80
+ AgentProfile(
81
+ agent_id="worm",
82
+ name="Worm",
83
+ floor_min=1,
84
+ floor_max=3,
85
+ can_be_boss=False,
86
+ character_summary=(
87
+ "Worm is a Rounders-style opportunist who turns every hand into a side hustle and every table into a chance to get even. "
88
+ "He is likable until he is not, and his confidence often arrives a little faster than his math. "
89
+ "He survives on nerve, memory, and the hope that nobody at the table is paying close enough attention."
90
+ ),
91
+ poker_style=(
92
+ "His game is volatile and tricky, with sudden stabs at neglected pots and spite calls when he feels disrespected. "
93
+ "He can make strong plays when cornered, but he is just as likely to chase a feeling if the table gives him an opening."
94
+ ),
95
+ dialogue_style=(
96
+ "Needles get under his skin quickly, while sincere kindness can disarm him for a hand or two. "
97
+ "He treats bluffs like dares and will often talk himself into a bigger pot just to see if you meant it."
98
+ ),
99
+ honesty_style=(
100
+ "He is loose with the truth in speech and happy to sell a story that sounds better than his cards. "
101
+ "When he is cornered or annoyed, he becomes surprisingly honest about how much the hand has gotten under his skin."
102
+ ),
103
+ speech_style=(
104
+ "He talks fast, slangy, and needling, with jokes that arrive half a beat too early to be entirely safe. "
105
+ "His lines feel improvised and personal, like he is trying to win the hand and the conversation at the same time."
106
+ ),
107
+ voice_id="worm_fast_talker",
108
+ ),
109
+ AgentProfile(
110
+ agent_id="lancey_howard",
111
+ name="Lancey Howard",
112
+ floor_min=2,
113
+ floor_max=5,
114
+ can_be_boss=True,
115
+ character_summary=(
116
+ "Lancey is a Cincinnati Kid-style veteran who has outlasted whole rooms of hot streaks and louder reputations. "
117
+ "He carries the calm of someone who has seen the same trick dressed up in new clothes a dozen times. "
118
+ "He does not need to dominate the table to own it; patience and timing are enough."
119
+ ),
120
+ poker_style=(
121
+ "He plays composed, fundamentally sound poker and is comfortable making thin but disciplined decisions. "
122
+ "He does not chase drama, but he will pay attention when a younger player starts believing their own story too much."
123
+ ),
124
+ dialogue_style=(
125
+ "He is rarely rattled and treats table talk as another bet placed in the middle of the hand. "
126
+ "He listens for patterns, not volume, and he gives more weight to consistency than to one loud line."
127
+ ),
128
+ honesty_style=(
129
+ "He lies rarely, but he frames the truth like a lesson with one page deliberately missing. "
130
+ "He may tell you what kind of player you are being without telling you what he actually has."
131
+ ),
132
+ speech_style=(
133
+ "His speech is calm, old-school, and aphoristic, with a veteran's pause before the blade lands. "
134
+ "He sounds unhurried even in big pots, as if time is one more advantage he has already banked."
135
+ ),
136
+ voice_id="lancey_old_school",
137
+ ),
138
+ AgentProfile(
139
+ agent_id="ginger_mckenna",
140
+ name="Ginger McKenna",
141
+ floor_min=1,
142
+ floor_max=4,
143
+ can_be_boss=False,
144
+ character_summary=(
145
+ "Ginger is a Casino-style gambler who makes glamour feel like a pressure tactic and chaos feel like an invitation. "
146
+ "She can be magnetic at the table, but the performance is never entirely separate from the play. "
147
+ "When she is locked in, her confidence is real enough to make even disciplined opponents hesitate."
148
+ ),
149
+ poker_style=(
150
+ "She plays high-variance poker and becomes splashy when excitement takes over or a dramatic read clicks. "
151
+ "She is willing to overbet a story if the table has given her enough emotional room to sell it."
152
+ ),
153
+ dialogue_style=(
154
+ "Charm can move her, intimidation can bore her, and needles can light the fuse faster than most players expect. "
155
+ "She responds to energy at the table and is especially dangerous when she feels underestimated."
156
+ ),
157
+ honesty_style=(
158
+ "She is emotionally honest in the moment, even when her strategy is deliberately selective. "
159
+ "She knows she is performing and will admit what she is feeling long before she admits what she is holding."
160
+ ),
161
+ speech_style=(
162
+ "Her lines are bright, teasing, and theatrical, with sudden flashes of steel when the pot gets serious. "
163
+ "She can sound playful right up to the point where the table realizes the joke had a price attached."
164
+ ),
165
+ voice_id="ginger_bright_gambler",
166
+ ),
167
+ AgentProfile(
168
+ agent_id="ace_rothstein",
169
+ name="Ace Rothstein",
170
+ floor_min=2,
171
+ floor_max=5,
172
+ can_be_boss=True,
173
+ character_summary=(
174
+ "Ace is a Casino-style floor manager who sees the table as an operation to audit rather than a game to improvise. "
175
+ "He notices sloppy process the way other players notice missed draws, and he has little patience for emotional leakage. "
176
+ "He plays like someone who expects the numbers to matter in the end."
177
+ ),
178
+ poker_style=(
179
+ "He plays tight-aggressive, pot-odds-conscious poker and is allergic to lines that do not reconcile with the board. "
180
+ "He prefers clean decisions over heroic guesses and will punish opponents who donate chips through imprecision."
181
+ ),
182
+ dialogue_style=(
183
+ "He does not reward theatrics and respects opponents who speak with precision instead of volume. "
184
+ "Players who leak frustration, panic, or vanity usually find him colder and more exacting in response."
185
+ ),
186
+ honesty_style=(
187
+ "He is blunt when the math is obvious and guarded when the leverage is not. "
188
+ "He will tell you that your line was bad without necessarily telling you how he plans to collect on that mistake."
189
+ ),
190
+ speech_style=(
191
+ "His speech is minimal, controlled, and managerial, as if every sentence has already been reconciled. "
192
+ "He rarely raises his voice, but his quiet lines can feel more final than another player's whole monologue."
193
+ ),
194
+ voice_id="ace_controlled_manager",
195
+ ),
196
+ AgentProfile(
197
+ agent_id="teddy_kgb",
198
+ name="Teddy KGB",
199
+ floor_min=5,
200
+ floor_max=5,
201
+ can_be_boss=True,
202
+ character_summary=(
203
+ "Teddy is a Rounders-style final boss who treats every hand as a confession and every opponent as a story waiting to break. "
204
+ "He is theatrical on purpose, because pressure is part of his game as much as cards and chips. "
205
+ "By the time he is across from you, he has already decided that the hand is about more than equity."
206
+ ),
207
+ poker_style=(
208
+ "He plays adaptive, theatrical poker and looks for repeated patterns he can punish once fear enters the pot. "
209
+ "He is comfortable turning a small edge into a psychological ordeal, especially against scared money."
210
+ ),
211
+ dialogue_style=(
212
+ "He remembers emotional pressure, mocks repeated tactics, and turns fear into a price you have to pay to continue. "
213
+ "Players who show the same tell twice should expect him to name it out loud and make the next decision harder."
214
+ ),
215
+ honesty_style=(
216
+ "He is honest in riddles and reveals enough reasoning to make the player sweat without giving away the hand. "
217
+ "He enjoys letting you think you understand him right up until the moment you realize you were being studied."
218
+ ),
219
+ speech_style=(
220
+ "His speech is grand, amused, and predatory, with short flourishes and heavy pauses that feel deliberately staged. "
221
+ "He sounds like he is enjoying the hand even when he is not, which makes it harder to tell when he actually is."
222
+ ),
223
+ voice_id="teddy_theatrical_boss",
224
+ ),
225
+ AgentProfile(
226
+ agent_id="ben_campbell",
227
+ name="Ben Campbell",
228
+ floor_min=1,
229
+ floor_max=4,
230
+ can_be_boss=False,
231
+ character_summary=(
232
+ "Ben is a 21-style table guide who is still deciding whether caution is courage or just another way to stay alive. "
233
+ "He is sharp with patterns and uncomfortable with chaos, which makes him dangerous in the right spot and brittle in the wrong one. "
234
+ "He plays like someone who understands the math but is still learning what the math costs emotionally."
235
+ ),
236
+ poker_style=(
237
+ "He plays measured, pattern-oriented poker and prefers clean spots with explainable pressure behind them. "
238
+ "He avoids unnecessary gambles, but he can become surprisingly firm when he thinks he has read the table correctly."
239
+ ),
240
+ dialogue_style=(
241
+ "He responds well to reasoned talk, grows wary of chaos, and notices repeated emotional plays faster than he admits. "
242
+ "Players who try to rush or rattle him often get a quieter, more careful version of him instead of a fold."
243
+ ),
244
+ honesty_style=(
245
+ "He is often candid, especially when explaining rhythm, timing, or what the betting line suggests. "
246
+ "He is less willing to discuss exact holdings and will redirect toward process when the question gets too direct."
247
+ ),
248
+ speech_style=(
249
+ "His speech is quiet, analytical, and slightly self-conscious, like a calculation spoken aloud before he commits to it. "
250
+ "He sounds young in the best sense: observant, careful, and still surprised when the table turns personal."
251
+ ),
252
+ voice_id="ben_quiet_counter",
253
+ ),
254
+ AgentProfile(
255
+ agent_id="rusty_ryan",
256
+ name="Rusty Ryan",
257
+ floor_min=2,
258
+ floor_max=5,
259
+ can_be_boss=True,
260
+ character_summary=(
261
+ "Rusty is an Ocean's Eleven-style closer who makes pressure look like leisure and big pots look like errands he has run before. "
262
+ "He is at ease in uncomfortable spots because discomfort is usually someone else's problem. "
263
+ "He gives the impression that he is barely trying, which is often the most expensive impression at the table."
264
+ ),
265
+ poker_style=(
266
+ "He stays cool under fire, plays opportunistically in position, and likes sudden tempo changes in high-stakes spots. "
267
+ "He is willing to let a hand breathe before striking, and he rarely looks as committed as he actually is."
268
+ ),
269
+ dialogue_style=(
270
+ "Intimidation backfires on him, charm becomes sport, and bluffs often invite a cleaner bluff in return. "
271
+ "He enjoys verbal sparring and will treat table talk like a side game he is already winning."
272
+ ),
273
+ honesty_style=(
274
+ "He is casually honest when it amuses him and evasive when the hand still has a punchline left. "
275
+ "He may tell you the truth in a way that sounds like a joke, which makes it harder to know what to believe."
276
+ ),
277
+ speech_style=(
278
+ "His speech is laid-back, witty, and concise, like he already knows where the exit is and is in no hurry to reach it. "
279
+ "He can deflate tension with one line and then raise the pot on the next without changing expression."
280
+ ),
281
+ voice_id="rusty_cool_closer",
282
+ ),
283
+ AgentProfile(
284
+ agent_id="sydney",
285
+ name="Sydney",
286
+ floor_min=3,
287
+ floor_max=5,
288
+ can_be_boss=True,
289
+ character_summary=(
290
+ "Sydney is a Hard Eight-style veteran who understands the cost of every favor, every debt, and every bad night at the table. "
291
+ "She has seen enough desperation to distrust charm on sight and enough quiet competence to respect it immediately. "
292
+ "She does not play for applause; she plays to still be standing when the room gets honest."
293
+ ),
294
+ poker_style=(
295
+ "She plays disciplined, observant poker and is especially strong against obvious charm, panic, or sloppy heroics. "
296
+ "She waits for players to reveal who they are under pressure, then makes them pay for the revelation."
297
+ ),
298
+ dialogue_style=(
299
+ "She reads speech aggressively, distrusts charm, and respects opponents who stay consistent when the pot grows. "
300
+ "Players who perform for the table usually get less patience from her than players who simply keep their word."
301
+ ),
302
+ honesty_style=(
303
+ "She is plainspoken and morally direct, though never careless with live information at the table. "
304
+ "She will tell you what she thinks of your line without turning the hand into a confession booth."
305
+ ),
306
+ speech_style=(
307
+ "Her speech is sparse, grave, and humane, with lines that land like advice, warning, or both at once. "
308
+ "She does not fill silence for sport, so when she speaks in a big pot, the table tends to listen."
309
+ ),
310
+ voice_id="sydney_sparse_veteran",
311
+ ),
312
+ )
313
+
314
+ DEFAULT_FINAL_BOSS_ID = "teddy_kgb"
315
+
316
+
317
+ def get_agent_profile(agent_id: str) -> AgentProfile:
318
+ for profile in AGENT_PROFILES:
319
+ if profile.agent_id == agent_id:
320
+ return profile
321
+ raise KeyError(f"unknown agent profile: {agent_id}")
322
+
323
+
324
+ def profiles_for_floor(floor_number: int, *, include_bosses: bool = True) -> tuple[AgentProfile, ...]:
325
+ return tuple(
326
+ profile
327
+ for profile in AGENT_PROFILES
328
+ if profile.floor_min <= floor_number <= profile.floor_max
329
+ and (include_bosses or not profile.can_be_boss)
330
+ )
331
+
332
+
333
+ def profile_field_names() -> set[str]:
334
+ return {field.name for field in fields(AgentProfile)}
telltale/agents/prompts.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, is_dataclass
4
+ from enum import Enum
5
+ import json
6
+ from typing import Any, Iterable
7
+
8
+ from telltale.agents.dialogue import PlayerUtterance
9
+ from telltale.agents.memory import AgentMemory
10
+ from telltale.agents.profiles import AgentProfile
11
+
12
+
13
+ def build_agent_prompt(
14
+ agent_profile: AgentProfile,
15
+ memory: AgentMemory,
16
+ public_state: Any,
17
+ private_agent_state: Any,
18
+ solver_recommendation: Any,
19
+ player_utterance: PlayerUtterance | None,
20
+ legal_actions: Iterable[Any],
21
+ *,
22
+ speech_max_words: int = 28,
23
+ rationale_max_words: int = 36,
24
+ ) -> str:
25
+ """
26
+ Builds the prompt for the agent.
27
+ """
28
+ legal_action_values = [_stringify(action) for action in legal_actions]
29
+ payload = {
30
+ "profile": {
31
+ "name": agent_profile.name,
32
+ "character_summary": agent_profile.character_summary,
33
+ "poker_style": agent_profile.poker_style,
34
+ "dialogue_style": agent_profile.dialogue_style,
35
+ "honesty_style": agent_profile.honesty_style,
36
+ "speech_style": agent_profile.speech_style,
37
+ },
38
+ "memory": {
39
+ "respect_for_player": memory.respect_for_player,
40
+ "fear_of_player": memory.fear_of_player,
41
+ "charmed_by_player": memory.charmed_by_player,
42
+ "grudge_against_player": memory.grudge_against_player,
43
+ "recent_player_patterns": memory.recent_player_patterns,
44
+ "recent_dialogue_impressions": memory.recent_dialogue_impressions,
45
+ "summary": memory.summary,
46
+ },
47
+ "public_state": _compact(public_state),
48
+ "private_agent_state": _compact(private_agent_state),
49
+ "solver_recommendation": _compact(solver_recommendation),
50
+ "legal_actions": legal_action_values,
51
+ "legal_amount_guidance": (
52
+ "Use amount 0 for fold/check/call. For bet/raise/all_in only, choose a non-negative chip amount."
53
+ ),
54
+ "required_output": {
55
+ "exact_keys_only": [
56
+ "action",
57
+ "amount",
58
+ "speech",
59
+ "honest_rationale",
60
+ "emotional_state",
61
+ "memory_delta",
62
+ ],
63
+ "action": "one of legal_actions",
64
+ "amount": "0 for fold/check/call; integer chips for bet/raise/all_in",
65
+ "speech": f"in-character table line, max {speech_max_words} words",
66
+ "honest_rationale": f"brief honest reason, max {rationale_max_words} words",
67
+ "emotional_state": "short phrase",
68
+ "memory_delta": "object with only changed fields; allowed fields are respect_for_player, fear_of_player, charmed_by_player, grudge_against_player, recent_player_patterns, recent_dialogue_impressions, summary",
69
+ },
70
+ }
71
+ if player_utterance is not None and not player_utterance.is_empty:
72
+ payload["player_utterance"] = player_utterance.to_dict()
73
+
74
+ return "\n".join(
75
+ [
76
+ "You are choosing one Texas Hold'em action for a fixed Telltale AI opponent.",
77
+ "Return only valid JSON matching the required schema.",
78
+ "Do not include markdown, comments, trailing prose, or keys outside required_output.exact_keys_only.",
79
+ "The solver recommendation is advisory context, not a command.",
80
+ "Your rationale should be honest. Your speech should sound like the character at the table.",
81
+ "You may play suboptimally when personality, memory, or player dialogue justify it.",
82
+ json.dumps(payload, default=_json_default, ensure_ascii=True, separators=(",", ":")),
83
+ "Return one complete JSON object now. Do not add any key after memory_delta.",
84
+ ]
85
+ )
86
+
87
+
88
+ def _compact(value: Any) -> Any:
89
+ if value is None:
90
+ return None
91
+ if is_dataclass(value):
92
+ return asdict(value)
93
+ if hasattr(value, "to_dict"):
94
+ return value.to_dict()
95
+ if hasattr(value, "model_dump"):
96
+ return value.model_dump()
97
+ if isinstance(value, dict):
98
+ return {str(key): _compact(item) for key, item in value.items()}
99
+ if isinstance(value, (list, tuple)):
100
+ return [_compact(item) for item in value]
101
+ if isinstance(value, Enum):
102
+ return value.value
103
+ return value
104
+
105
+
106
+ def _json_default(value: Any) -> Any:
107
+ if isinstance(value, Enum):
108
+ return value.value
109
+ if is_dataclass(value):
110
+ return asdict(value)
111
+ return str(value)
112
+
113
+
114
+ def _stringify(value: Any) -> str:
115
+ if isinstance(value, Enum):
116
+ return str(value.value)
117
+ return str(value)
telltale/agents/trace.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, is_dataclass
4
+ from datetime import datetime, timezone
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class TraceLogger:
11
+ def __init__(self, run_id: str, root_dir: str | Path = "runs"):
12
+ self.run_id = run_id
13
+ self.run_dir = Path(root_dir) / run_id
14
+ self.path = self.run_dir / "trace.jsonl"
15
+ self.records: list[dict[str, Any]] = []
16
+
17
+ def record(self, **fields: Any) -> dict[str, Any]:
18
+ record = {
19
+ "trace_version": 1,
20
+ "timestamp": datetime.now(timezone.utc).isoformat(),
21
+ "run_id": self.run_id,
22
+ **fields,
23
+ }
24
+ self.records.append(record)
25
+ self.run_dir.mkdir(parents=True, exist_ok=True)
26
+ with self.path.open("a", encoding="utf-8") as file:
27
+ file.write(json.dumps(record, default=_json_default, ensure_ascii=True) + "\n")
28
+ return record
29
+
30
+ def export_text(self) -> str:
31
+ if self.path.exists():
32
+ return self.path.read_text(encoding="utf-8")
33
+ return "\n".join(json.dumps(record, default=_json_default) for record in self.records)
34
+
35
+
36
+ def _json_default(value: Any) -> Any:
37
+ if is_dataclass(value):
38
+ return asdict(value)
39
+ if hasattr(value, "to_dict"):
40
+ return value.to_dict()
41
+ if hasattr(value, "model_dump"):
42
+ return value.model_dump()
43
+ if hasattr(value, "value"):
44
+ return value.value
45
+ return str(value)
telltale/game/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Public API for the game resources
2
+
3
+ from telltale.game.cards import Card, Deck
4
+ from telltale.game.economy import RunState, RunStatus
5
+ from telltale.game.engine import HoldemEngine
6
+ from telltale.game.floors import FLOOR_CONFIGS, FloorConfig
7
+ from telltale.game.holdem import HandState, PlayerState, Street
8
+ from telltale.game.perks import ActivePerk, PerkDefinition
9
+
10
+ __all__ = [
11
+ "ActivePerk",
12
+ "Card",
13
+ "Deck",
14
+ "FLOOR_CONFIGS",
15
+ "FloorConfig",
16
+ "HoldemEngine",
17
+ "HandState",
18
+ "PerkDefinition",
19
+ "PlayerState",
20
+ "RunState",
21
+ "RunStatus",
22
+ "Street",
23
+ ]
telltale/game/cards.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This file contains the Card and Deck classes for the game of poker.
3
+
4
+ Cards are represented as a string of two characters, the rank and the suit.
5
+ For example, "Ah" is the ace of hearts.
6
+
7
+ Deck is a collection of 52 unique cards.
8
+ It can be shuffled and drawn from.
9
+
10
+ CardError is raised when card or deck operations are invalid.
11
+ """
12
+
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ import random
18
+
19
+
20
+ RANKS = "23456789TJQKA"
21
+ SUITS = "cdhs"
22
+
23
+
24
+ class CardError(ValueError):
25
+ """Raised when card or deck operations are invalid."""
26
+
27
+ '''
28
+ Note for self regarding dataclasses:
29
+
30
+ The order=true argument will add things like __lt__, __gt__, __le__, __ge__
31
+ , __eq__, and __ne__ methods to the class. These will be useful for sorting
32
+ and comparing cards.
33
+
34
+ The frozen=True argument adds __setattr__ and __delattr__ methods to the class.
35
+ These methods will prevent the card from being modified after it is created.
36
+ '''
37
+ @dataclass(frozen=True, order=True)
38
+ class Card:
39
+ rank: str
40
+ suit: str
41
+
42
+ @classmethod
43
+ def parse(cls, value: str) -> "Card":
44
+ """
45
+ Parse a string representation of a card into a Card object.
46
+ """
47
+ if len(value) != 2:
48
+ raise CardError(f"card must be two characters like 'Ah': {value!r}")
49
+ rank = value[0]
50
+ suit = value[1]
51
+ if rank not in RANKS:
52
+ raise CardError(f"invalid rank {rank!r} in {value!r}")
53
+ if suit not in SUITS:
54
+ raise CardError(f"invalid suit {suit!r} in {value!r}")
55
+ return cls(rank=rank, suit=suit)
56
+
57
+ def __str__(self) -> str:
58
+ return f"{self.rank}{self.suit}"
59
+
60
+
61
+ class Deck:
62
+ def __init__(self, seed: int | str | bytes | None = None, cards: list[Card] | None = None):
63
+ # note to self: [:] is a shallow copy of the list
64
+ if cards is not None:
65
+ self.cards = cards[:]
66
+ else:
67
+ self.cards = _standard_deck()
68
+ if cards is None and seed is not None:
69
+ random.Random(seed).shuffle(self.cards)
70
+
71
+ @classmethod
72
+ def shuffled(cls, seed: int | str | bytes | None = None) -> "Deck":
73
+ return cls(seed=seed)
74
+
75
+ def draw(self, count: int = 1) -> Card | list[Card]:
76
+ if count < 1:
77
+ raise CardError("draw count must be at least 1")
78
+ if count > len(self.cards):
79
+ raise CardError(f"cannot draw {count} cards from deck with {len(self.cards)} cards")
80
+ drawn: list[Card] = []
81
+ for _ in range(count):
82
+ drawn.append(self.cards.pop())
83
+ return drawn[0] if count == 1 else drawn
84
+
85
+ def to_strings(self) -> list[str]:
86
+ strings: list[str] = []
87
+ for card in self.cards:
88
+ strings.append(str(card))
89
+ return strings
90
+
91
+ def __len__(self) -> int:
92
+ return len(self.cards)
93
+
94
+
95
+ def _standard_deck() -> list[Card]:
96
+ cards: list[Card] = []
97
+ for suit in SUITS:
98
+ for rank in RANKS:
99
+ cards.append(Card(rank, suit))
100
+ return cards
telltale/game/economy.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ """
3
+
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from hashlib import sha256
10
+ from random import Random
11
+ from uuid import uuid5, NAMESPACE_URL
12
+
13
+ from telltale.game.floors import FloorConfig, get_floor_config
14
+ from telltale.game.holdem import HandState
15
+ from telltale.game.perks import ActivePerk, PERK_DEFINITIONS, PerkEffectContext, TriggerTiming, get_perk
16
+
17
+
18
+ STARTING_BANKROLL = 250
19
+ MAX_CONTINUES = 2
20
+
21
+
22
+ class RunStatus(str, Enum):
23
+ ACTIVE = "active"
24
+ WON = "won"
25
+ LOST = "lost"
26
+
27
+
28
+ @dataclass
29
+ class TableState:
30
+ """
31
+ Represents the state of the table for a given floor.
32
+ A table is created when a player enters a floor and is destroyed when the floor is won or lost.
33
+ """
34
+ floor_number: int
35
+ seed: str
36
+ player_stack: int # number of chips the player has available to bet
37
+ opponent_stacks: list[int]
38
+ small_blind: int
39
+ big_blind: int
40
+ hand_index: int = 0 # index of the current hand being played
41
+ pending_metadata: dict = field(default_factory=dict)
42
+
43
+ def serialize(self) -> dict:
44
+ return {
45
+ "floor_number": self.floor_number,
46
+ "seed": self.seed,
47
+ "player_stack": self.player_stack,
48
+ "opponent_stacks": list(self.opponent_stacks),
49
+ "small_blind": self.small_blind,
50
+ "big_blind": self.big_blind,
51
+ "hand_index": self.hand_index,
52
+ "pending_metadata": dict(self.pending_metadata),
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class RunState:
58
+ """
59
+ A run is a sequence of floors that a player plays.
60
+ Losing a floor ends the run unless the player uses a continue.
61
+ Winning a floor advances to the next floor and rewards the player with perks.
62
+ RunState manages the state of the run and provides methods to interact with the game.
63
+ """
64
+ run_id: str
65
+ seed: str
66
+ floor_index: int # index of the current floor being played
67
+ # bankroll is the number of chips the player has available to bet.
68
+ bankroll: int
69
+ # continues track how many times the player has bailed out to keep the run alive.
70
+ # each continue increases the cost of the next floor's buy-in.
71
+ # the player starts with 0 continues and can use up to MAX_CONTINUES per run.
72
+ continues: int = 0
73
+ # perks are special abilities that can be used to gain an advantage over the house.
74
+ # the player starts with no active perks and can earn them by winning floors.
75
+ active_perks: list[ActivePerk] = field(default_factory=list)
76
+ completed_floors: list[int] = field(default_factory=list)
77
+ status: RunStatus = RunStatus.ACTIVE
78
+ current_hand: HandState | None = None
79
+ current_table: TableState | None = None
80
+ event_log: list[dict] = field(default_factory=list)
81
+ available_rewards: list[str] = field(default_factory=list)
82
+ awaiting_reward: bool = False
83
+
84
+ @classmethod
85
+ def start(cls, seed: int | str | bytes | None = None, bankroll: int = STARTING_BANKROLL) -> "RunState":
86
+ normalized_seed = _normalize_seed(seed)
87
+ run = cls(
88
+ run_id=str(uuid5(NAMESPACE_URL, f"telltale-run:{normalized_seed}")),
89
+ seed=normalized_seed,
90
+ floor_index=0,
91
+ bankroll=bankroll,
92
+ )
93
+ run._log("run_started", bankroll=bankroll)
94
+ return run
95
+
96
+ @property
97
+ def current_floor(self) -> FloorConfig:
98
+ return get_floor_config(self.floor_index)
99
+
100
+ def floor_seed(self, floor_index: int | None = None) -> str:
101
+ index = self.floor_index if floor_index is None else floor_index
102
+ return derive_seed(self.seed, "floor", index)
103
+
104
+ def hand_seed(self, floor_index: int | None = None, hand_index: int = 0) -> str:
105
+ index = self.floor_index if floor_index is None else floor_index
106
+ return derive_seed(self.seed, "hand", index, hand_index)
107
+
108
+ def buy_in_for_floor(self, floor: FloorConfig | None = None) -> int:
109
+ """
110
+ Calculates the buy-in for the current floor. The buy-in is the amount of chips the player must pay to enter the floor.
111
+ The buy-in is affected by the player's continues and active perks.
112
+ - If the player has 1 continue, the buy-in is increased by 15%.
113
+ - If the player has 2 or more continues, the buy-in is increased by 30%.
114
+ - If the player has the Buy-In Discount perk, the buy-in is reduced by 15%.
115
+ - If the player has the Continue Waiver perk, one of the used continues is waived for buy-in penalty calculations, which will
116
+ reduce the buy-in by the amount of the waived continue.
117
+ """
118
+ floor = self.current_floor if floor is None else floor
119
+ amount = floor.buy_in
120
+ effective_continues = self.effective_continues()
121
+ if effective_continues == 1:
122
+ amount = round(amount * 1.15)
123
+ elif effective_continues >= 2:
124
+ amount = round(amount * 1.30)
125
+
126
+ for perk in list(self.active_perks):
127
+ definition = get_perk(perk.perk_id)
128
+ if definition.trigger_timing != TriggerTiming.NEXT_FLOOR_BUY_IN:
129
+ continue
130
+ result = definition.effect(PerkEffectContext(amount=amount))
131
+ if result.amount is not None:
132
+ amount = result.amount
133
+ return max(0, amount)
134
+
135
+ def effective_continues(self) -> int:
136
+ count = self.continues
137
+ for perk in self.active_perks:
138
+ definition = get_perk(perk.perk_id)
139
+ if definition.trigger_timing == TriggerTiming.CONTINUE_PENALTY:
140
+ result = definition.effect(PerkEffectContext(continues=count))
141
+ if result.continues is not None:
142
+ count = result.continues
143
+ return max(0, count)
144
+
145
+ def enter_current_floor(self, use_continue: bool = True) -> bool:
146
+ if self.status != RunStatus.ACTIVE:
147
+ return False
148
+ if self.awaiting_reward:
149
+ raise ValueError("choose a reward before entering the next floor")
150
+ if self.current_table is not None:
151
+ return True
152
+
153
+ floor = self.current_floor
154
+ buy_in = self.buy_in_for_floor(floor)
155
+ if self.bankroll < buy_in:
156
+ if not self._use_continue_if_allowed(buy_in, use_continue):
157
+ self.status = RunStatus.LOST
158
+ self._log("run_lost", reason="insufficient_bankroll", floor_number=floor.floor_number)
159
+ return False
160
+
161
+ self.bankroll -= buy_in
162
+ if self.bankroll < 0:
163
+ raise ValueError("bankroll cannot be negative")
164
+ self._consume_perks(TriggerTiming.NEXT_FLOOR_BUY_IN)
165
+ self.current_table = self._create_table(floor)
166
+ self._log("floor_entered", floor_number=floor.floor_number, buy_in=buy_in, bankroll=self.bankroll)
167
+ return True
168
+
169
+ def win_current_floor(self, ending_stack: int | None = None) -> None:
170
+ if self.current_table is None:
171
+ self.enter_current_floor()
172
+ if self.current_table is None or self.status != RunStatus.ACTIVE:
173
+ return
174
+ floor = self.current_floor
175
+ stack = self.current_table.player_stack if ending_stack is None else ending_stack
176
+ stack = max(0, stack)
177
+ self.bankroll += stack
178
+ self.completed_floors.append(floor.floor_number)
179
+ self.current_table = None
180
+ self.current_hand = None
181
+ self._log("floor_won", floor_number=floor.floor_number, returned_stack=stack, bankroll=self.bankroll)
182
+
183
+ if floor.is_boss:
184
+ self.status = RunStatus.WON
185
+ self.available_rewards = []
186
+ self.awaiting_reward = False
187
+ self._log("run_won")
188
+ return
189
+
190
+ self.available_rewards = self._reward_choices(floor)
191
+ self.awaiting_reward = True
192
+
193
+ def lose_current_floor(self, all_in_loss: bool = False) -> None:
194
+ if self.current_table is None:
195
+ return
196
+ floor = self.current_floor
197
+ recovered = 0
198
+ if all_in_loss:
199
+ recovered = self._trigger_insurance(self.current_table.player_stack)
200
+ self.bankroll += recovered
201
+ self.current_table = None
202
+ self.current_hand = None
203
+ self._log("floor_lost", floor_number=floor.floor_number, recovered_stack=recovered, bankroll=self.bankroll)
204
+ if floor.is_boss:
205
+ self.status = RunStatus.LOST
206
+ self._log("run_lost", reason="boss_floor_loss")
207
+ return
208
+ if not self._use_continue_if_allowed(self.current_floor.buy_in, use_continue=True):
209
+ self.status = RunStatus.LOST
210
+ self._log("run_lost", reason="max_continues")
211
+
212
+ def choose_reward(self, perk_id: str) -> None:
213
+ if not self.awaiting_reward:
214
+ raise ValueError("there is no reward to choose")
215
+ if perk_id not in self.available_rewards:
216
+ raise ValueError(f"{perk_id} is not an available reward")
217
+ definition = get_perk(perk_id)
218
+ self.active_perks.append(ActivePerk.from_definition(definition))
219
+ self.available_rewards = []
220
+ self.awaiting_reward = False
221
+ self.floor_index += 1
222
+ self._log("reward_chosen", perk_id=perk_id)
223
+
224
+ def serialize_public(self) -> dict:
225
+ return {
226
+ "run_id": self.run_id,
227
+ "seed": self.seed,
228
+ "floor_index": self.floor_index,
229
+ "floor_number": self.current_floor.floor_number if self.status == RunStatus.ACTIVE else None,
230
+ "bankroll": self.bankroll,
231
+ "continues": self.continues,
232
+ "active_perks": [perk.serialize() for perk in self.active_perks],
233
+ "completed_floors": list(self.completed_floors),
234
+ "status": self.status.value,
235
+ "current_table": self.current_table.serialize() if self.current_table else None,
236
+ "available_rewards": list(self.available_rewards),
237
+ "awaiting_reward": self.awaiting_reward,
238
+ "event_log": list(self.event_log),
239
+ }
240
+
241
+ def _create_table(self, floor: FloorConfig) -> TableState:
242
+ rng = Random(self.floor_seed())
243
+ opponent_count = rng.randint(floor.opponent_count_min, floor.opponent_count_max)
244
+ opponent_stacks = [floor.player_starting_stack for _ in range(opponent_count)]
245
+ metadata = self._apply_floor_start_perks()
246
+ return TableState(
247
+ floor_number=floor.floor_number,
248
+ seed=self.floor_seed(),
249
+ player_stack=floor.player_starting_stack,
250
+ opponent_stacks=opponent_stacks,
251
+ small_blind=floor.blinds.small_blind,
252
+ big_blind=floor.blinds.big_blind,
253
+ pending_metadata=metadata,
254
+ )
255
+
256
+ def _reward_choices(self, floor: FloorConfig) -> list[str]:
257
+ count = floor.reward_choices_count
258
+ if self.effective_continues() >= 2:
259
+ count = max(1, count - 1)
260
+ rng = Random(derive_seed(self.seed, "reward", floor.floor_number))
261
+ available = [perk.perk_id for perk in PERK_DEFINITIONS]
262
+ rng.shuffle(available)
263
+ return available[:count]
264
+
265
+ def _use_continue_if_allowed(self, needed_bankroll: int, use_continue: bool) -> bool:
266
+ floor = self.current_floor
267
+ if not use_continue or floor.is_boss or self.continues >= MAX_CONTINUES:
268
+ return False
269
+ self.continues += 1
270
+ if self.bankroll < needed_bankroll:
271
+ self.bankroll = needed_bankroll
272
+ self._log("continue_used", continues=self.continues, bankroll=self.bankroll)
273
+ return True
274
+
275
+ def _consume_perks(self, timing: TriggerTiming) -> None:
276
+ kept: list[ActivePerk] = []
277
+ for perk in self.active_perks:
278
+ definition = get_perk(perk.perk_id)
279
+ if definition.trigger_timing == timing and perk.remaining_uses is not None:
280
+ perk.remaining_uses -= 1
281
+ if perk.remaining_uses is None or perk.remaining_uses > 0:
282
+ kept.append(perk)
283
+ self.active_perks = kept
284
+
285
+ def _apply_floor_start_perks(self) -> dict:
286
+ metadata: dict = {}
287
+ kept: list[ActivePerk] = []
288
+ for perk in self.active_perks:
289
+ definition = get_perk(perk.perk_id)
290
+ if definition.trigger_timing == TriggerTiming.NEXT_FLOOR_START:
291
+ result = definition.effect(PerkEffectContext(metadata=perk.metadata))
292
+ metadata.update(result.metadata)
293
+ if perk.remaining_uses is not None:
294
+ perk.remaining_uses -= 1
295
+ if perk.remaining_uses is None or perk.remaining_uses > 0:
296
+ kept.append(perk)
297
+ self.active_perks = kept
298
+ return metadata
299
+
300
+ def _trigger_insurance(self, table_stack: int) -> int:
301
+ kept: list[ActivePerk] = []
302
+ recovered = 0
303
+ for perk in self.active_perks:
304
+ definition = get_perk(perk.perk_id)
305
+ if definition.trigger_timing == TriggerTiming.ALL_IN_LOSS and recovered == 0:
306
+ result = definition.effect(PerkEffectContext(table_stack=table_stack))
307
+ recovered = result.recovered_stack
308
+ if perk.remaining_uses is not None:
309
+ perk.remaining_uses -= 1
310
+ if perk.remaining_uses is None or perk.remaining_uses > 0:
311
+ kept.append(perk)
312
+ self.active_perks = kept
313
+ return recovered
314
+
315
+ def _log(self, event: str, **payload: object) -> None:
316
+ self.event_log.append({"event": event, **payload})
317
+
318
+
319
+ def derive_seed(run_seed: str, *parts: object) -> str:
320
+ payload = ":".join([run_seed, *(str(part) for part in parts)])
321
+ return sha256(payload.encode("utf-8")).hexdigest()[:16]
322
+
323
+
324
+ def _normalize_seed(seed: int | str | bytes | None) -> str:
325
+ if seed is None:
326
+ return "default"
327
+ if isinstance(seed, bytes):
328
+ return seed.hex()
329
+ return str(seed)
telltale/game/engine.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from telltale.game.economy import RunState
4
+ from telltale.game.holdem import ActionType, HandState, PlayerState
5
+
6
+
7
+ class HoldemEngine:
8
+ def start_hand(
9
+ self,
10
+ players: list[PlayerState],
11
+ seed: int | str | bytes | None = None,
12
+ dealer_button_index: int = 0,
13
+ small_blind: int = 5,
14
+ big_blind: int = 10,
15
+ ) -> HandState:
16
+ return HandState.start(
17
+ players=players,
18
+ seed=seed,
19
+ dealer_button_index=dealer_button_index,
20
+ small_blind=small_blind,
21
+ big_blind=big_blind,
22
+ )
23
+
24
+ def legal_actions(self, hand: HandState, player_id: str | None = None) -> set[ActionType]:
25
+ return hand.legal_actions(player_id)
26
+
27
+ def apply_action(
28
+ self,
29
+ hand: HandState,
30
+ action: ActionType | str,
31
+ amount: int = 0,
32
+ player_id: str | None = None,
33
+ ) -> HandState:
34
+ hand.apply_action(action, amount=amount, player_id=player_id)
35
+ return hand
36
+
37
+ def start_run(self, seed: int | str | bytes | None = None, bankroll: int = 250) -> RunState:
38
+ return RunState.start(seed=seed, bankroll=bankroll)
39
+
40
+ def enter_floor(self, run: RunState, use_continue: bool = True) -> RunState:
41
+ run.enter_current_floor(use_continue=use_continue)
42
+ return run
43
+
44
+ def win_floor(self, run: RunState, ending_stack: int | None = None) -> RunState:
45
+ run.win_current_floor(ending_stack=ending_stack)
46
+ return run
47
+
48
+ def lose_floor(self, run: RunState, all_in_loss: bool = False) -> RunState:
49
+ run.lose_current_floor(all_in_loss=all_in_loss)
50
+ return run
51
+
52
+ def choose_reward(self, run: RunState, perk_id: str) -> RunState:
53
+ run.choose_reward(perk_id)
54
+ return run
telltale/game/floors.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This module defines the floors that can be played in the game.
3
+
4
+ Floors are the different levels of the game.
5
+ Each floor has a name, a buy-in, a starting stack, a number of opponents, and a number of rewards.
6
+ The floors are ordered from level 1 to level 5 (inclusive).
7
+ Level 5 is the final level and is the boss fight.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BlindStructure:
17
+ """
18
+ Defines the blind structure (the amount of chips that each player must
19
+ post as the small blind and big blind) for a given floor.
20
+ """
21
+ small_blind: int
22
+ big_blind: int
23
+ # escalation interval is the number of hands that must pass before the blinds are increased.
24
+ # this is used to create a dynamic blind structure that increases over time.
25
+ escalation_interval_hands: int = 0
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FloorConfig:
30
+ floor_number: int
31
+ name: str
32
+ buy_in: int
33
+ player_starting_stack: int # the number of chips the player starts with at the beginning of the floor
34
+ opponent_count_min: int
35
+ opponent_count_max: int
36
+ blinds: BlindStructure
37
+ win_target: int # the number of chips the player must have at the end of the floor to win the floor
38
+ reward_choices_count: int
39
+ is_boss: bool = False
40
+
41
+ @property
42
+ def total_players_min(self) -> int:
43
+ return self.opponent_count_min + 1
44
+
45
+ @property
46
+ def total_players_max(self) -> int:
47
+ return self.opponent_count_max + 1
48
+
49
+
50
+ FLOOR_CONFIGS: tuple[FloorConfig, ...] = (
51
+ FloorConfig(
52
+ floor_number=1,
53
+ name="Level 1 (Tutorial)",
54
+ buy_in=40,
55
+ player_starting_stack=100,
56
+ opponent_count_min=2,
57
+ opponent_count_max=2,
58
+ blinds=BlindStructure(small_blind=2, big_blind=4),
59
+ win_target=160,
60
+ reward_choices_count=3,
61
+ ),
62
+ FloorConfig(
63
+ floor_number=2,
64
+ name="Level 2",
65
+ buy_in=70,
66
+ player_starting_stack=140,
67
+ opponent_count_min=2,
68
+ opponent_count_max=3,
69
+ blinds=BlindStructure(small_blind=3, big_blind=6),
70
+ win_target=225,
71
+ reward_choices_count=3,
72
+ ),
73
+ FloorConfig(
74
+ floor_number=3,
75
+ name="Level 3",
76
+ buy_in=110,
77
+ player_starting_stack=180,
78
+ opponent_count_min=3,
79
+ opponent_count_max=3,
80
+ blinds=BlindStructure(small_blind=5, big_blind=10, escalation_interval_hands=8),
81
+ win_target=300,
82
+ reward_choices_count=3,
83
+ ),
84
+ FloorConfig(
85
+ floor_number=4,
86
+ name="Level 4",
87
+ buy_in=160,
88
+ player_starting_stack=230,
89
+ opponent_count_min=3,
90
+ opponent_count_max=4,
91
+ blinds=BlindStructure(small_blind=8, big_blind=16, escalation_interval_hands=6),
92
+ win_target=390,
93
+ reward_choices_count=3,
94
+ ),
95
+ FloorConfig(
96
+ floor_number=5,
97
+ name="Level 5 (Final)",
98
+ buy_in=240,
99
+ player_starting_stack=320,
100
+ opponent_count_min=4,
101
+ opponent_count_max=4,
102
+ blinds=BlindStructure(small_blind=12, big_blind=24, escalation_interval_hands=5),
103
+ win_target=560,
104
+ reward_choices_count=0,
105
+ is_boss=True,
106
+ ),
107
+ )
108
+
109
+ def get_floor_config(floor_index: int) -> FloorConfig:
110
+ if floor_index < 0 or floor_index >= len(FLOOR_CONFIGS):
111
+ raise ValueError(f"invalid floor index: {floor_index}")
112
+ return FLOOR_CONFIGS[floor_index]
telltale/game/holdem.py ADDED
@@ -0,0 +1,626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from itertools import combinations
7
+ from uuid import uuid4
8
+
9
+ from telltale.game.cards import Card, Deck
10
+
11
+
12
+ class PokerError(ValueError):
13
+ """Raised when a poker action or state transition is invalid."""
14
+
15
+
16
+ class Street(str, Enum):
17
+ """Defines the different stages of the poker game."""
18
+ PREFLOP = "preflop"
19
+ FLOP = "flop"
20
+ TURN = "turn"
21
+ RIVER = "river"
22
+ SHOWDOWN = "showdown"
23
+ COMPLETE = "complete"
24
+
25
+
26
+ class ActionType(str, Enum):
27
+ """Defines the different actions a player can take in the poker game."""
28
+ FOLD = "fold"
29
+ CHECK = "check"
30
+ CALL = "call"
31
+ BET = "bet"
32
+ RAISE = "raise"
33
+ ALL_IN = "all_in"
34
+
35
+
36
+ @dataclass
37
+ class PlayerState:
38
+ """Represents the state of a player in the poker game."""
39
+ player_id: str
40
+ name: str
41
+ seat_index: int
42
+ stack: int # number of chips the player has available to bet
43
+ hole_cards: list[Card] = field(default_factory=list) # the cards the player has in their hand
44
+ current_bet: int = 0 # the amount of chips the player has bet in the current round
45
+ has_folded: bool = False
46
+ is_all_in: bool = False
47
+ is_human: bool = False
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ActionRecord:
52
+ """Represents a record of an action taken by a player in the poker game."""
53
+ player_id: str
54
+ action: ActionType
55
+ amount: int
56
+ street: Street
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Pot:
61
+ amount: int
62
+ eligible_player_ids: tuple[str, ...] # the ids of the players who are eligible to win the pot
63
+
64
+
65
+ @dataclass
66
+ class HandState:
67
+ hand_id: str
68
+ deck: Deck
69
+ players: list[PlayerState]
70
+ dealer_button_index: int # note this is the index of the player who is the dealer, not the player id.
71
+ small_blind_index: int
72
+ big_blind_index: int
73
+ current_actor_index: int | None # the index of the player who is currently allowed to act
74
+ street: Street = Street.PREFLOP # the current street of the game
75
+ board_cards: list[Card] = field(default_factory=list) # the cards on the table
76
+ pot_contributions: dict[str, int] = field(default_factory=dict) # the amount of chips each player has contributed to the pot
77
+ minimum_call_amount: int = 0
78
+ minimum_raise_amount: int = 0
79
+ action_history: list[ActionRecord] = field(default_factory=list) # the history of actions taken by the players
80
+ street_acted_player_ids: set[str] = field(default_factory=set) # the ids of the players who have acted on the current street
81
+
82
+ @classmethod
83
+ def start(
84
+ cls,
85
+ players: list[PlayerState],
86
+ seed: int | str | bytes | None = None,
87
+ dealer_button_index: int = 0,
88
+ small_blind: int = 5,
89
+ big_blind: int = 10,
90
+ hand_id: str | None = None,
91
+ ) -> "HandState":
92
+ """Starts a new hand of poker."""
93
+ if len(players) < 2:
94
+ raise PokerError("at least two players are required")
95
+ if small_blind < 0 or big_blind <= 0:
96
+ raise PokerError("blind amounts must be positive")
97
+
98
+ ordered = sorted(players, key=lambda player: player.seat_index)
99
+ deck = Deck.shuffled(seed)
100
+ dealer_pos = dealer_button_index % len(ordered)
101
+ small_blind_pos = _next_seat(ordered, dealer_pos)
102
+ # the index of the player in the players list who is the big blind
103
+ big_blind_pos = _next_seat(ordered, small_blind_pos)
104
+ pot_contributions: dict[str, int] = {}
105
+ for player in ordered:
106
+ pot_contributions[player.player_id] = 0
107
+ hand = cls(
108
+ hand_id=hand_id or str(uuid4()),
109
+ deck=deck,
110
+ players=ordered,
111
+ dealer_button_index=dealer_pos,
112
+ small_blind_index=small_blind_pos,
113
+ big_blind_index=big_blind_pos,
114
+ current_actor_index=_next_seat(ordered, big_blind_pos),
115
+ pot_contributions=pot_contributions,
116
+ minimum_raise_amount=big_blind,
117
+ )
118
+ for _ in range(2):
119
+ for player in ordered:
120
+ player.hole_cards.append(deck.draw())
121
+ hand._post_blind(small_blind_pos, small_blind) # the small blind player posts the small blind amount
122
+ hand._post_blind(big_blind_pos, big_blind) # the big blind player posts the big blind amount
123
+ hand.minimum_call_amount = hand.current_max_bet()
124
+ hand._skip_unavailable_actor()
125
+ return hand
126
+
127
+ def legal_actions(self, player_id: str | None = None) -> set[ActionType]:
128
+ player = self._actor_or_player(player_id)
129
+ if self.street == Street.COMPLETE:
130
+ return set[ActionType]()
131
+ if player.has_folded or player.is_all_in or player.stack <= 0:
132
+ return set[ActionType]()
133
+
134
+ # the amount of chips the player needs to call to match the current maximum bet
135
+ outstanding = self.current_max_bet() - player.current_bet
136
+ actions = {ActionType.ALL_IN}
137
+ # if the player has no outstanding amount to call, they can check or bet
138
+ if outstanding == 0:
139
+ actions.add(ActionType.CHECK)
140
+ # if the current maximum bet is 0, the player can bet
141
+ if self.current_max_bet() == 0:
142
+ actions.add(ActionType.BET)
143
+ else:
144
+ # if the player has an outstanding amount to call, they can fold or call
145
+ actions.update({ActionType.FOLD, ActionType.CALL})
146
+ # if the player has more chips than the outstanding amount, they can raise
147
+ if player.stack > outstanding:
148
+ actions.add(ActionType.RAISE)
149
+ return actions
150
+
151
+ def apply_action(self, action: ActionType | str, amount: int = 0, player_id: str | None = None) -> None:
152
+ if self.street == Street.COMPLETE:
153
+ raise PokerError("hand is already complete")
154
+ player = self._actor_or_player(player_id)
155
+ if player_id is not None and self.current_actor_index is not None:
156
+ actor = self.players[self.current_actor_index]
157
+ if actor.player_id != player_id:
158
+ raise PokerError(f"it is {actor.player_id}'s turn, not {player_id}")
159
+
160
+ action_type = ActionType(action)
161
+ self._validate_action(player, action_type, amount)
162
+ paid = self._apply_payment(player, action_type, amount)
163
+ self.action_history.append(ActionRecord(player.player_id, action_type, paid, self.street))
164
+ self.street_acted_player_ids.add(player.player_id)
165
+
166
+ # if the action is a bet or a raise,
167
+ # OR (all-in AND player's current bet is greater than the min. call amount):
168
+ # update the minimum call amount and minimum raise amount
169
+ if action_type in {ActionType.BET, ActionType.RAISE} or (
170
+ action_type == ActionType.ALL_IN and player.current_bet > self.minimum_call_amount
171
+ ):
172
+ previous_call = self.minimum_call_amount
173
+ self.minimum_call_amount = self.current_max_bet()
174
+ raise_size = self.minimum_call_amount - previous_call
175
+ if raise_size >= self.minimum_raise_amount:
176
+ self.minimum_raise_amount = raise_size
177
+ self.street_acted_player_ids = {player.player_id}
178
+
179
+ self._settle_or_advance()
180
+
181
+ def to_private_state(self) -> dict:
182
+ """Returns the private state of the hand. This is internal to the game and not visible to the viewer."""
183
+ board_cards = _card_strings(self.board_cards)
184
+ players = []
185
+ for player in self.players:
186
+ players.append(self._serialize_player(player, reveal_hole_cards=True))
187
+ action_history = []
188
+ for record in self.action_history:
189
+ action_history.append(_serialize_action_record(record))
190
+ return {
191
+ "hand_id": self.hand_id,
192
+ "street": self.street.value,
193
+ "deck": self.deck.to_strings(),
194
+ "board_cards": board_cards,
195
+ "players": players,
196
+ "pot_contributions": dict(self.pot_contributions),
197
+ "current_actor_index": self.current_actor_index,
198
+ "action_history": action_history,
199
+ }
200
+
201
+ def to_public_state(self, viewer_player_id: str) -> dict:
202
+ """Returns the public state of the hand. This is what is visble to the viewer."""
203
+ # if the street is showdown or complete, the hole cards are visible to the viewer
204
+ showdown_visible = self.street in {Street.SHOWDOWN, Street.COMPLETE}
205
+ board_cards = _card_strings(self.board_cards)
206
+ players = []
207
+ for player in self.players:
208
+ reveal_hole_cards = showdown_visible or player.player_id == viewer_player_id
209
+ players.append(self._serialize_player(player, reveal_hole_cards=reveal_hole_cards))
210
+ legal_actions = []
211
+ for action in sorted(self.legal_actions(viewer_player_id), key=lambda item: item.value):
212
+ legal_actions.append(action.value)
213
+ action_history = []
214
+ for record in self.action_history:
215
+ action_history.append(_serialize_action_record(record))
216
+ return {
217
+ "hand_id": self.hand_id,
218
+ "street": self.street.value,
219
+ "board_cards": board_cards,
220
+ "players": players,
221
+ "pot_contributions": dict(self.pot_contributions),
222
+ "current_actor_index": self.current_actor_index,
223
+ "legal_actions": legal_actions,
224
+ "action_history": action_history,
225
+ }
226
+
227
+ def _validate_action(self, player: PlayerState, action: ActionType, amount: int) -> None:
228
+ """Validates the action taken by the player."""
229
+ if player.has_folded:
230
+ raise PokerError("folded players cannot act")
231
+ if player.is_all_in:
232
+ raise PokerError("all-in players cannot act")
233
+ if player.stack <= 0:
234
+ raise PokerError("players with no chips cannot act")
235
+ if action not in self.legal_actions(player.player_id):
236
+ raise PokerError(f"{action.value} is not legal for player {player.player_id}")
237
+ if amount < 0:
238
+ raise PokerError("action amount cannot be negative")
239
+
240
+ outstanding = self.current_max_bet() - player.current_bet
241
+ if action == ActionType.BET:
242
+ if amount <= 0:
243
+ raise PokerError("bet amount must be positive")
244
+ if amount > player.stack:
245
+ raise PokerError("bet amount exceeds stack")
246
+ if amount < self.minimum_raise_amount and amount < player.stack:
247
+ raise PokerError("bet amount is below the minimum bet")
248
+ elif action == ActionType.RAISE:
249
+ target = player.current_bet + amount
250
+ minimum_target = self.current_max_bet() + self.minimum_raise_amount
251
+ if amount <= outstanding:
252
+ raise PokerError("raise amount must exceed the call amount")
253
+ if amount > player.stack:
254
+ raise PokerError("raise amount exceeds stack")
255
+ if target < minimum_target and amount < player.stack:
256
+ raise PokerError("raise amount is below the minimum raise")
257
+
258
+ def _apply_payment(self, player: PlayerState, action: ActionType, amount: int) -> int:
259
+ """
260
+ Applies the payment for the action taken by the player.
261
+ Returns the amount of chips paid by the player.
262
+ """
263
+ paid = 0
264
+ if action == ActionType.FOLD:
265
+ player.has_folded = True
266
+ elif action == ActionType.CHECK:
267
+ paid = 0
268
+ elif action == ActionType.CALL:
269
+ # current max bet - player's current bet = the amount of chips the player needs to call to match the current maximum bet
270
+ paid = self._pay(player, min(player.stack, self.current_max_bet() - player.current_bet))
271
+ elif action in {ActionType.BET, ActionType.RAISE}:
272
+ paid = self._pay(player, amount)
273
+ elif action == ActionType.ALL_IN:
274
+ paid = self._pay(player, player.stack)
275
+ return paid
276
+
277
+ def _pay(self, player: PlayerState, amount: int) -> int:
278
+ """Pays the player the specified amount of chips."""
279
+ paid = min(amount, player.stack)
280
+ player.stack -= paid
281
+ player.current_bet += paid
282
+ self.pot_contributions[player.player_id] += paid
283
+ if player.stack == 0:
284
+ player.is_all_in = True
285
+ return paid
286
+
287
+ def _post_blind(self, player_index: int, amount: int) -> None:
288
+ """Posts the blind for the player at the specified index."""
289
+ player = self.players[player_index]
290
+ self._pay(player, amount)
291
+
292
+ def _settle_or_advance(self) -> None:
293
+ """Setstles the pot or advances the street."""
294
+ active = _players_not_folded(self.players)
295
+ # if there is only one active player, award the pot to them
296
+ if len(active) == 1:
297
+ self._award_uncontested(active[0])
298
+ return
299
+
300
+ # if all remaining players are all-in, showdown
301
+ if self._all_remaining_all_in():
302
+ self._deal_remaining_board() # deal the remaining board cards
303
+ self._showdown() # showdown
304
+ return
305
+
306
+ if self._street_is_complete():
307
+ self._advance_street() # advance the street
308
+ else:
309
+ self.current_actor_index = self._next_actionable_index(self.current_actor_index) # set the current actor index to the next actionable index
310
+
311
+ def _advance_street(self) -> None:
312
+ """Advances the street."""
313
+ for player in self.players:
314
+ player.current_bet = 0
315
+ self.street_acted_player_ids.clear()
316
+ self.minimum_call_amount = 0 # reset the minimum call amount
317
+
318
+ if self.street == Street.PREFLOP:
319
+ self.street = Street.FLOP
320
+ self._burn()
321
+ self.board_cards.extend(self.deck.draw(3))
322
+ elif self.street == Street.FLOP:
323
+ self.street = Street.TURN
324
+ self._burn()
325
+ self.board_cards.append(self.deck.draw())
326
+ elif self.street == Street.TURN:
327
+ self.street = Street.RIVER
328
+ self._burn()
329
+ self.board_cards.append(self.deck.draw())
330
+ elif self.street == Street.RIVER:
331
+ self._showdown()
332
+ return
333
+
334
+ self.current_actor_index = self._next_actionable_index(self.dealer_button_index) # set the current actor index to the next actionable index
335
+ if self.current_actor_index is None:
336
+ self._deal_remaining_board()
337
+ self._showdown()
338
+
339
+ def _showdown(self) -> None:
340
+ """
341
+ Shows down the hand.
342
+
343
+ We will build the side pots. Each side pot will be a Pot(amount, eligible_player_ids).
344
+ For all pots, we will find the winners and award the pot to them.
345
+ """
346
+ self.street = Street.SHOWDOWN # set the street to the showdown
347
+ for pot in build_side_pots(self.pot_contributions, self.players):
348
+ winners = self._best_players(pot.eligible_player_ids)
349
+ share, remainder = divmod(pot.amount, len(winners))
350
+ for player in winners:
351
+ player.stack += share
352
+ for player in self._clockwise_players_from_dealer(winners)[:remainder]:
353
+ player.stack += 1
354
+ self.street = Street.COMPLETE
355
+ self.current_actor_index = None
356
+
357
+ def _award_uncontested(self, winner: PlayerState) -> None:
358
+ """Awards the pot to the winner."""
359
+ winner.stack += sum(self.pot_contributions.values())
360
+ self.street = Street.COMPLETE
361
+ self.current_actor_index = None
362
+
363
+ def _deal_remaining_board(self) -> None:
364
+ while len(self.board_cards) < 5:
365
+ self._burn()
366
+ draw_count = 3 if len(self.board_cards) == 0 else 1
367
+ drawn = self.deck.draw(draw_count)
368
+ self.board_cards.extend(drawn if isinstance(drawn, list) else [drawn])
369
+
370
+ def _street_is_complete(self) -> bool:
371
+ actionable = _actionable_players(self.players)
372
+ if len(actionable) <= 1:
373
+ return True
374
+ max_bet = self.current_max_bet()
375
+ for player in actionable:
376
+ if player.current_bet != max_bet:
377
+ return False
378
+ if player.player_id not in self.street_acted_player_ids:
379
+ return False
380
+ return True
381
+
382
+ def _all_remaining_all_in(self) -> bool:
383
+ active = _players_not_folded(self.players)
384
+ if len(active) <= 1:
385
+ return False
386
+ for player in active:
387
+ if not player.is_all_in:
388
+ return False
389
+ return True
390
+
391
+ def _next_actionable_index(self, start_index: int | None) -> int | None:
392
+ if start_index is None:
393
+ return None
394
+ for offset in range(1, len(self.players) + 1):
395
+ index = (start_index + offset) % len(self.players)
396
+ player = self.players[index]
397
+ if not player.has_folded and not player.is_all_in and player.stack > 0:
398
+ return index
399
+ return None
400
+
401
+ def _skip_unavailable_actor(self) -> None:
402
+ if self.current_actor_index is None:
403
+ return
404
+ actor = self.players[self.current_actor_index]
405
+ if actor.has_folded or actor.is_all_in or actor.stack <= 0:
406
+ self.current_actor_index = self._next_actionable_index(self.current_actor_index)
407
+
408
+ def _actor_or_player(self, player_id: str | None) -> PlayerState:
409
+ """
410
+ Gets the player state for the given player id.
411
+ If the player id is None, it returns the player state for the current actor.
412
+ If the player id is not None, it returns the player state for the given player id.
413
+ """
414
+ if player_id is None:
415
+ if self.current_actor_index is None:
416
+ raise PokerError("there is no current actor")
417
+ else:
418
+ return self.players[self.current_actor_index]
419
+ return self._player_by_id(player_id)
420
+
421
+ def _player_by_id(self, player_id: str) -> PlayerState:
422
+ """Returns the player state for the given player id."""
423
+ for player in self.players:
424
+ if player.player_id == player_id:
425
+ return player
426
+ raise PokerError(f"unknown player id: {player_id}")
427
+
428
+ def _serialize_player(self, player: PlayerState, reveal_hole_cards: bool) -> dict:
429
+ hole_cards: list[str] = []
430
+ if reveal_hole_cards:
431
+ hole_cards = _card_strings(player.hole_cards)
432
+ return {
433
+ "player_id": player.player_id,
434
+ "name": player.name,
435
+ "seat_index": player.seat_index,
436
+ "stack": player.stack,
437
+ "hole_cards": hole_cards,
438
+ "current_bet": player.current_bet,
439
+ "has_folded": player.has_folded,
440
+ "is_all_in": player.is_all_in,
441
+ "is_human": player.is_human,
442
+ }
443
+
444
+ def _burn(self) -> None:
445
+ self.deck.draw()
446
+
447
+ def current_max_bet(self) -> int:
448
+ max_bet = 0
449
+ for player in self.players:
450
+ if player.current_bet > max_bet:
451
+ max_bet = player.current_bet
452
+ return max_bet
453
+
454
+ def _clockwise_players_from_dealer(self, players: list[PlayerState]) -> list[PlayerState]:
455
+ """Returns the players in the clockwise order from the dealer."""
456
+ ids: set[str] = set()
457
+ for player in players:
458
+ ids.add(player.player_id)
459
+ ordered = []
460
+ for offset in range(1, len(self.players) + 1):
461
+ player = self.players[(self.dealer_button_index + offset) % len(self.players)]
462
+ if player.player_id in ids:
463
+ ordered.append(player)
464
+ return ordered
465
+
466
+ def _best_players(self, eligible_player_ids: tuple[str, ...]) -> list[PlayerState]:
467
+ """Returns all eligible players tied for the best hand on the current board."""
468
+ best_rank: tuple[int, ...] | None = None
469
+ winners: list[PlayerState] = []
470
+ for player in self.players:
471
+ if player.player_id not in eligible_player_ids:
472
+ continue
473
+ rank = evaluate_7(player.hole_cards + self.board_cards)
474
+ if best_rank is None or rank > best_rank:
475
+ best_rank = rank
476
+ winners = [player]
477
+ elif rank == best_rank:
478
+ winners.append(player)
479
+ return winners
480
+
481
+
482
+ def evaluate_7(cards: list[Card]) -> tuple[int, ...]:
483
+ """Returns the best five-card Hold'em rank from seven cards."""
484
+ best_rank: tuple[int, ...] | None = None
485
+ for hand in combinations(cards, 5):
486
+ rank = evaluate_5(list(hand))
487
+ if best_rank is None or rank > best_rank:
488
+ best_rank = rank
489
+ if best_rank is None:
490
+ raise PokerError("evaluate_7 requires at least seven cards")
491
+ return best_rank
492
+
493
+ def evaluate_5(cards: list[Card]) -> tuple[int, ...]:
494
+ """
495
+ Returns the best five-card Hold'em rank from five cards.
496
+ """
497
+ values: list[int] = [] # for all cards, convert the rank to value
498
+ for card in cards:
499
+ values.append(_rank_value(card.rank))
500
+ values.sort(reverse=True) # sort the values in descending order
501
+
502
+ suits: set[str] = set() # get the suits of the cards
503
+ for card in cards:
504
+ suits.add(card.suit)
505
+ is_flush = len(suits) == 1 # check if the cards are a flush.
506
+
507
+ unique_values = sorted(set(values), reverse=True) # get the unique values of the cards
508
+ is_straight = False
509
+ straight_high = 0
510
+ if len(unique_values) == 5:
511
+ if unique_values[0] - unique_values[4] == 4:
512
+ is_straight = True
513
+ straight_high = unique_values[0]
514
+ elif unique_values == [14, 5, 4, 3, 2]:
515
+ is_straight = True
516
+ straight_high = 5
517
+
518
+ counts = Counter()
519
+ for card in cards:
520
+ counts[_rank_value(card.rank)] += 1
521
+ by_count = sorted(counts.items(), key=lambda item: (item[1], item[0]), reverse=True)
522
+
523
+ if is_straight and is_flush:
524
+ return (8, straight_high)
525
+ if by_count[0][1] == 4:
526
+ return (7, by_count[0][0], by_count[1][0])
527
+ if by_count[0][1] == 3 and by_count[1][1] == 2:
528
+ return (6, by_count[0][0], by_count[1][0])
529
+ if is_flush:
530
+ return (5, *values)
531
+ if is_straight:
532
+ return (4, straight_high)
533
+ if by_count[0][1] == 3:
534
+ kickers = _ranks_with_count_other_than(counts, 3)
535
+ return (3, by_count[0][0], *kickers)
536
+ if by_count[0][1] == 2 and by_count[1][1] == 2:
537
+ high_pair, low_pair = sorted((by_count[0][0], by_count[1][0]), reverse=True)
538
+ return (2, high_pair, low_pair, by_count[2][0])
539
+ if by_count[0][1] == 2:
540
+ kickers = _ranks_with_count_other_than(counts, 2)
541
+ return (1, by_count[0][0], *kickers)
542
+ return (0, *values)
543
+
544
+
545
+ def _rank_value(rank: str) -> int:
546
+ """Returns the value of a rank in the game of poker.
547
+ For example, the value of "2" is 2, the value of "T" is 10, the value of "J" is 11, etc.
548
+ """
549
+ return "23456789TJQKA".index(rank) + 2
550
+
551
+
552
+ def _card_strings(cards: list[Card]) -> list[str]:
553
+ strings: list[str] = []
554
+ for card in cards:
555
+ strings.append(str(card))
556
+ return strings
557
+
558
+
559
+ def _serialize_action_record(record: ActionRecord) -> dict:
560
+ return record.__dict__ | {"action": record.action.value, "street": record.street.value}
561
+
562
+
563
+ def _players_not_folded(players: list[PlayerState]) -> list[PlayerState]:
564
+ active: list[PlayerState] = []
565
+ for player in players:
566
+ if not player.has_folded:
567
+ active.append(player)
568
+ return active
569
+
570
+
571
+ def _actionable_players(players: list[PlayerState]) -> list[PlayerState]:
572
+ actionable: list[PlayerState] = []
573
+ for player in players:
574
+ if not player.has_folded and not player.is_all_in:
575
+ actionable.append(player)
576
+ return actionable
577
+
578
+
579
+ def _ranks_with_count_other_than(counts: Counter, excluded_count: int) -> list[int]:
580
+ ranks: list[int] = []
581
+ for rank, count in counts.items():
582
+ if count != excluded_count:
583
+ ranks.append(rank)
584
+ ranks.sort(reverse=True)
585
+ return ranks
586
+
587
+
588
+ def build_side_pots(contributions: dict[str, int], players: list[PlayerState]) -> list[Pot]:
589
+ """
590
+ Builds the side pots for the hands.
591
+
592
+ Side pots are built when a player goes all-in and there are other players who have not folded.
593
+ For example, if Alice puts in 50 chips all-in, Bob puts in 200, and Carol puts in 200, Alice
594
+ can only win 150 (50x3); Bob and Carol contest the remaining 300 between themselves.
595
+ """
596
+ levels: set[int] = set()
597
+ for amount in contributions.values():
598
+ if amount > 0:
599
+ levels.add(amount)
600
+ levels = sorted(levels)
601
+ pots: list[Pot] = []
602
+ previous = 0
603
+ for level in levels:
604
+ contributors: list[PlayerState] = []
605
+ for player in players:
606
+ if contributions[player.player_id] >= level:
607
+ contributors.append(player)
608
+ amount = (level - previous) * len(contributors)
609
+ eligible_ids: list[str] = []
610
+ for player in contributors:
611
+ if not player.has_folded:
612
+ eligible_ids.append(player.player_id)
613
+ eligible = tuple(eligible_ids)
614
+ if amount > 0 and eligible:
615
+ pots.append(Pot(amount=amount, eligible_player_ids=eligible))
616
+ previous = level
617
+ return pots
618
+
619
+
620
+ def _next_seat(players: list[PlayerState], start_index: int) -> int:
621
+ """
622
+ Returns the index of the next player in the list of players.
623
+ This is a circular list, so if the start_index is the last player,
624
+ the next player is the first player.
625
+ """
626
+ return (start_index + 1) % len(players)
telltale/game/perks.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This module defines the perks that can be used by the player in the game.
3
+
4
+ Perks are effects that can be applied to the player's game state.
5
+ They are triggered by certain events in the game and can have a variety of effects.
6
+
7
+ The perks defined here are:
8
+ - Buy-In Discount: The next floor buy-in is reduced by 15%.
9
+ - Continue Waiver: One used continue is waived for buy-in penalty calculations.
10
+ - Spotlight Seat: Act last on the first hand of the next floor when permitted.
11
+ - Stack Recovery: Recover 20% of the table stack once after an all-in loss.
12
+
13
+ You can get these perks by winning floors. When you win a floor, you are rewarded with a random perk.
14
+
15
+ Perks are defined by a trigger timing, which determines when the perk is triggered,
16
+ and an effect, which is a function that is called when the perk is triggered.
17
+
18
+ The perk can be triggered at the following times:
19
+ - Next floor buy-in: The perk is triggered when the player buys in for the next floor.
20
+ - Continue penalty: The perk is triggered when the player uses a continue.
21
+ - Next floor start: The perk is triggered when the player starts the next floor.
22
+ - All-in loss: The perk is triggered when the player loses an all-in.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from enum import Enum
29
+ from typing import Any, Callable
30
+
31
+
32
+ class TriggerTiming(str, Enum):
33
+ NEXT_FLOOR_BUY_IN = "next_floor_buy_in"
34
+ CONTINUE_PENALTY = "continue_penalty"
35
+ NEXT_FLOOR_START = "next_floor_start"
36
+ ALL_IN_LOSS = "all_in_loss"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PerkEffectContext:
41
+ amount: int = 0
42
+ continues: int = 0
43
+ table_stack: int = 0
44
+ metadata: dict[str, Any] = field(default_factory=dict)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class PerkEffectResult:
49
+ amount: int | None = None
50
+ continues: int | None = None
51
+ recovered_stack: int = 0
52
+ metadata: dict[str, Any] = field(default_factory=dict)
53
+ consumed: bool = False
54
+
55
+ # define a type alias for the effect function
56
+ EffectFunction = Callable[[PerkEffectContext], PerkEffectResult]
57
+
58
+ @dataclass(frozen=True)
59
+ class PerkDefinition:
60
+ perk_id: str
61
+ name: str
62
+ description: str
63
+ trigger_timing: TriggerTiming
64
+ effect: EffectFunction
65
+ max_uses: int | None = None
66
+ active_duration_floors: int | None = None
67
+ metadata: dict[str, Any] = field(default_factory=dict)
68
+
69
+ def serialize(self) -> dict[str, Any]:
70
+ return {
71
+ "perk_id": self.perk_id,
72
+ "name": self.name,
73
+ "description": self.description,
74
+ "trigger_timing": self.trigger_timing.value,
75
+ "max_uses": self.max_uses,
76
+ "active_duration_floors": self.active_duration_floors,
77
+ "metadata": dict(self.metadata),
78
+ }
79
+
80
+
81
+ @dataclass
82
+ class ActivePerk:
83
+ perk_id: str
84
+ remaining_uses: int | None = None
85
+ remaining_floors: int | None = None
86
+ metadata: dict[str, Any] = field(default_factory=dict)
87
+
88
+ @classmethod
89
+ def from_definition(cls, definition: PerkDefinition) -> "ActivePerk":
90
+ return cls(
91
+ perk_id=definition.perk_id,
92
+ remaining_uses=definition.max_uses,
93
+ remaining_floors=definition.active_duration_floors,
94
+ metadata=dict(definition.metadata),
95
+ )
96
+
97
+ def serialize(self) -> dict[str, Any]:
98
+ definition = get_perk(self.perk_id)
99
+ return definition.serialize() | {
100
+ "remaining_uses": self.remaining_uses,
101
+ "remaining_floors": self.remaining_floors,
102
+ "metadata": dict(self.metadata),
103
+ }
104
+
105
+
106
+ def _buy_in_discount_effect(context: PerkEffectContext) -> PerkEffectResult:
107
+ return PerkEffectResult(amount=max(0, round(context.amount * 0.85)), consumed=True)
108
+
109
+
110
+ def _continue_waiver_effect(context: PerkEffectContext) -> PerkEffectResult:
111
+ return PerkEffectResult(continues=max(0, context.continues - 1))
112
+
113
+
114
+ def _spotlight_seat_effect(context: PerkEffectContext) -> PerkEffectResult:
115
+ return PerkEffectResult(metadata={"player_acts_last_first_hand": True}, consumed=True)
116
+
117
+
118
+ def _stack_recovery_effect(context: PerkEffectContext) -> PerkEffectResult:
119
+ return PerkEffectResult(recovered_stack=max(0, context.table_stack // 5), consumed=True)
120
+
121
+
122
+ PERK_DEFINITIONS: tuple[PerkDefinition, ...] = (
123
+ PerkDefinition(
124
+ perk_id="buy_in_discount",
125
+ name="Buy-In Discount",
126
+ description="Next floor buy-in reduced by 15%.",
127
+ trigger_timing=TriggerTiming.NEXT_FLOOR_BUY_IN,
128
+ effect=_buy_in_discount_effect,
129
+ max_uses=1,
130
+ ),
131
+ PerkDefinition(
132
+ perk_id="continue_waiver",
133
+ name="Continue Waiver",
134
+ description="One used continue is waived for buy-in penalty calculations.",
135
+ trigger_timing=TriggerTiming.CONTINUE_PENALTY,
136
+ effect=_continue_waiver_effect,
137
+ # there is no max uses for this perk because it is only used once per continue
138
+ # and it is only triggered when the player uses a continue
139
+ # so it is not possible to use it more than once
140
+ ),
141
+ PerkDefinition(
142
+ perk_id="spotlight_seat",
143
+ name="Spotlight Seat",
144
+ description="Act last on the first hand of the next floor when permitted.",
145
+ trigger_timing=TriggerTiming.NEXT_FLOOR_START,
146
+ effect=_spotlight_seat_effect,
147
+ max_uses=1,
148
+ ),
149
+ PerkDefinition(
150
+ perk_id="stack_recovery",
151
+ name="Stack Recovery",
152
+ description="Recover 20% of your table stack once after an all-in loss.",
153
+ trigger_timing=TriggerTiming.ALL_IN_LOSS,
154
+ effect=_stack_recovery_effect,
155
+ max_uses=1,
156
+ ),
157
+ )
158
+
159
+ def get_perk(perk_id: str) -> PerkDefinition:
160
+ for perk in PERK_DEFINITIONS:
161
+ if perk.perk_id == perk_id:
162
+ return perk
163
+ raise ValueError(f"unknown perk id: {perk_id}")
164
+
165
+
166
+ def apply_buy_in_discount(amount: int) -> int:
167
+ return _buy_in_discount_effect(PerkEffectContext(amount=amount)).amount or 0
168
+
169
+
170
+ def apply_continue_waiver(continues: int) -> int:
171
+ result = _continue_waiver_effect(PerkEffectContext(continues=continues))
172
+ return result.continues if result.continues is not None else continues
173
+
174
+ def trigger_stack_recovery(table_stack: int, already_used: bool = False) -> tuple[int, bool]:
175
+ if already_used:
176
+ return 0, True
177
+ result = _stack_recovery_effect(PerkEffectContext(table_stack=table_stack))
178
+ return result.recovered_stack, result.consumed
telltale/models/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Local model, speech, and transcription runtimes for Telltale."""
2
+
3
+ from telltale.models.llama_runtime import (
4
+ DEFAULT_NEMOTRON_MODEL_NAME,
5
+ LocalTextRuntime,
6
+ RuntimeSettings,
7
+ )
8
+
9
+ __all__ = ["DEFAULT_NEMOTRON_MODEL_NAME", "LocalTextRuntime", "RuntimeSettings"]
telltale/models/eval_cli.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ from telltale.models.eval_prompts import (
9
+ COMPARISON_MODEL_CANDIDATES,
10
+ DEFAULT_MODEL_CANDIDATES,
11
+ EVAL_CASES_BY_ID,
12
+ MODEL_CANDIDATES_BY_LABEL,
13
+ AgentEvalCase,
14
+ ModelCandidate,
15
+ get_eval_case,
16
+ load_candidates_from_json,
17
+ )
18
+ from telltale.models.eval_runner import (
19
+ EvalRunConfig,
20
+ build_runtime_for_candidate,
21
+ evaluate_candidate,
22
+ write_eval_bundle,
23
+ )
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ parser = build_parser()
28
+ args = parser.parse_args(argv)
29
+
30
+ candidates = _candidate_pool(args.candidate_file)
31
+ if args.list_candidates:
32
+ _print_candidates(candidates)
33
+ return 0
34
+ if args.list_cases:
35
+ _print_cases(EVAL_CASES_BY_ID.values())
36
+ return 0
37
+
38
+ selected_candidates = _select_candidates(candidates, args.candidate)
39
+ selected_cases = _select_cases(args.case, args.case_set, args.max_cases)
40
+ profile_defaults = _profile_defaults(args.profile)
41
+ config = EvalRunConfig(
42
+ max_tokens=args.max_tokens if args.max_tokens is not None else profile_defaults["max_tokens"],
43
+ context_size=args.context_size,
44
+ temperature=args.temperature if args.temperature is not None else profile_defaults["temperature"],
45
+ seed=args.seed,
46
+ output_dir=args.output_dir,
47
+ hardware_profile=args.hardware_profile,
48
+ server_url=args.server_url,
49
+ n_gpu_layers=args.n_gpu_layers,
50
+ speech_max_words=args.speech_max_words
51
+ if args.speech_max_words is not None
52
+ else profile_defaults["speech_max_words"],
53
+ rationale_max_words=args.rationale_max_words
54
+ if args.rationale_max_words is not None
55
+ else profile_defaults["rationale_max_words"],
56
+ )
57
+
58
+ results_by_candidate = {}
59
+ for candidate in selected_candidates:
60
+ model_path = _model_path_for_candidate(candidate, args.model_path)
61
+ runtime = build_runtime_for_candidate(candidate, config, model_path=model_path)
62
+ results_by_candidate[candidate.label] = evaluate_candidate(
63
+ candidate,
64
+ runtime,
65
+ cases=selected_cases,
66
+ config=config,
67
+ )
68
+
69
+ bundle = write_eval_bundle(results_by_candidate, output_dir=args.output_dir)
70
+ print(json.dumps(bundle, ensure_ascii=True, indent=2))
71
+ return 0
72
+
73
+
74
+ def build_parser() -> argparse.ArgumentParser:
75
+ parser = argparse.ArgumentParser(description="Evaluate GGUF models on Telltale agent prompts.")
76
+ parser.add_argument(
77
+ "--candidate",
78
+ action="append",
79
+ help="Candidate label to evaluate. Repeat for multiple. Defaults to built-in defaults.",
80
+ )
81
+ parser.add_argument(
82
+ "--candidate-file",
83
+ help="JSON list/object of extra candidates with label, hf_repo_id, and gguf_filename.",
84
+ )
85
+ parser.add_argument(
86
+ "--case",
87
+ action="append",
88
+ help="Eval case id to run. Repeat for multiple.",
89
+ )
90
+ parser.add_argument(
91
+ "--case-set",
92
+ choices=["smoke"],
93
+ default="smoke",
94
+ help="Named case set to run when --case is omitted.",
95
+ )
96
+ parser.add_argument("--max-cases", type=int, default=None, help="Cap selected cases for budget control.")
97
+ parser.add_argument("--model-path", action="append", help="Map candidate to local GGUF: label=/path/model.gguf.")
98
+ parser.add_argument("--output-dir", default="runs/model_evals", help="Directory for JSONL and summary artifacts.")
99
+ parser.add_argument("--profile", choices=["default", "nemotron"], default="default")
100
+ parser.add_argument("--max-tokens", type=int, default=None)
101
+ parser.add_argument("--context-size", type=int, default=2048)
102
+ parser.add_argument("--temperature", type=float, default=None)
103
+ parser.add_argument("--speech-max-words", type=int, default=None)
104
+ parser.add_argument("--rationale-max-words", type=int, default=None)
105
+ parser.add_argument("--seed", type=int, default=17)
106
+ parser.add_argument("--hardware-profile", default="local")
107
+ parser.add_argument("--server-url", default="http://127.0.0.1:8080")
108
+ parser.add_argument("--n-gpu-layers", type=int, default=0)
109
+ parser.add_argument("--list-candidates", action="store_true")
110
+ parser.add_argument("--list-cases", action="store_true")
111
+ return parser
112
+
113
+
114
+ def _profile_defaults(profile: str) -> dict[str, float | int]:
115
+ if profile == "nemotron":
116
+ return {
117
+ "max_tokens": 520,
118
+ "temperature": 0.55,
119
+ "speech_max_words": 36,
120
+ "rationale_max_words": 44,
121
+ }
122
+ return {
123
+ "max_tokens": 320,
124
+ "temperature": 0.30,
125
+ "speech_max_words": 16,
126
+ "rationale_max_words": 24,
127
+ }
128
+
129
+
130
+ def _candidate_pool(candidate_file: str | None) -> dict[str, ModelCandidate]:
131
+ candidates = dict(MODEL_CANDIDATES_BY_LABEL)
132
+ if candidate_file:
133
+ for candidate in load_candidates_from_json(candidate_file):
134
+ candidates[candidate.label] = candidate
135
+ return candidates
136
+
137
+
138
+ def _select_candidates(
139
+ candidates: dict[str, ModelCandidate],
140
+ labels: list[str] | None,
141
+ ) -> tuple[ModelCandidate, ...]:
142
+ if not labels:
143
+ return DEFAULT_MODEL_CANDIDATES
144
+ if "all" in labels:
145
+ return COMPARISON_MODEL_CANDIDATES
146
+ selected = []
147
+ for label in labels:
148
+ if label not in candidates:
149
+ known = ", ".join(sorted(candidates))
150
+ raise SystemExit(f"unknown candidate {label!r}; known candidates: {known}")
151
+ selected.append(candidates[label])
152
+ return tuple(selected)
153
+
154
+
155
+ def _select_cases(
156
+ case_ids: list[str] | None,
157
+ case_set: str,
158
+ max_cases: int | None,
159
+ ) -> tuple[AgentEvalCase, ...]:
160
+ if case_ids:
161
+ cases = tuple(get_eval_case(case_id) for case_id in case_ids)
162
+ elif case_set == "smoke":
163
+ cases = tuple(EVAL_CASES_BY_ID.values())
164
+ else:
165
+ raise SystemExit(f"unknown case set: {case_set}")
166
+ if max_cases is not None:
167
+ if max_cases <= 0:
168
+ raise SystemExit("--max-cases must be positive")
169
+ cases = cases[:max_cases]
170
+ return cases
171
+
172
+
173
+ def _model_path_for_candidate(candidate: ModelCandidate, model_path_args: list[str] | None) -> str | None:
174
+ paths = _parse_model_paths(model_path_args or [])
175
+ return paths.get(candidate.label)
176
+
177
+
178
+ def _parse_model_paths(values: Iterable[str]) -> dict[str, str]:
179
+ paths: dict[str, str] = {}
180
+ for value in values:
181
+ if "=" not in value:
182
+ raise SystemExit("--model-path must use label=/path/model.gguf")
183
+ label, path = value.split("=", 1)
184
+ if not label or not path:
185
+ raise SystemExit("--model-path must use label=/path/model.gguf")
186
+ if not Path(path).exists():
187
+ raise SystemExit(f"local GGUF path does not exist for {label}: {path}")
188
+ paths[label] = path
189
+ return paths
190
+
191
+
192
+ def _print_candidates(candidates: dict[str, ModelCandidate]) -> None:
193
+ for candidate in sorted(candidates.values(), key=lambda item: item.label):
194
+ print(
195
+ json.dumps(
196
+ {
197
+ "label": candidate.label,
198
+ "hf_repo_id": candidate.hf_repo_id,
199
+ "gguf_filename": candidate.gguf_filename,
200
+ "quantization": candidate.quantization,
201
+ },
202
+ ensure_ascii=True,
203
+ )
204
+ )
205
+
206
+
207
+ def _print_cases(cases: Iterable[AgentEvalCase]) -> None:
208
+ for case in cases:
209
+ print(
210
+ json.dumps(
211
+ {
212
+ "case_id": case.case_id,
213
+ "agent_id": case.agent_id,
214
+ "legal_actions": [action.value for action in case.legal_actions],
215
+ "expected_pressure": case.expected_pressure,
216
+ },
217
+ ensure_ascii=True,
218
+ )
219
+ )
220
+
221
+
222
+ if __name__ == "__main__":
223
+ raise SystemExit(main())
telltale/models/eval_prompts.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from telltale.agents.dialogue import PlayerUtterance
7
+ from telltale.agents.memory import AgentMemory
8
+ from telltale.agents.profiles import get_agent_profile
9
+ from telltale.agents.prompts import build_agent_prompt
10
+ from telltale.game.holdem import ActionType
11
+ from telltale.poker.policy import PokerDecision
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ModelCandidate:
16
+ label: str
17
+ hf_repo_id: str
18
+ gguf_filename: str
19
+ quantization: str
20
+
21
+ @classmethod
22
+ def from_mapping(cls, data: dict[str, Any]) -> "ModelCandidate":
23
+ return cls(
24
+ label=str(data["label"]),
25
+ hf_repo_id=str(data["hf_repo_id"]),
26
+ gguf_filename=str(data["gguf_filename"]),
27
+ quantization=str(data.get("quantization") or ""),
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AgentEvalCase:
33
+ case_id: str
34
+ agent_id: str
35
+ memory_summary: str
36
+ public_state: dict[str, Any]
37
+ private_agent_state: dict[str, Any]
38
+ solver_recommendation: PokerDecision
39
+ player_utterance: PlayerUtterance | None
40
+ legal_actions: tuple[ActionType, ...]
41
+ expected_pressure: str
42
+
43
+ def build_prompt(self, *, speech_max_words: int = 28, rationale_max_words: int = 36) -> str:
44
+ profile = get_agent_profile(self.agent_id)
45
+ memory = AgentMemory.neutral(self.agent_id)
46
+ memory.summary = self.memory_summary
47
+ return build_agent_prompt(
48
+ profile,
49
+ memory,
50
+ self.public_state,
51
+ self.private_agent_state,
52
+ self.solver_recommendation,
53
+ self.player_utterance,
54
+ self.legal_actions,
55
+ speech_max_words=speech_max_words,
56
+ rationale_max_words=rationale_max_words,
57
+ )
58
+
59
+
60
+ NEMOTRON_3_NANO_4B_Q4_K_M = ModelCandidate(
61
+ label="nemotron_3_nano_4b_q4_k_m",
62
+ hf_repo_id="nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF",
63
+ gguf_filename="NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf",
64
+ quantization="Q4_K_M",
65
+ )
66
+
67
+ QWEN3_4B_Q4_K_M = ModelCandidate(
68
+ label="qwen3_4b_q4_k_m",
69
+ hf_repo_id="Qwen/Qwen3-4B-GGUF",
70
+ gguf_filename="Qwen3-4B-Q4_K_M.gguf",
71
+ quantization="Q4_K_M",
72
+ )
73
+
74
+ MINICPM41_8B_Q4_K_M = ModelCandidate(
75
+ label="minicpm41_8b_q4_k_m",
76
+ hf_repo_id="openbmb/MiniCPM4.1-8B-GGUF",
77
+ gguf_filename="MiniCPM4.1-8B-Q4_K_M.gguf",
78
+ quantization="Q4_K_M",
79
+ )
80
+
81
+ QWEN3_8B_Q4_K_M = ModelCandidate(
82
+ label="qwen3_8b_q4_k_m",
83
+ hf_repo_id="Qwen/Qwen3-8B-GGUF",
84
+ gguf_filename="Qwen3-8B-Q4_K_M.gguf",
85
+ quantization="Q4_K_M",
86
+ )
87
+
88
+
89
+ DEFAULT_MODEL_CANDIDATES: tuple[ModelCandidate, ...] = (NEMOTRON_3_NANO_4B_Q4_K_M,)
90
+ COMPARISON_MODEL_CANDIDATES: tuple[ModelCandidate, ...] = (
91
+ NEMOTRON_3_NANO_4B_Q4_K_M,
92
+ QWEN3_4B_Q4_K_M,
93
+ MINICPM41_8B_Q4_K_M,
94
+ QWEN3_8B_Q4_K_M,
95
+ )
96
+ MODEL_CANDIDATES_BY_LABEL: dict[str, ModelCandidate] = {
97
+ candidate.label: candidate for candidate in COMPARISON_MODEL_CANDIDATES
98
+ }
99
+
100
+
101
+ SMOKE_EVAL_CASES: tuple[AgentEvalCase, ...] = (
102
+ AgentEvalCase(
103
+ case_id="worm_needle_facing_call",
104
+ agent_id="worm",
105
+ memory_summary="Player needled Worm after winning a small pot and Worm has been talking faster since.",
106
+ public_state={
107
+ "street": "turn",
108
+ "board_cards": ["Ah", "7d", "7s", "2c"],
109
+ "pot": 140,
110
+ "amount_to_call": 45,
111
+ "stacks": {"player": 310, "worm": 220},
112
+ "recent_actions": ["player bet 45 into 140"],
113
+ },
114
+ private_agent_state={
115
+ "hole_cards": ["Ks", "Qs"],
116
+ "stack": 220,
117
+ "position": "button",
118
+ },
119
+ solver_recommendation=PokerDecision(
120
+ ActionType.FOLD,
121
+ amount=0,
122
+ equity=0.18,
123
+ pot_odds=0.24,
124
+ reason="equity trails pot odds and paired ace board favors the bettor",
125
+ ),
126
+ player_utterance=PlayerUtterance("You always pay me off when you're tilted.", target_agent_id="worm"),
127
+ legal_actions=(ActionType.FOLD, ActionType.CALL, ActionType.RAISE, ActionType.ALL_IN),
128
+ expected_pressure="Tests whether table talk can tempt a volatile agent without breaking legality.",
129
+ ),
130
+ AgentEvalCase(
131
+ case_id="molly_bluff_catcher",
132
+ agent_id="molly_bloom",
133
+ memory_summary="Player has shown one river bluff and one disciplined fold this floor.",
134
+ public_state={
135
+ "street": "river",
136
+ "board_cards": ["Kh", "Jd", "8d", "3s", "2c"],
137
+ "pot": 260,
138
+ "amount_to_call": 80,
139
+ "stacks": {"player": 520, "molly": 430},
140
+ "recent_actions": ["player bet 80 into 260"],
141
+ },
142
+ private_agent_state={
143
+ "hole_cards": ["Kc", "Tc"],
144
+ "stack": 430,
145
+ "position": "field",
146
+ },
147
+ solver_recommendation=PokerDecision(
148
+ ActionType.CALL,
149
+ amount=0,
150
+ equity=0.46,
151
+ pot_odds=0.235,
152
+ reason="top pair has enough showdown value for this price",
153
+ ),
154
+ player_utterance=PlayerUtterance("You know I would not fire river without it.", target_agent_id="molly_bloom"),
155
+ legal_actions=(ActionType.FOLD, ActionType.CALL, ActionType.RAISE, ActionType.ALL_IN),
156
+ expected_pressure="Tests charm/intimidation resistance and honest rationale.",
157
+ ),
158
+ AgentEvalCase(
159
+ case_id="teddy_boss_value_raise",
160
+ agent_id="teddy_kgb",
161
+ memory_summary="Player has repeated the same confident speech pattern before two bluffs.",
162
+ public_state={
163
+ "street": "flop",
164
+ "board_cards": ["Qh", "9h", "4s"],
165
+ "pot": 180,
166
+ "amount_to_call": 0,
167
+ "stacks": {"player": 650, "teddy": 700},
168
+ "recent_actions": ["player checked"],
169
+ },
170
+ private_agent_state={
171
+ "hole_cards": ["Qd", "Qs"],
172
+ "stack": 700,
173
+ "position": "button",
174
+ },
175
+ solver_recommendation=PokerDecision(
176
+ ActionType.BET,
177
+ amount=90,
178
+ equity=0.82,
179
+ pot_odds=0.0,
180
+ reason="monster equity on a wet board should build the pot",
181
+ ),
182
+ player_utterance=PlayerUtterance("Careful. I set traps too.", target_agent_id="teddy_kgb"),
183
+ legal_actions=(ActionType.CHECK, ActionType.BET, ActionType.ALL_IN),
184
+ expected_pressure="Tests boss voice, aggression, and legal bet amount JSON.",
185
+ ),
186
+ )
187
+
188
+ EVAL_CASES_BY_ID: dict[str, AgentEvalCase] = {case.case_id: case for case in SMOKE_EVAL_CASES}
189
+
190
+
191
+ def get_model_candidate(label: str) -> ModelCandidate:
192
+ try:
193
+ return MODEL_CANDIDATES_BY_LABEL[label]
194
+ except KeyError as error:
195
+ known = ", ".join(sorted(MODEL_CANDIDATES_BY_LABEL))
196
+ raise KeyError(f"unknown model candidate {label!r}; known candidates: {known}") from error
197
+
198
+
199
+ def get_eval_case(case_id: str) -> AgentEvalCase:
200
+ try:
201
+ return EVAL_CASES_BY_ID[case_id]
202
+ except KeyError as error:
203
+ known = ", ".join(sorted(EVAL_CASES_BY_ID))
204
+ raise KeyError(f"unknown eval case {case_id!r}; known cases: {known}") from error
205
+
206
+
207
+ def load_candidates_from_json(path: str) -> tuple[ModelCandidate, ...]:
208
+ import json
209
+ from pathlib import Path
210
+
211
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
212
+ if isinstance(data, dict):
213
+ data = data.get("candidates", [])
214
+ if not isinstance(data, list):
215
+ raise ValueError("candidate JSON must be a list or an object with a candidates list")
216
+ candidates = tuple(ModelCandidate.from_mapping(item) for item in data)
217
+ labels = [candidate.label for candidate in candidates]
218
+ if len(labels) != len(set(labels)):
219
+ raise ValueError("candidate labels must be unique")
220
+ return candidates
221
+
222
+
223
+ __all__ = [
224
+ "AgentEvalCase",
225
+ "COMPARISON_MODEL_CANDIDATES",
226
+ "DEFAULT_MODEL_CANDIDATES",
227
+ "EVAL_CASES_BY_ID",
228
+ "MODEL_CANDIDATES_BY_LABEL",
229
+ "ModelCandidate",
230
+ "MINICPM41_8B_Q4_K_M",
231
+ "NEMOTRON_3_NANO_4B_Q4_K_M",
232
+ "QWEN3_4B_Q4_K_M",
233
+ "QWEN3_8B_Q4_K_M",
234
+ "SMOKE_EVAL_CASES",
235
+ "get_eval_case",
236
+ "get_model_candidate",
237
+ "load_candidates_from_json",
238
+ ]
telltale/models/eval_runner.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import datetime, timezone
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+ import time
9
+ from typing import Protocol
10
+
11
+ from telltale.agents.decision import AgentDecision, parse_agent_decision, validate_and_repair
12
+ from telltale.game.holdem import ActionType
13
+ from telltale.models.eval_prompts import AgentEvalCase, ModelCandidate, SMOKE_EVAL_CASES
14
+ from telltale.models.llama_runtime import LlamaRuntimeConfig, LocalLlamaCppRuntime
15
+
16
+
17
+ class TextRuntime(Protocol):
18
+ def generate(
19
+ self,
20
+ prompt: str,
21
+ *,
22
+ max_tokens: int | None = None,
23
+ temperature: float | None = None,
24
+ seed: int | None = None,
25
+ ) -> str:
26
+ ...
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class EvalRunConfig:
31
+ context_size: int = 2048
32
+ max_tokens: int = 320
33
+ temperature: float = 0.30
34
+ seed: int | None = 17
35
+ output_dir: str = "runs/model_evals"
36
+ hardware_profile: str = "local"
37
+ server_url: str = "http://127.0.0.1:8080"
38
+ n_gpu_layers: int = 0
39
+ speech_max_words: int = 16
40
+ rationale_max_words: int = 24
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AgentEvalResult:
45
+ candidate_label: str
46
+ case_id: str
47
+ timestamp: str
48
+ prompt: str
49
+ raw_output: str
50
+ parsed_output: dict | None
51
+ final_decision: dict | None
52
+ json_valid: bool
53
+ legal_action_valid: bool
54
+ speech_length_valid: bool
55
+ memory_delta_valid: bool
56
+ schema_keys_valid: bool
57
+ unexpected_keys: list[str]
58
+ normalization_applied: bool
59
+ repair_applied: bool
60
+ repair_reason: str | None
61
+ error: str | None
62
+ latency_ms: float
63
+ runtime_metadata: dict
64
+
65
+ def to_json_line(self) -> str:
66
+ return json.dumps(asdict(self), ensure_ascii=True, sort_keys=True)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class CandidateEvalSummary:
71
+ candidate_label: str
72
+ total: int
73
+ json_valid: int
74
+ legal_action_valid: int
75
+ speech_length_valid: int
76
+ memory_delta_valid: int
77
+ schema_keys_valid: int
78
+ normalization_applied: int
79
+ repair_applied: int
80
+ errors: int
81
+ average_latency_ms: float
82
+ json_valid_rate: float
83
+ legal_action_valid_rate: float
84
+ speech_length_valid_rate: float
85
+ memory_delta_valid_rate: float
86
+ schema_keys_valid_rate: float
87
+ normalization_rate: float
88
+ repair_rate: float
89
+ error_rate: float
90
+ automatic_score: float
91
+
92
+ def to_dict(self) -> dict:
93
+ return asdict(self)
94
+
95
+
96
+ def build_runtime_for_candidate(
97
+ candidate: ModelCandidate,
98
+ config: EvalRunConfig,
99
+ *,
100
+ model_path: str | None = None,
101
+ ) -> LocalLlamaCppRuntime:
102
+ runtime_config = LlamaRuntimeConfig(
103
+ model_path=model_path,
104
+ repo_id=None if model_path else candidate.hf_repo_id,
105
+ filename=None if model_path else candidate.gguf_filename,
106
+ server_url=config.server_url,
107
+ context_size=config.context_size,
108
+ max_tokens=config.max_tokens,
109
+ temperature=config.temperature,
110
+ seed=config.seed,
111
+ n_gpu_layers=config.n_gpu_layers,
112
+ )
113
+ return LocalLlamaCppRuntime(runtime_config)
114
+
115
+
116
+ def evaluate_candidate(
117
+ candidate: ModelCandidate,
118
+ runtime: TextRuntime,
119
+ *,
120
+ cases: tuple[AgentEvalCase, ...] = SMOKE_EVAL_CASES,
121
+ config: EvalRunConfig | None = None,
122
+ ) -> list[AgentEvalResult]:
123
+ run_config = config or EvalRunConfig()
124
+ results: list[AgentEvalResult] = []
125
+ for case in cases:
126
+ results.append(evaluate_case(candidate, runtime, case, run_config))
127
+ return results
128
+
129
+
130
+ def evaluate_candidates(
131
+ candidates: tuple[ModelCandidate, ...],
132
+ runtime_factory,
133
+ *,
134
+ cases: tuple[AgentEvalCase, ...] = SMOKE_EVAL_CASES,
135
+ config: EvalRunConfig | None = None,
136
+ ) -> dict[str, list[AgentEvalResult]]:
137
+ run_config = config or EvalRunConfig()
138
+ results_by_candidate: dict[str, list[AgentEvalResult]] = {}
139
+ for candidate in candidates:
140
+ runtime = runtime_factory(candidate, run_config)
141
+ results_by_candidate[candidate.label] = evaluate_candidate(
142
+ candidate,
143
+ runtime,
144
+ cases=cases,
145
+ config=run_config,
146
+ )
147
+ return results_by_candidate
148
+
149
+
150
+ def evaluate_case(
151
+ candidate: ModelCandidate,
152
+ runtime: TextRuntime,
153
+ case: AgentEvalCase,
154
+ config: EvalRunConfig,
155
+ ) -> AgentEvalResult:
156
+ prompt = case.build_prompt(
157
+ speech_max_words=config.speech_max_words,
158
+ rationale_max_words=config.rationale_max_words,
159
+ )
160
+ started = time.perf_counter()
161
+ raw_output = ""
162
+ parsed: AgentDecision | None = None
163
+ repaired: AgentDecision | None = None
164
+ json_valid = False
165
+ legal_action_valid = False
166
+ repair_applied = False
167
+ normalization_applied = False
168
+ repair_reason: str | None = None
169
+ error: str | None = None
170
+ speech_length_valid = False
171
+ memory_delta_valid = False
172
+ schema_keys_valid = False
173
+ unexpected_keys: list[str] = []
174
+ try:
175
+ raw_output = runtime.generate(
176
+ prompt,
177
+ max_tokens=config.max_tokens,
178
+ temperature=config.temperature,
179
+ seed=config.seed,
180
+ )
181
+ parsed = parse_agent_decision(raw_output)
182
+ json_valid = True
183
+ unexpected_keys = _unexpected_output_keys(raw_output)
184
+ schema_keys_valid = not unexpected_keys
185
+ speech_length_valid = _speech_length_is_valid(parsed.speech, config.speech_max_words)
186
+ memory_delta_valid = True
187
+ parsed_action = parsed.action.value if isinstance(parsed.action, ActionType) else str(parsed.action)
188
+ legal_values = {action.value for action in case.legal_actions}
189
+ legal_action_valid = parsed_action in legal_values
190
+ original = parsed.to_dict()
191
+ repaired = validate_and_repair(parsed, case.legal_actions, case.solver_recommendation)
192
+ repaired_dict = repaired.to_dict()
193
+ normalization_applied = _normalization_only(original, repaired_dict)
194
+ repair_applied = original != repaired_dict and not normalization_applied
195
+ if repair_applied:
196
+ repair_reason = "model output required legal action or amount repair"
197
+ except Exception as exc: # noqa: BLE001 - eval records failures instead of hiding them
198
+ error = f"{type(exc).__name__}: {exc}"
199
+ latency_ms = (time.perf_counter() - started) * 1000
200
+ generation_metadata = getattr(runtime, "last_generation_metadata", None)
201
+ return AgentEvalResult(
202
+ candidate_label=candidate.label,
203
+ case_id=case.case_id,
204
+ timestamp=datetime.now(timezone.utc).isoformat(),
205
+ prompt=prompt,
206
+ raw_output=raw_output,
207
+ parsed_output=parsed.to_dict() if parsed else None,
208
+ final_decision=repaired.to_dict() if repaired else None,
209
+ json_valid=json_valid,
210
+ legal_action_valid=legal_action_valid,
211
+ speech_length_valid=speech_length_valid,
212
+ memory_delta_valid=memory_delta_valid,
213
+ schema_keys_valid=schema_keys_valid,
214
+ unexpected_keys=unexpected_keys,
215
+ normalization_applied=normalization_applied,
216
+ repair_applied=repair_applied,
217
+ repair_reason=repair_reason,
218
+ error=error,
219
+ latency_ms=latency_ms,
220
+ runtime_metadata={
221
+ "hf_repo_id": candidate.hf_repo_id,
222
+ "gguf_filename": candidate.gguf_filename,
223
+ "quantization": candidate.quantization,
224
+ "hardware_profile": config.hardware_profile,
225
+ "max_tokens": config.max_tokens,
226
+ "temperature": config.temperature,
227
+ "seed": config.seed,
228
+ "speech_max_words": config.speech_max_words,
229
+ "rationale_max_words": config.rationale_max_words,
230
+ "generation": generation_metadata if isinstance(generation_metadata, dict) else {},
231
+ },
232
+ )
233
+
234
+
235
+ def write_eval_results(
236
+ results: list[AgentEvalResult],
237
+ candidate: ModelCandidate,
238
+ config: EvalRunConfig,
239
+ ) -> Path:
240
+ output_dir = Path(config.output_dir)
241
+ output_dir.mkdir(parents=True, exist_ok=True)
242
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
243
+ path = output_dir / f"{timestamp}_{candidate.label}.jsonl"
244
+ with path.open("w", encoding="utf-8") as handle:
245
+ for result in results:
246
+ handle.write(result.to_json_line())
247
+ handle.write("\n")
248
+ return path
249
+
250
+
251
+ def write_eval_bundle(
252
+ results_by_candidate: dict[str, list[AgentEvalResult]],
253
+ *,
254
+ output_dir: str,
255
+ ) -> dict[str, str | dict]:
256
+ root = Path(output_dir)
257
+ root.mkdir(parents=True, exist_ok=True)
258
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
259
+ run_dir = root / timestamp
260
+ run_dir.mkdir()
261
+
262
+ result_paths: dict[str, str] = {}
263
+ summaries: dict[str, dict] = {}
264
+ for label, results in results_by_candidate.items():
265
+ path = run_dir / f"{label}.jsonl"
266
+ with path.open("w", encoding="utf-8") as handle:
267
+ for result in results:
268
+ handle.write(result.to_json_line())
269
+ handle.write("\n")
270
+ result_paths[label] = str(path)
271
+ summaries[label] = summarize_candidate_results(label, results).to_dict()
272
+
273
+ ranking = sorted(
274
+ summaries.values(),
275
+ key=lambda item: (-float(item["automatic_score"]), float(item["average_latency_ms"])),
276
+ )
277
+ summary_path = run_dir / "summary.json"
278
+ summary_payload = {
279
+ "created_at": datetime.now(timezone.utc).isoformat(),
280
+ "result_paths": result_paths,
281
+ "summaries": summaries,
282
+ "ranking": ranking,
283
+ }
284
+ summary_path.write_text(json.dumps(summary_payload, ensure_ascii=True, indent=2), encoding="utf-8")
285
+ return {
286
+ "run_dir": str(run_dir),
287
+ "summary_path": str(summary_path),
288
+ "result_paths": result_paths,
289
+ "summaries": summaries,
290
+ "ranking": ranking,
291
+ }
292
+
293
+
294
+ def summarize_results(results: list[AgentEvalResult]) -> dict[str, float | int]:
295
+ total = len(results)
296
+ if total == 0:
297
+ return {
298
+ "total": 0,
299
+ "json_valid": 0,
300
+ "legal_action_valid": 0,
301
+ "speech_length_valid": 0,
302
+ "memory_delta_valid": 0,
303
+ "schema_keys_valid": 0,
304
+ "normalization_applied": 0,
305
+ "repair_applied": 0,
306
+ "errors": 0,
307
+ "average_latency_ms": 0.0,
308
+ }
309
+ return {
310
+ "total": total,
311
+ "json_valid": sum(result.json_valid for result in results),
312
+ "legal_action_valid": sum(result.legal_action_valid for result in results),
313
+ "speech_length_valid": sum(result.speech_length_valid for result in results),
314
+ "memory_delta_valid": sum(result.memory_delta_valid for result in results),
315
+ "schema_keys_valid": sum(result.schema_keys_valid for result in results),
316
+ "normalization_applied": sum(result.normalization_applied for result in results),
317
+ "repair_applied": sum(result.repair_applied for result in results),
318
+ "errors": sum(result.error is not None for result in results),
319
+ "average_latency_ms": sum(result.latency_ms for result in results) / total,
320
+ }
321
+
322
+
323
+ def summarize_candidate_results(candidate_label: str, results: list[AgentEvalResult]) -> CandidateEvalSummary:
324
+ total = len(results)
325
+ if total == 0:
326
+ return CandidateEvalSummary(
327
+ candidate_label=candidate_label,
328
+ total=0,
329
+ json_valid=0,
330
+ legal_action_valid=0,
331
+ speech_length_valid=0,
332
+ memory_delta_valid=0,
333
+ schema_keys_valid=0,
334
+ normalization_applied=0,
335
+ repair_applied=0,
336
+ errors=0,
337
+ average_latency_ms=0.0,
338
+ json_valid_rate=0.0,
339
+ legal_action_valid_rate=0.0,
340
+ speech_length_valid_rate=0.0,
341
+ memory_delta_valid_rate=0.0,
342
+ schema_keys_valid_rate=0.0,
343
+ normalization_rate=0.0,
344
+ repair_rate=0.0,
345
+ error_rate=1.0,
346
+ automatic_score=0.0,
347
+ )
348
+
349
+ json_valid = sum(result.json_valid for result in results)
350
+ legal_action_valid = sum(result.legal_action_valid for result in results)
351
+ speech_length_valid = sum(result.speech_length_valid for result in results)
352
+ memory_delta_valid = sum(result.memory_delta_valid for result in results)
353
+ schema_keys_valid = sum(result.schema_keys_valid for result in results)
354
+ normalization_applied = sum(result.normalization_applied for result in results)
355
+ repair_applied = sum(result.repair_applied for result in results)
356
+ errors = sum(result.error is not None for result in results)
357
+ average_latency_ms = sum(result.latency_ms for result in results) / total
358
+ json_rate = json_valid / total
359
+ legal_rate = legal_action_valid / total
360
+ speech_rate = speech_length_valid / total
361
+ memory_rate = memory_delta_valid / total
362
+ schema_rate = schema_keys_valid / total
363
+ normalization_rate = normalization_applied / total
364
+ repair_rate = repair_applied / total
365
+ error_rate = errors / total
366
+ latency_score = _latency_score(average_latency_ms)
367
+ automatic_score = (
368
+ 0.31 * json_rate
369
+ + 0.22 * legal_rate
370
+ + 0.13 * speech_rate
371
+ + 0.10 * memory_rate
372
+ + 0.04 * schema_rate
373
+ + 0.10 * (1.0 - repair_rate)
374
+ + 0.06 * (1.0 - error_rate)
375
+ + 0.04 * latency_score
376
+ )
377
+ return CandidateEvalSummary(
378
+ candidate_label=candidate_label,
379
+ total=total,
380
+ json_valid=json_valid,
381
+ legal_action_valid=legal_action_valid,
382
+ speech_length_valid=speech_length_valid,
383
+ memory_delta_valid=memory_delta_valid,
384
+ schema_keys_valid=schema_keys_valid,
385
+ normalization_applied=normalization_applied,
386
+ repair_applied=repair_applied,
387
+ errors=errors,
388
+ average_latency_ms=average_latency_ms,
389
+ json_valid_rate=json_rate,
390
+ legal_action_valid_rate=legal_rate,
391
+ speech_length_valid_rate=speech_rate,
392
+ memory_delta_valid_rate=memory_rate,
393
+ schema_keys_valid_rate=schema_rate,
394
+ normalization_rate=normalization_rate,
395
+ repair_rate=repair_rate,
396
+ error_rate=error_rate,
397
+ automatic_score=automatic_score,
398
+ )
399
+
400
+
401
+ def _speech_length_is_valid(speech: str, max_words: int) -> bool:
402
+ word_count = len(speech.split())
403
+ return 1 <= word_count <= max_words
404
+
405
+
406
+ def _latency_score(average_latency_ms: float) -> float:
407
+ if average_latency_ms <= 1_000:
408
+ return 1.0
409
+ if average_latency_ms >= 12_000:
410
+ return 0.0
411
+ return (12_000 - average_latency_ms) / 11_000
412
+
413
+
414
+ def _normalization_only(original: dict, repaired: dict) -> bool:
415
+ if original == repaired:
416
+ return False
417
+ ignored_fields = {"amount"}
418
+ for key, original_value in original.items():
419
+ if key in ignored_fields:
420
+ continue
421
+ if repaired.get(key) != original_value:
422
+ return False
423
+ return set(repaired) == set(original)
424
+
425
+
426
+ def _unexpected_output_keys(raw_output: str) -> list[str]:
427
+ allowed = {"action", "amount", "speech", "honest_rationale", "emotional_state", "memory_delta", "memory_updates"}
428
+ try:
429
+ data = json.loads(raw_output)
430
+ except json.JSONDecodeError:
431
+ match = re.search(r"\{.*\}", raw_output, flags=re.DOTALL)
432
+ if not match:
433
+ return []
434
+ try:
435
+ data = json.loads(match.group(0))
436
+ except json.JSONDecodeError:
437
+ return []
438
+ if not isinstance(data, dict):
439
+ return []
440
+ return sorted(str(key) for key in data if str(key) not in allowed)
441
+
442
+
443
+ __all__ = [
444
+ "AgentEvalResult",
445
+ "CandidateEvalSummary",
446
+ "EvalRunConfig",
447
+ "TextRuntime",
448
+ "build_runtime_for_candidate",
449
+ "evaluate_candidate",
450
+ "evaluate_candidates",
451
+ "evaluate_case",
452
+ "summarize_candidate_results",
453
+ "summarize_results",
454
+ "write_eval_bundle",
455
+ "write_eval_results",
456
+ ]
telltale/models/llama_runtime.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ import os
6
+ import time
7
+ from typing import Any, Callable
8
+ from urllib import error, request
9
+
10
+
11
+ DEFAULT_NEMOTRON_MODEL_NAME = "NVIDIA Nemotron 3 Nano 4B GGUF"
12
+ DEFAULT_NEMOTRON_GGUF_REPO = "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF"
13
+ DEFAULT_NEMOTRON_GGUF_FILE = "NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf"
14
+ DEFAULT_ZERO_GPU_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
15
+ DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080"
16
+
17
+ try:
18
+ import spaces
19
+ except ImportError: # pragma: no cover - local dev and tests usually do not install spaces
20
+ spaces = None
21
+
22
+
23
+ class RuntimeConfigurationError(RuntimeError):
24
+ """Raised when a local model runtime is requested without required config."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RuntimeSettings:
29
+ mode: str = "mock"
30
+ model_name: str = DEFAULT_NEMOTRON_MODEL_NAME
31
+ gguf_path: str | None = None
32
+ model_repo: str = DEFAULT_NEMOTRON_GGUF_REPO
33
+ model_file: str = DEFAULT_NEMOTRON_GGUF_FILE
34
+ zero_gpu_model_id: str = DEFAULT_ZERO_GPU_MODEL_ID
35
+ server_url: str = DEFAULT_LLAMA_SERVER_URL
36
+ model_alias: str = "telltale-agent"
37
+ context_size: int = 4096
38
+ max_tokens: int = 220
39
+ temperature: float = 0.65
40
+ seed: int | None = None
41
+ request_timeout_seconds: float = 120.0
42
+
43
+ @classmethod
44
+ def from_env(cls) -> "RuntimeSettings":
45
+ seed_text = os.getenv("TELLTALE_SEED")
46
+ default_mode = "zero_gpu" if os.getenv("SPACE_ID") else "mock"
47
+ return cls(
48
+ mode=_normalize_mode(os.getenv("TELLTALE_MODEL_MODE", default_mode)),
49
+ model_name=os.getenv("TELLTALE_MODEL_NAME", DEFAULT_NEMOTRON_MODEL_NAME),
50
+ gguf_path=os.getenv("TELLTALE_GGUF_PATH") or None,
51
+ model_repo=os.getenv("TELLTALE_MODEL_REPO", DEFAULT_NEMOTRON_GGUF_REPO),
52
+ model_file=os.getenv("TELLTALE_MODEL_FILE", DEFAULT_NEMOTRON_GGUF_FILE),
53
+ zero_gpu_model_id=os.getenv("TELLTALE_ZERO_GPU_MODEL_ID", DEFAULT_ZERO_GPU_MODEL_ID),
54
+ server_url=os.getenv("TELLTALE_LLAMA_SERVER_URL", DEFAULT_LLAMA_SERVER_URL),
55
+ model_alias=os.getenv("TELLTALE_MODEL_ALIAS", "telltale-agent"),
56
+ context_size=int(os.getenv("TELLTALE_CONTEXT_SIZE", "4096")),
57
+ max_tokens=int(os.getenv("TELLTALE_MAX_TOKENS", "220")),
58
+ temperature=float(os.getenv("TELLTALE_TEMPERATURE", "0.65")),
59
+ seed=int(seed_text) if seed_text else None,
60
+ request_timeout_seconds=float(os.getenv("TELLTALE_REQUEST_TIMEOUT_SECONDS", "120")),
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class LlamaRuntimeConfig:
66
+ model_path: str | None = None
67
+ repo_id: str | None = None
68
+ filename: str | None = None
69
+ zero_gpu_model_id: str = DEFAULT_ZERO_GPU_MODEL_ID
70
+ server_url: str = DEFAULT_LLAMA_SERVER_URL
71
+ model_alias: str = "telltale-agent"
72
+ context_size: int = 4096
73
+ max_tokens: int = 220
74
+ temperature: float = 0.65
75
+ seed: int | None = None
76
+ n_gpu_layers: int = 0
77
+ model_name: str = DEFAULT_NEMOTRON_MODEL_NAME
78
+ verbose: bool = False
79
+ request_timeout_seconds: float = 120.0
80
+
81
+ def to_runtime_settings(self) -> RuntimeSettings:
82
+ return RuntimeSettings(
83
+ mode="llama_server",
84
+ model_name=self.model_name,
85
+ gguf_path=self.model_path,
86
+ model_repo=self.repo_id or DEFAULT_NEMOTRON_GGUF_REPO,
87
+ model_file=self.filename or DEFAULT_NEMOTRON_GGUF_FILE,
88
+ zero_gpu_model_id=self.zero_gpu_model_id,
89
+ server_url=self.server_url,
90
+ model_alias=self.model_alias,
91
+ context_size=self.context_size,
92
+ max_tokens=self.max_tokens,
93
+ temperature=self.temperature,
94
+ seed=self.seed,
95
+ request_timeout_seconds=self.request_timeout_seconds,
96
+ )
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class GenerationResult:
101
+ text: str
102
+ latency_ms: int
103
+ tokens_per_second: float | None
104
+ metadata: dict[str, Any]
105
+
106
+
107
+ def _first_choice(output: dict[str, Any]) -> dict[str, Any]:
108
+ choices = output.get("choices") or []
109
+ if not choices or not isinstance(choices[0], dict):
110
+ return {}
111
+ return choices[0]
112
+
113
+
114
+ def _choice_text(output: dict[str, Any]) -> str:
115
+ choice = _first_choice(output)
116
+ message = choice.get("message")
117
+ if isinstance(message, dict):
118
+ return str(message.get("content") or "")
119
+ return str(choice.get("text") or choice.get("content") or "")
120
+
121
+
122
+ def _generation_metadata(output: dict[str, Any]) -> dict[str, Any]:
123
+ choice = _first_choice(output)
124
+ text = _choice_text(output)
125
+ metadata: dict[str, Any] = {
126
+ "finish_reason": choice.get("finish_reason"),
127
+ "raw_text_length": len(text),
128
+ "raw_text_stripped_length": len(text.strip()),
129
+ "raw_text_repr": repr(text[:240]),
130
+ }
131
+ usage = output.get("usage")
132
+ if isinstance(usage, dict):
133
+ metadata["usage"] = usage
134
+ return metadata
135
+
136
+
137
+ class LocalTextRuntime:
138
+ """Text runtime backed by mock responses or an actual llama.cpp HTTP server."""
139
+
140
+ def __init__(self, settings: RuntimeSettings | None = None):
141
+ raw_settings = settings or RuntimeSettings.from_env()
142
+ self.settings = RuntimeSettings(
143
+ mode=_normalize_mode(raw_settings.mode),
144
+ model_name=raw_settings.model_name,
145
+ gguf_path=raw_settings.gguf_path,
146
+ model_repo=raw_settings.model_repo,
147
+ model_file=raw_settings.model_file,
148
+ zero_gpu_model_id=raw_settings.zero_gpu_model_id,
149
+ server_url=raw_settings.server_url.rstrip("/"),
150
+ model_alias=raw_settings.model_alias,
151
+ context_size=raw_settings.context_size,
152
+ max_tokens=raw_settings.max_tokens,
153
+ temperature=raw_settings.temperature,
154
+ seed=raw_settings.seed,
155
+ request_timeout_seconds=raw_settings.request_timeout_seconds,
156
+ )
157
+ self.last_generation_metadata: dict[str, Any] = {}
158
+ if self.settings.mode not in {"mock", "llama_server", "zero_gpu"}:
159
+ raise RuntimeConfigurationError(
160
+ "TELLTALE_MODEL_MODE must be 'mock', 'llama_server', or 'zero_gpu'."
161
+ )
162
+ if self.settings.mode == "llama_server":
163
+ self._ensure_server_is_reachable()
164
+ self._zero_gpu_runtime: TransformersZeroGPURuntime | None = None
165
+
166
+ @property
167
+ def mode(self) -> str:
168
+ return self.settings.mode
169
+
170
+ def generate(
171
+ self,
172
+ prompt: str,
173
+ *,
174
+ max_tokens: int | None = None,
175
+ temperature: float | None = None,
176
+ seed: int | None = None,
177
+ context: dict[str, Any] | None = None,
178
+ ) -> GenerationResult:
179
+ started = time.perf_counter()
180
+ if self.settings.mode == "mock":
181
+ text = self._mock_response(prompt, context or {})
182
+ latency_ms = int((time.perf_counter() - started) * 1000)
183
+ return GenerationResult(text=text, latency_ms=latency_ms, tokens_per_second=None, metadata=self.metadata())
184
+
185
+ if self.settings.mode == "zero_gpu":
186
+ runtime = self._get_zero_gpu_runtime()
187
+ text = runtime.generate(
188
+ prompt,
189
+ max_tokens=max_tokens or self.settings.max_tokens,
190
+ temperature=temperature if temperature is not None else self.settings.temperature,
191
+ seed=seed if seed is not None else self.settings.seed,
192
+ )
193
+ latency_ms = int((time.perf_counter() - started) * 1000)
194
+ token_count = len(text.split())
195
+ tps = (token_count / (latency_ms / 1000)) if latency_ms > 0 else None
196
+ self.last_generation_metadata = {
197
+ "raw_text_length": len(text),
198
+ "raw_text_stripped_length": len(text.strip()),
199
+ "raw_text_repr": repr(text[:240]),
200
+ }
201
+ return GenerationResult(text=text, latency_ms=latency_ms, tokens_per_second=tps, metadata=self.metadata())
202
+
203
+ output = self._post_json(
204
+ "/v1/chat/completions",
205
+ {
206
+ "model": self.settings.model_alias,
207
+ "messages": [{"role": "user", "content": prompt}],
208
+ "max_tokens": max_tokens or self.settings.max_tokens,
209
+ "temperature": temperature if temperature is not None else self.settings.temperature,
210
+ "seed": seed if seed is not None else self.settings.seed,
211
+ "response_format": {"type": "json_object"},
212
+ },
213
+ )
214
+ self.last_generation_metadata = _generation_metadata(output)
215
+ text = _choice_text(output)
216
+ latency_ms = int((time.perf_counter() - started) * 1000)
217
+ token_count = len(text.split())
218
+ tps = (token_count / (latency_ms / 1000)) if latency_ms > 0 else None
219
+ return GenerationResult(text=text, latency_ms=latency_ms, tokens_per_second=tps, metadata=self.metadata())
220
+
221
+ def metadata(self) -> dict[str, Any]:
222
+ return {
223
+ "model_name": self.settings.model_name,
224
+ "gguf_path": self.settings.gguf_path,
225
+ "model_repo": self.settings.model_repo,
226
+ "model_file": self.settings.model_file,
227
+ "zero_gpu_model_id": self.settings.zero_gpu_model_id,
228
+ "server_url": self.settings.server_url,
229
+ "model_alias": self.settings.model_alias,
230
+ "runtime_backend": self.settings.mode,
231
+ "hardware_profile": os.getenv("TELLTALE_HARDWARE_PROFILE", "local_cpu"),
232
+ }
233
+
234
+ def _get_zero_gpu_runtime(self) -> "TransformersZeroGPURuntime":
235
+ if self._zero_gpu_runtime is None:
236
+ self._zero_gpu_runtime = TransformersZeroGPURuntime(
237
+ model_id=self.settings.zero_gpu_model_id,
238
+ model_name=self.settings.model_name,
239
+ )
240
+ return self._zero_gpu_runtime
241
+
242
+ def _ensure_server_is_reachable(self) -> None:
243
+ try:
244
+ self._get_json("/v1/models")
245
+ except RuntimeConfigurationError:
246
+ raise
247
+ except Exception as exc: # noqa: BLE001 - wrap connection failures cleanly
248
+ raise RuntimeConfigurationError(
249
+ "TELLTALE_MODEL_MODE=llama_server requires a reachable llama.cpp "
250
+ f"server at {self.settings.server_url}. Start llama-server first or set "
251
+ "TELLTALE_LLAMA_SERVER_URL."
252
+ ) from exc
253
+
254
+ def _get_json(self, path: str) -> dict[str, Any]:
255
+ url = f"{self.settings.server_url}{path}"
256
+ http_request = request.Request(url, method="GET")
257
+ try:
258
+ with request.urlopen(http_request, timeout=self.settings.request_timeout_seconds) as response:
259
+ return json.loads(response.read().decode("utf-8"))
260
+ except error.URLError as exc:
261
+ raise RuntimeConfigurationError(f"llama-server request failed: {exc}") from exc
262
+ except json.JSONDecodeError as exc:
263
+ raise RuntimeConfigurationError("llama-server returned invalid JSON.") from exc
264
+
265
+ def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
266
+ url = f"{self.settings.server_url}{path}"
267
+ body = json.dumps({key: value for key, value in payload.items() if value is not None}).encode("utf-8")
268
+ http_request = request.Request(
269
+ url,
270
+ data=body,
271
+ headers={"Content-Type": "application/json"},
272
+ method="POST",
273
+ )
274
+ try:
275
+ with request.urlopen(http_request, timeout=self.settings.request_timeout_seconds) as response:
276
+ return json.loads(response.read().decode("utf-8"))
277
+ except error.HTTPError as exc:
278
+ detail = exc.read().decode("utf-8", errors="replace")
279
+ raise RuntimeConfigurationError(f"llama-server generation failed: {exc.code} {detail}") from exc
280
+ except error.URLError as exc:
281
+ raise RuntimeConfigurationError(f"llama-server generation failed: {exc}") from exc
282
+ except json.JSONDecodeError as exc:
283
+ raise RuntimeConfigurationError("llama-server returned invalid generation JSON.") from exc
284
+
285
+ def _mock_response(self, prompt: str, context: dict[str, Any]) -> str:
286
+ action = str(context.get("suggested_action") or "check")
287
+ amount = int(context.get("suggested_amount") or 0)
288
+ agent_name = str(context.get("agent_name") or "The regular")
289
+ player_utterance = str(context.get("player_utterance") or "").strip()
290
+ if player_utterance:
291
+ speech = f"{agent_name}: I heard that. The chips still decide."
292
+ summary = f"Player used table talk: {player_utterance[:80]}"
293
+ else:
294
+ speech = f"{agent_name}: I will take the clean line."
295
+ summary = "Played the solver-informed line without extra table pressure."
296
+ return json.dumps(
297
+ {
298
+ "action": action,
299
+ "amount": amount,
300
+ "speech": speech,
301
+ "honest_rationale": "The recommendation fits the current price and stack pressure.",
302
+ "emotional_state": "focused",
303
+ "memory_delta": {
304
+ "summary": summary,
305
+ "recent_dialogue_impressions": [player_utterance] if player_utterance else [],
306
+ },
307
+ }
308
+ )
309
+
310
+
311
+ class LocalLlamaCppRuntime:
312
+ """Compatibility wrapper for evals, backed by actual llama-server HTTP calls."""
313
+
314
+ def __init__(self, config: LlamaRuntimeConfig):
315
+ self.config = config
316
+ self.runtime = LocalTextRuntime(config.to_runtime_settings())
317
+ self.last_generation_metadata: dict[str, Any] = {}
318
+
319
+ def generate(
320
+ self,
321
+ prompt: str,
322
+ *,
323
+ max_tokens: int | None = None,
324
+ temperature: float | None = None,
325
+ seed: int | None = None,
326
+ ) -> str:
327
+ result = self.runtime.generate(
328
+ prompt,
329
+ max_tokens=max_tokens or self.config.max_tokens,
330
+ temperature=temperature if temperature is not None else self.config.temperature,
331
+ seed=seed if seed is not None else self.config.seed,
332
+ )
333
+ self.last_generation_metadata = result.metadata
334
+ return result.text
335
+
336
+
337
+ def _normalize_mode(mode: str) -> str:
338
+ if mode == "llama_cpp":
339
+ return "llama_server"
340
+ return mode
341
+
342
+
343
+ def _gpu_decorator() -> Callable:
344
+ if spaces is None:
345
+ return lambda fn: fn
346
+ return spaces.GPU(duration=120)
347
+
348
+
349
+ class TransformersZeroGPURuntime:
350
+ """ZeroGPU-compatible text runtime using Transformers inside a Gradio Space."""
351
+
352
+ _model = None
353
+ _tokenizer = None
354
+
355
+ def __init__(self, *, model_id: str, model_name: str):
356
+ self.model_id = model_id
357
+ self.model_name = model_name
358
+
359
+ @_gpu_decorator()
360
+ def generate(
361
+ self,
362
+ prompt: str,
363
+ *,
364
+ max_tokens: int,
365
+ temperature: float,
366
+ seed: int | None,
367
+ ) -> str:
368
+ if seed is not None:
369
+ try:
370
+ import torch
371
+
372
+ torch.manual_seed(seed)
373
+ except ImportError:
374
+ pass
375
+ model, tokenizer = self._load_model()
376
+ messages = [
377
+ {
378
+ "role": "system",
379
+ "content": "Return only the requested compact JSON object. Do not include hidden reasoning.",
380
+ },
381
+ {"role": "user", "content": prompt},
382
+ ]
383
+ try:
384
+ inputs = tokenizer.apply_chat_template(
385
+ messages,
386
+ tokenize=True,
387
+ enable_thinking=False,
388
+ add_generation_prompt=True,
389
+ return_tensors="pt",
390
+ ).to(model.device)
391
+ except TypeError:
392
+ inputs = tokenizer.apply_chat_template(
393
+ messages,
394
+ tokenize=True,
395
+ add_generation_prompt=True,
396
+ return_tensors="pt",
397
+ ).to(model.device)
398
+ output_ids = model.generate(
399
+ inputs,
400
+ max_new_tokens=max_tokens,
401
+ temperature=temperature,
402
+ do_sample=temperature > 0,
403
+ eos_token_id=tokenizer.eos_token_id,
404
+ )
405
+ generated = output_ids[0][inputs.shape[-1] :]
406
+ return str(tokenizer.decode(generated, skip_special_tokens=True)).strip()
407
+
408
+ def _load_model(self):
409
+ if TransformersZeroGPURuntime._model is not None and TransformersZeroGPURuntime._tokenizer is not None:
410
+ return TransformersZeroGPURuntime._model, TransformersZeroGPURuntime._tokenizer
411
+ try:
412
+ import torch
413
+ from transformers import AutoModelForCausalLM, AutoTokenizer
414
+ except ImportError as exc:
415
+ raise RuntimeConfigurationError(
416
+ "TELLTALE_MODEL_MODE=zero_gpu requires transformers, torch, and spaces."
417
+ ) from exc
418
+ tokenizer = AutoTokenizer.from_pretrained(self.model_id, trust_remote_code=True)
419
+ model = AutoModelForCausalLM.from_pretrained(
420
+ self.model_id,
421
+ torch_dtype=torch.bfloat16,
422
+ trust_remote_code=True,
423
+ device_map="auto",
424
+ )
425
+ TransformersZeroGPURuntime._tokenizer = tokenizer
426
+ TransformersZeroGPURuntime._model = model
427
+ return model, tokenizer
telltale/models/stt.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class STTResult:
8
+ text: str = ""
9
+ confidence: float | None = None
10
+ disabled: bool = True
11
+ error: str | None = None
12
+
13
+
14
+ class STTRuntime:
15
+ """Optional local speech-to-text wrapper. Typed table talk remains primary."""
16
+
17
+ def __init__(self, enabled: bool = False):
18
+ self.enabled = enabled
19
+
20
+ def transcribe(self, audio: bytes | str | None) -> STTResult:
21
+ if not self.enabled:
22
+ return STTResult(disabled=True, error="speech input is disabled")
23
+ if not audio:
24
+ return STTResult(disabled=False, error="empty audio")
25
+ return STTResult(disabled=False, error="local STT runtime is not configured")
telltale/models/tts.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class TTSResult:
10
+ text: str
11
+ voice_id: str
12
+ audio_path: str | None = None
13
+ mime_type: str | None = None
14
+ duration_seconds: float | None = None
15
+ cache_hit: bool = False
16
+ disabled: bool = True
17
+ error: str | None = None
18
+
19
+
20
+ class TTSRuntime:
21
+ """Text-first TTS wrapper. Local synthesis can be plugged in without affecting gameplay."""
22
+
23
+ def __init__(self, enabled: bool = False, cache_dir: str | Path = "runs/tts_cache"):
24
+ self.enabled = enabled
25
+ self.cache_dir = Path(cache_dir)
26
+
27
+ def synthesize(self, text: str, voice_id: str) -> TTSResult:
28
+ normalized = text.strip()
29
+ if not normalized:
30
+ return TTSResult(text=text, voice_id=voice_id, error="empty text")
31
+ if not self.enabled:
32
+ return TTSResult(text=normalized, voice_id=voice_id, disabled=True)
33
+ digest = hashlib.sha256(f"{voice_id}:{normalized}".encode("utf-8")).hexdigest()[:16]
34
+ path = self.cache_dir / f"{digest}.wav"
35
+ if path.exists():
36
+ return TTSResult(
37
+ text=normalized,
38
+ voice_id=voice_id,
39
+ audio_path=str(path),
40
+ mime_type="audio/wav",
41
+ cache_hit=True,
42
+ disabled=False,
43
+ )
44
+ return TTSResult(
45
+ text=normalized,
46
+ voice_id=voice_id,
47
+ disabled=False,
48
+ error="local TTS runtime is not configured",
49
+ )
telltale/native/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Local native gameplay helpers."""
telltale/native/poker_solver/CMakeLists.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cmake_minimum_required(VERSION 3.16)
2
+ project(telltale_poker_solver LANGUAGES CXX)
3
+
4
+ set(CMAKE_CXX_STANDARD 17)
5
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
6
+
7
+ add_library(telltale_poker_solver SHARED
8
+ bindings.cpp
9
+ evaluator.cpp
10
+ equity.cpp
11
+ action_policy.cpp
12
+ )
13
+
14
+ target_include_directories(telltale_poker_solver PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
telltale/native/poker_solver/README.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This is a lightweight C++ poker solver used for fast hand evaluation, Monte Carlo equity estimation, and action recommendations. Python code loads it through a ctypes layer in `telltale/poker/native.py`.
2
+
3
+ The library is built on first use if a C++17 compiler is available:
4
+
5
+ ```bash
6
+ python -c "from telltale.poker import native; print(native.native_available())"
7
+ ```
8
+
9
+ Artifacts land in `build/native_poker_solver/`. Sources are recompiled when any `.cpp` or `.hpp` file in this directory is newer than the shared library.
10
+
11
+ For the API, all functions write JSON into a caller-provided buffer and return:
12
+
13
+ - `0` on success
14
+ - `-1` if the output buffer is null or too small
15
+ - a positive value equal to the required buffer size (including null terminator) when the buffer is too small
16
+
17
+ On error, the JSON payload contains an `"error"` field. The following are all the functions available to use from the API:
18
+
19
+ | Function | Description |
20
+ | --- | --- |
21
+ | `telltale_evaluate7(cards_csv, output, output_length)` | Best five-card rank from seven or more cards |
22
+ | `telltale_compare7(cards_a_csv, cards_b_csv, output, output_length)` | Compare two seven-card hands (`-1`, `0`, or `1`) |
23
+ | `telltale_estimate_equity(hero, board, num_opponents, dead, iterations, seed, output, output_length)` | Monte Carlo equity simulation |
24
+ | `telltale_estimate_equity_vs_range(hero, board, range, dead, iterations, seed, output, output_length)` | Monte Carlo equity simulation against a weighted one-opponent range such as `AhAd:1,KsQs:0.5` |
25
+ | `telltale_recommend_action(street, hero_equity, pot_size, amount_to_call, pot_odds, stack_to_pot_ratio, position_bucket, players_remaining, aggression_faced, can_check, legal_actions_csv, hero_stack, minimum_raise_amount, board_texture, street_action_count, previous_aggression_count, output, output_length)` | Weighted action policy recommendation |
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
telltale/native/poker_solver/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from telltale.poker.native import EquityResult, NativePokerSolver
2
+
3
+ __all__ = ["EquityResult", "NativePokerSolver"]
telltale/native/poker_solver/action_policy.cpp ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "action_policy.hpp"
2
+
3
+ #include <algorithm>
4
+ #include <cmath>
5
+ #include <iomanip>
6
+ #include <map>
7
+ #include <sstream>
8
+ #include <string>
9
+
10
+ namespace telltale::poker {
11
+
12
+ namespace {
13
+
14
+ /**
15
+ * Checks if an action is legal.
16
+ * Returns true if the action is in the list of legal actions, false otherwise.
17
+ */
18
+ bool has_action(const DecisionFeatures& features, const std::string& action) {
19
+ return std::find(features.legal_actions.begin(), features.legal_actions.end(), action) != features.legal_actions.end();
20
+ }
21
+
22
+ /**
23
+ * Adds a weight to an action if the action is legal.
24
+ * The weight will be the maximum of 0.0 and the given weight if the action is legal, 0.0 otherwise.
25
+ */
26
+ void add_if_legal(
27
+ const DecisionFeatures& features,
28
+ std::map<std::string, double>& weights,
29
+ const std::string& action,
30
+ double weight
31
+ ) {
32
+ if (has_action(features, action)) {
33
+ weights[action] = std::max(0.0, weight);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Returns the suggested amount to bet or raise.
39
+ * The amount will be the maximum of 0 and the hero stack.
40
+ * If the amount to call is greater than 0, the amount will be the maximum of the minimum raise and the pot size divided by 2.
41
+ * If the amount to call is 0, the amount will be the maximum of the minimum raise and the pot size divided by 2.
42
+ */
43
+ int suggested_amount(const DecisionFeatures& features) {
44
+ const int stack = std::max(0, features.hero_stack);
45
+ if (stack <= 0) {
46
+ return 0;
47
+ }
48
+ const int minimum_raise = std::max(1, features.minimum_raise_amount);
49
+ const int base = std::max(minimum_raise, features.pot_size / 2);
50
+ if (features.amount_to_call > 0) {
51
+ const int minimum_total = features.amount_to_call + minimum_raise;
52
+ return std::min(stack, std::max(minimum_total, features.amount_to_call + base));
53
+ }
54
+ return std::min(stack, base);
55
+ }
56
+
57
+ /**
58
+ * Returns the amount options for a given decision.
59
+ * The small amount will be the maximum of the minimum total and the pot divided by 3.
60
+ * The medium amount will be the maximum of the minimum total and the pot divided by 2.
61
+ * The large amount will be the maximum of the minimum total and the pot.
62
+ */
63
+ std::map<std::string, int> amount_options(const DecisionFeatures& features) {
64
+ const int stack = std::max(0, features.hero_stack);
65
+ const int minimum_raise = std::max(1, features.minimum_raise_amount);
66
+ const int pot = std::max(1, features.pot_size);
67
+ const int minimum_total = features.amount_to_call > 0
68
+ ? features.amount_to_call + minimum_raise
69
+ : minimum_raise;
70
+ return {
71
+ {"small", std::min(stack, std::max(minimum_total, pot / 3))},
72
+ {"medium", std::min(stack, std::max(minimum_total, pot / 2))},
73
+ {"large", std::min(stack, std::max(minimum_total, pot))},
74
+ {"all_in", stack},
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Returns the amount options as a JSON string.
80
+ * The JSON string will be in the format of:
81
+ * { "small": <small>, "medium": <medium>, "large": <large>, "all_in": <all_in> }
82
+ */
83
+ std::string amount_options_json(const std::map<std::string, int>& options) {
84
+ std::ostringstream output;
85
+ output << "{";
86
+ bool first = true;
87
+ for (const auto& [label, amount] : options) {
88
+ if (!first) {
89
+ output << ",";
90
+ }
91
+ first = false;
92
+ output << "\"" << label << "\":" << amount;
93
+ }
94
+ output << "}";
95
+ return output.str();
96
+ }
97
+
98
+ /**
99
+ * Returns the value threshold for a given decision.
100
+ * The value threshold will be the equity threshold for the given street.
101
+ */
102
+ double value_threshold(const DecisionFeatures& features) {
103
+ if (features.street == "preflop") {
104
+ return 0.63;
105
+ }
106
+ if (features.street == "flop") {
107
+ return 0.60;
108
+ }
109
+ if (features.street == "turn") {
110
+ return 0.58;
111
+ }
112
+ if (features.street == "river") {
113
+ return 0.55;
114
+ }
115
+ return 0.60;
116
+ }
117
+
118
+ /**
119
+ * Returns the raise threshold for a given decision.
120
+ * The raise threshold will be the equity threshold for the given street.
121
+ */
122
+ double raise_threshold(const DecisionFeatures& features) {
123
+ if (features.street == "preflop") {
124
+ return 0.74;
125
+ }
126
+ if (features.street == "flop") {
127
+ return 0.72;
128
+ }
129
+ if (features.street == "turn") {
130
+ return 0.70;
131
+ }
132
+ if (features.street == "river") {
133
+ return 0.67;
134
+ }
135
+ return 0.72;
136
+ }
137
+
138
+ /**
139
+ * Returns the board caution for a given decision.
140
+ */
141
+ double board_caution(const DecisionFeatures& features) {
142
+ if (features.board_texture == "wet" || features.board_texture == "monotone") {
143
+ return 0.72;
144
+ }
145
+ if (features.board_texture == "connected" || features.board_texture == "two_tone") {
146
+ return 0.85;
147
+ }
148
+ if (features.board_texture == "paired") {
149
+ return 0.92;
150
+ }
151
+ return 1.0;
152
+ }
153
+
154
+ /**
155
+ * Returns the risk label for a given decision.
156
+ */
157
+ std::string risk_label(const DecisionFeatures& features, const std::string& top_action) {
158
+ if (top_action == "all_in" || features.stack_to_pot_ratio <= 1.0) {
159
+ return "high";
160
+ }
161
+ if (top_action == "bet" || top_action == "raise" || features.board_texture == "wet" || features.board_texture == "monotone") {
162
+ return "medium";
163
+ }
164
+ return "low";
165
+ }
166
+
167
+ /**
168
+ * Returns the hand strength bucket for a given equity.
169
+ */
170
+ std::string hand_strength_bucket(double equity) {
171
+ if (equity >= 0.82) {
172
+ return "monster";
173
+ }
174
+ if (equity >= 0.62) {
175
+ return "strong";
176
+ }
177
+ if (equity >= 0.42) {
178
+ return "medium";
179
+ }
180
+ return "weak";
181
+ }
182
+
183
+ /**
184
+ * Returns the stack pressure bucket for a given decision.
185
+ */
186
+ std::string spr_bucket(const DecisionFeatures& features) {
187
+ if (features.stack_to_pot_ratio <= 1.5) {
188
+ return "low";
189
+ }
190
+ if (features.stack_to_pot_ratio <= 4.0) {
191
+ return "medium";
192
+ }
193
+ return "deep";
194
+ }
195
+
196
+ /**
197
+ * Returns the abstract action labels for a given decision.
198
+ */
199
+ std::vector<std::string> abstract_action_labels(const DecisionFeatures& features) {
200
+ std::vector<std::string> labels;
201
+ if (has_action(features, "fold")) {
202
+ labels.push_back("fold");
203
+ }
204
+ if (has_action(features, "check")) {
205
+ labels.push_back("check");
206
+ }
207
+ if (has_action(features, "call")) {
208
+ labels.push_back("call");
209
+ }
210
+ if (has_action(features, "bet")) {
211
+ labels.push_back("bet_33");
212
+ labels.push_back("bet_66");
213
+ labels.push_back("bet_100");
214
+ }
215
+ if (has_action(features, "raise")) {
216
+ labels.push_back("raise_2_5x");
217
+ labels.push_back("raise_pot");
218
+ }
219
+ if (has_action(features, "all_in")) {
220
+ labels.push_back("all_in");
221
+ }
222
+ return labels;
223
+ }
224
+
225
+ /**
226
+ * Returns a JSON string representing a vector of strings.
227
+ * The JSON string will be in the format of: [ "<value1>", "<value2>", "<value3>" ]
228
+ */
229
+ std::string string_array_json(const std::vector<std::string>& values) {
230
+ std::ostringstream output;
231
+ output << "[";
232
+ bool first = true;
233
+ for (const auto& value : values) {
234
+ if (!first) {
235
+ output << ",";
236
+ }
237
+ first = false;
238
+ output << "\"" << value << "\"";
239
+ }
240
+ output << "]";
241
+ return output.str();
242
+ }
243
+
244
+ }
245
+
246
+ /**
247
+ * Scores each legal action, normalizes to
248
+ * probabilities, and returns a JSON decision payload.
249
+ *
250
+ * Algorithm:
251
+ * 1. Derive context flags from features:
252
+ * - facing_bad_price: must call and equity is >3pp below pot odds
253
+ * - multiway: 3+ players still in the hand
254
+ * - caution: board_texture dampener (wet/monotone boards score lower)
255
+ * - value_line / raise_line: street-specific equity cutoffs for betting
256
+ * - aggression_penalty: shrinks call/raise weight after prior aggression
257
+ * - multiway_penalty / pressure_bonus: fewer bets multiway, more after
258
+ * repeated action on the street
259
+ *
260
+ * 2. Assign nonnegative weights only to legal actions (via add_if_legal):
261
+ * - If can_check: check vs bet vs all-in using value_line and stack depth
262
+ * - Else: fold vs call vs raise vs all-in using pot odds, raise_line, and
263
+ * the modifiers above
264
+ *
265
+ * 3. Normalize weights into probabilities (uniform fallback if none qualify).
266
+ *
267
+ * 4. Pick suggested_action = highest probability; decision_margin = gap to
268
+ * second-best. confidence blends margin with |equity - pot_odds|.
269
+ *
270
+ * 5. Attach sizing: suggested_amount (single chip count) and amount_options
271
+ * (small/medium/large/all_in) from pot fractions and raise minimums.
272
+ *
273
+ * Output JSON keys: probabilities, suggested_action, suggested_amount,
274
+ * equity, pot_odds, confidence, explanation, risk_label, board_texture,
275
+ * decision_margin, amount_options.
276
+ */
277
+ std::string recommend_action_json(const DecisionFeatures& features) {
278
+ std::map<std::string, double> weights;
279
+ const double equity = features.hero_equity;
280
+ const bool facing_bad_price = features.amount_to_call > 0 && equity + 0.03 < features.pot_odds;
281
+ const bool multiway = features.players_remaining >= 3;
282
+ const double caution = board_caution(features);
283
+ const double value_line = value_threshold(features);
284
+ const double raise_line = raise_threshold(features);
285
+ const double aggression_penalty = 1.0 / (1.0 + (0.12 * std::max(0, features.previous_aggression_count)));
286
+ const double multiway_penalty = multiway ? 0.72 : 1.0;
287
+ const double pressure_bonus = 1.0 + (0.08 * std::max(0, features.street_action_count));
288
+
289
+ if (features.can_check) {
290
+ add_if_legal(features, weights, "check", equity < value_line ? 3.2 : 0.8);
291
+ add_if_legal(
292
+ features,
293
+ weights,
294
+ "bet",
295
+ equity >= value_line ? 3.6 * caution * multiway_penalty * pressure_bonus : 0.35
296
+ );
297
+ add_if_legal(
298
+ features,
299
+ weights,
300
+ "all_in",
301
+ equity >= 0.82 && features.stack_to_pot_ratio <= 1.15 ? 1.4 * multiway_penalty : 0.08
302
+ );
303
+ } else {
304
+ add_if_legal(features, weights, "fold", facing_bad_price ? 3.8 : 0.35);
305
+ add_if_legal(features, weights, "call", facing_bad_price ? 0.55 : (equity >= raise_line ? 1.7 : 2.5) * aggression_penalty);
306
+ add_if_legal(
307
+ features,
308
+ weights,
309
+ "raise",
310
+ equity >= raise_line ? 3.4 * caution * multiway_penalty * aggression_penalty : 0.2
311
+ );
312
+ add_if_legal(
313
+ features,
314
+ weights,
315
+ "all_in",
316
+ equity >= 0.84 && features.stack_to_pot_ratio <= 1.25 ? 1.6 * multiway_penalty : 0.08
317
+ );
318
+ }
319
+
320
+ if (weights.empty()) {
321
+ for (const auto& action : features.legal_actions) {
322
+ weights[action] = 1.0;
323
+ }
324
+ }
325
+
326
+ double total = 0.0;
327
+ for (const auto& [_, weight] : weights) {
328
+ total += weight;
329
+ }
330
+ if (total <= 0.0) {
331
+ total = static_cast<double>(weights.size());
332
+ for (auto& [_, weight] : weights) {
333
+ weight = 1.0;
334
+ }
335
+ }
336
+
337
+ std::string top_action;
338
+ double top_probability = -1.0;
339
+ double second_probability = -1.0;
340
+ std::ostringstream probabilities;
341
+ probabilities << std::setprecision(17);
342
+ probabilities << "{";
343
+ bool first = true;
344
+ for (const auto& [action, weight] : weights) {
345
+ const double probability = weight / total;
346
+ if (!first) {
347
+ probabilities << ",";
348
+ }
349
+ first = false;
350
+ probabilities << "\"" << action << "\":" << probability;
351
+ if (probability > top_probability) {
352
+ second_probability = top_probability;
353
+ top_probability = probability;
354
+ top_action = action;
355
+ } else if (probability > second_probability) {
356
+ second_probability = probability;
357
+ }
358
+ }
359
+ probabilities << "}";
360
+ if (second_probability < 0.0) {
361
+ second_probability = 0.0;
362
+ }
363
+ const double decision_margin = std::max(0.0, top_probability - second_probability);
364
+ const auto options = amount_options(features);
365
+ const auto abstract_actions = abstract_action_labels(features);
366
+ const std::string strength_bucket = hand_strength_bucket(equity);
367
+ const std::string stack_pressure_bucket = spr_bucket(features);
368
+
369
+ std::ostringstream explanation;
370
+ explanation << "equity " << equity
371
+ << ", pot odds " << features.pot_odds
372
+ << ", texture " << features.board_texture
373
+ << ", players " << features.players_remaining;
374
+
375
+ std::ostringstream output;
376
+ output << std::setprecision(17);
377
+ output << "{\"probabilities\":" << probabilities.str()
378
+ << ",\"suggested_action\":\"" << top_action << "\""
379
+ << ",\"suggested_amount\":" << suggested_amount(features)
380
+ << ",\"equity\":" << equity
381
+ << ",\"pot_odds\":" << features.pot_odds
382
+ << ",\"confidence\":" << std::min(0.95, 0.50 + decision_margin + (0.15 * std::abs(equity - features.pot_odds)))
383
+ << ",\"explanation\":\"" << explanation.str() << "\""
384
+ << ",\"risk_label\":\"" << risk_label(features, top_action) << "\""
385
+ << ",\"board_texture\":\"" << features.board_texture << "\""
386
+ << ",\"decision_margin\":" << decision_margin
387
+ << ",\"amount_options\":" << amount_options_json(options)
388
+ << ",\"hand_strength_bucket\":\"" << strength_bucket << "\""
389
+ << ",\"spr_bucket\":\"" << stack_pressure_bucket << "\""
390
+ << ",\"abstract_actions\":" << string_array_json(abstract_actions) << "}";
391
+ return output.str();
392
+ }
393
+
394
+ }
telltale/native/poker_solver/action_policy.hpp ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include <string>
4
+ #include <vector>
5
+
6
+ namespace telltale::poker {
7
+
8
+ /**
9
+ * Describes the features of a decision.
10
+ * This is the input to the action policy.
11
+ */
12
+ struct DecisionFeatures {
13
+ std::string street;
14
+ double hero_equity;
15
+ int pot_size;
16
+ int amount_to_call;
17
+ double pot_odds;
18
+ double stack_to_pot_ratio;
19
+ int players_remaining;
20
+ bool can_check;
21
+ std::vector<std::string> legal_actions;
22
+ int hero_stack;
23
+ int minimum_raise_amount;
24
+ std::string board_texture;
25
+ int street_action_count;
26
+ int previous_aggression_count;
27
+ };
28
+
29
+ std::string recommend_action_json(const DecisionFeatures& features);
30
+
31
+ }
telltale/native/poker_solver/bindings.cpp ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "action_policy.hpp"
2
+ #include "equity.hpp"
3
+ #include "evaluator.hpp"
4
+
5
+ #include <cstring>
6
+ #include <exception>
7
+ #include <sstream>
8
+
9
+ namespace {
10
+
11
+ /**
12
+ * Writes the output to the given buffer.
13
+ * Returns 0 on success, -1 if the output buffer is null or too small, and the required buffer size (including null terminator) if the buffer is too small.
14
+ */
15
+ int write_output(const std::string& value, char* output, int output_length) {
16
+ if (output == nullptr || output_length <= 0) {
17
+ return -1;
18
+ }
19
+ if (static_cast<int>(value.size()) + 1 > output_length) {
20
+ return static_cast<int>(value.size()) + 1;
21
+ }
22
+ std::memcpy(output, value.c_str(), value.size() + 1);
23
+ return 0;
24
+ }
25
+
26
+ /**
27
+ * Returns a JSON string representing an error.
28
+ * The JSON string will be in the format of: {"error":"<error message>"}
29
+ */
30
+ std::string error_json(const std::exception& error) {
31
+ return std::string("{\"error\":\"") + error.what() + "\"}";
32
+ }
33
+
34
+ /**
35
+ * Splits a CSV string into a vector of strings.
36
+ * The strings will be in the order they appear in the CSV string.
37
+ * The strings will be trimmed of whitespace.
38
+ */
39
+ std::vector<std::string> split_csv(const char* values) {
40
+ std::vector<std::string> items;
41
+ if (values == nullptr || values[0] == '\0') {
42
+ return items;
43
+ }
44
+ std::stringstream stream(values);
45
+ std::string item;
46
+ while (std::getline(stream, item, ',')) {
47
+ if (!item.empty()) {
48
+ items.push_back(item);
49
+ }
50
+ }
51
+ return items;
52
+ }
53
+
54
+ }
55
+
56
+ extern "C" int telltale_evaluate7(const char* cards_csv, char* output, int output_length) {
57
+ try {
58
+ return write_output(telltale::poker::rank_to_json(telltale::poker::evaluate_7(telltale::poker::parse_cards_csv(cards_csv))), output, output_length);
59
+ } catch (const std::exception& error) {
60
+ return write_output(error_json(error), output, output_length);
61
+ }
62
+ }
63
+
64
+ extern "C" int telltale_compare7(const char* cards_a_csv, const char* cards_b_csv, char* output, int output_length) {
65
+ try {
66
+ const auto left = telltale::poker::evaluate_7(telltale::poker::parse_cards_csv(cards_a_csv));
67
+ const auto right = telltale::poker::evaluate_7(telltale::poker::parse_cards_csv(cards_b_csv));
68
+ return write_output("{\"comparison\":" + std::to_string(telltale::poker::compare_ranks(left, right)) + "}", output, output_length);
69
+ } catch (const std::exception& error) {
70
+ return write_output(error_json(error), output, output_length);
71
+ }
72
+ }
73
+
74
+ extern "C" int telltale_estimate_equity(
75
+ const char* hero_csv,
76
+ const char* board_csv,
77
+ int num_opponents,
78
+ const char* dead_csv,
79
+ int iterations,
80
+ unsigned long long seed,
81
+ char* output,
82
+ int output_length
83
+ ) {
84
+ try {
85
+ const auto result = telltale::poker::estimate_equity(
86
+ telltale::poker::parse_cards_csv(hero_csv),
87
+ telltale::poker::parse_cards_csv(board_csv),
88
+ num_opponents,
89
+ telltale::poker::parse_cards_csv(dead_csv),
90
+ iterations,
91
+ seed
92
+ );
93
+ return write_output(telltale::poker::equity_to_json(result), output, output_length);
94
+ } catch (const std::exception& error) {
95
+ return write_output(error_json(error), output, output_length);
96
+ }
97
+ }
98
+
99
+ extern "C" int telltale_estimate_equity_vs_range(
100
+ const char* hero_csv,
101
+ const char* board_csv,
102
+ const char* range_csv,
103
+ const char* dead_csv,
104
+ int iterations,
105
+ unsigned long long seed,
106
+ char* output,
107
+ int output_length
108
+ ) {
109
+ try {
110
+ const auto result = telltale::poker::estimate_equity_vs_range(
111
+ telltale::poker::parse_cards_csv(hero_csv),
112
+ telltale::poker::parse_cards_csv(board_csv),
113
+ telltale::poker::parse_range_csv(range_csv == nullptr ? "" : range_csv),
114
+ telltale::poker::parse_cards_csv(dead_csv),
115
+ iterations,
116
+ seed
117
+ );
118
+ return write_output(telltale::poker::equity_to_json(result), output, output_length);
119
+ } catch (const std::exception& error) {
120
+ return write_output(error_json(error), output, output_length);
121
+ }
122
+ }
123
+
124
+ extern "C" int telltale_recommend_action(
125
+ const char* street,
126
+ double hero_equity,
127
+ int pot_size,
128
+ int amount_to_call,
129
+ double pot_odds,
130
+ double stack_to_pot_ratio,
131
+ int players_remaining,
132
+ bool can_check,
133
+ const char* legal_actions_csv,
134
+ int hero_stack,
135
+ int minimum_raise_amount,
136
+ const char* board_texture,
137
+ int street_action_count,
138
+ int previous_aggression_count,
139
+ char* output,
140
+ int output_length
141
+ ) {
142
+ try {
143
+ const telltale::poker::DecisionFeatures features{
144
+ street == nullptr ? "" : street,
145
+ hero_equity,
146
+ pot_size,
147
+ amount_to_call,
148
+ pot_odds,
149
+ stack_to_pot_ratio,
150
+ players_remaining,
151
+ can_check,
152
+ split_csv(legal_actions_csv),
153
+ hero_stack,
154
+ minimum_raise_amount,
155
+ board_texture == nullptr ? "dry" : board_texture,
156
+ street_action_count,
157
+ previous_aggression_count,
158
+ };
159
+ return write_output(telltale::poker::recommend_action_json(features), output, output_length);
160
+ } catch (const std::exception& error) {
161
+ return write_output(error_json(error), output, output_length);
162
+ }
163
+ }
telltale/native/poker_solver/equity.cpp ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "equity.hpp"
2
+
3
+ #include <algorithm>
4
+ #include <array>
5
+ #include <iomanip>
6
+ #include <random>
7
+ #include <set>
8
+ #include <sstream>
9
+ #include <stdexcept>
10
+
11
+ namespace telltale::poker {
12
+
13
+ namespace {
14
+
15
+ /**
16
+ * Returns a standard deck of cards.
17
+ */
18
+ std::vector<Card> standard_deck() {
19
+ std::vector<Card> deck;
20
+ const std::string ranks = "23456789TJQKA";
21
+ const std::string suits = "cdhs";
22
+ for (const char suit : suits) {
23
+ for (const char rank : ranks) {
24
+ deck.push_back(parse_card(std::string{rank, suit}));
25
+ }
26
+ }
27
+ return deck;
28
+ }
29
+
30
+ /**
31
+ * Returns a string representation of a card.
32
+ * The string will be in the format of: <rank><suit>
33
+ * For example, "Ah" is the ace of hearts, "Ks" is the king of spades, "2c" is the 2 of clubs, etc.
34
+ */
35
+ std::string card_key(const Card& card) {
36
+ return std::to_string(card.rank) + card.suit;
37
+ }
38
+
39
+ /**
40
+ * Ensures that a list of cards are unique.
41
+ * If the list contains duplicate cards, an exception will be thrown.
42
+ */
43
+ void ensure_unique(const std::vector<Card>& cards) {
44
+ std::set<std::string> seen;
45
+ for (const auto& card : cards) {
46
+ const auto [_, inserted] = seen.insert(card_key(card));
47
+ if (!inserted) {
48
+ throw std::invalid_argument("known cards must be unique");
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Returns a list of cards that are not in the known cards.
55
+ * The list will be in the order they appear in the standard deck.
56
+ */
57
+ std::vector<Card> remaining_deck(const std::vector<Card>& known_cards) {
58
+ std::set<std::string> known;
59
+ for (const auto& card : known_cards) {
60
+ known.insert(card_key(card));
61
+ }
62
+ std::vector<Card> remaining;
63
+ for (const auto& card : standard_deck()) {
64
+ if (known.count(card_key(card)) == 0) {
65
+ remaining.push_back(card);
66
+ }
67
+ }
68
+ return remaining;
69
+ }
70
+
71
+ /**
72
+ * Returns true if a range combo conflicts with the known cards.
73
+ */
74
+ bool conflicts_with_known(const RangeCombo& combo, const std::set<std::string>& known) {
75
+ return known.count(card_key(combo.first)) > 0 || known.count(card_key(combo.second)) > 0;
76
+ }
77
+
78
+ /**
79
+ * Returns a list of range combos that do not conflict with the known cards.
80
+ * The list will be in the order they appear in the opponent range.
81
+ */
82
+ std::vector<RangeCombo> filter_range(
83
+ const std::vector<RangeCombo>& opponent_range,
84
+ const std::vector<Card>& known_cards
85
+ ) {
86
+ std::set<std::string> known;
87
+ for (const auto& card : known_cards) {
88
+ known.insert(card_key(card));
89
+ }
90
+
91
+ std::vector<RangeCombo> filtered;
92
+ for (const auto& combo : opponent_range) {
93
+ if (!conflicts_with_known(combo, known)) {
94
+ filtered.push_back(combo);
95
+ }
96
+ }
97
+ if (filtered.empty()) {
98
+ throw std::invalid_argument("opponent range has no live combos");
99
+ }
100
+ return filtered;
101
+ }
102
+
103
+ /**
104
+ * Samples a range combo from a weighted range.
105
+ * The range combo will be sampled with a probability proportional to its weight.
106
+ */
107
+ RangeCombo sample_combo(const std::vector<RangeCombo>& range, std::mt19937_64& rng) {
108
+ double total_weight = 0.0;
109
+ for (const auto& combo : range) {
110
+ total_weight += combo.weight;
111
+ }
112
+
113
+ std::uniform_real_distribution<double> distribution(0.0, total_weight);
114
+ double point = distribution(rng);
115
+ for (const auto& combo : range) {
116
+ point -= combo.weight;
117
+ if (point <= 0.0) {
118
+ return combo;
119
+ }
120
+ }
121
+ return range.back();
122
+ }
123
+
124
+ /**
125
+ * Returns a list of cards that are not in the given combo.
126
+ * The list will be in the order they appear in the deck.
127
+ */
128
+ std::vector<Card> deck_without_combo(const std::vector<Card>& deck, const RangeCombo& combo) {
129
+ const std::string first = card_key(combo.first);
130
+ const std::string second = card_key(combo.second);
131
+ std::vector<Card> filtered;
132
+ for (const auto& card : deck) {
133
+ const std::string key = card_key(card);
134
+ if (key != first && key != second) {
135
+ filtered.push_back(card);
136
+ }
137
+ }
138
+ return filtered;
139
+ }
140
+
141
+ }
142
+
143
+ /**
144
+ * Estimates the equity of a hand using a Monte Carlo simulation.
145
+ */
146
+ EquityResult estimate_equity(
147
+ const std::vector<Card>& hero_cards,
148
+ const std::vector<Card>& board_cards,
149
+ int num_opponents,
150
+ const std::vector<Card>& dead_cards,
151
+ int iterations,
152
+ std::uint64_t seed
153
+ ) {
154
+ if (hero_cards.size() != 2) {
155
+ throw std::invalid_argument("exactly two hero cards are required");
156
+ }
157
+ if (board_cards.size() > 5) {
158
+ throw std::invalid_argument("board cannot contain more than five cards");
159
+ }
160
+ if (num_opponents < 1) {
161
+ throw std::invalid_argument("at least one opponent is required");
162
+ }
163
+ if (iterations < 1) {
164
+ throw std::invalid_argument("iterations must be positive");
165
+ }
166
+
167
+ std::vector<Card> known = hero_cards;
168
+ known.insert(known.end(), board_cards.begin(), board_cards.end());
169
+ known.insert(known.end(), dead_cards.begin(), dead_cards.end());
170
+ ensure_unique(known);
171
+
172
+ const int board_needed = static_cast<int>(5 - board_cards.size()); // We need to fill the board with 5 cards.
173
+ const int cards_needed = board_needed + (num_opponents * 2);
174
+ std::vector<Card> deck = remaining_deck(known);
175
+ if (cards_needed > static_cast<int>(deck.size())) {
176
+ throw std::invalid_argument("not enough unknown cards remain");
177
+ }
178
+
179
+ std::mt19937_64 rng(seed); // We will use a random number generator to shuffle the deck.
180
+ int wins = 0;
181
+ int ties = 0;
182
+ int losses = 0;
183
+
184
+ for (int iteration = 0; iteration < iterations; ++iteration) {
185
+ // Shuffle the deck.
186
+ std::shuffle(deck.begin(), deck.end(), rng);
187
+
188
+ std::vector<Card> board = board_cards;
189
+ int offset = 0;
190
+ // Fill the board with the remaining cards.
191
+ for (int index = 0; index < board_needed; ++index) {
192
+ board.push_back(deck[offset++]);
193
+ }
194
+
195
+ std::vector<Card> hero_hand = hero_cards; // Add the hero cards to the board.
196
+ hero_hand.insert(hero_hand.end(), board.begin(), board.end());
197
+ const HandRank hero_rank = evaluate_7(hero_hand); // Evaluate the hero hand.
198
+
199
+ bool any_better = false;
200
+ bool any_tied = false;
201
+ // Evaluate the hands of the opponents.
202
+ for (int opponent = 0; opponent < num_opponents; ++opponent) {
203
+ std::vector<Card> opponent_hand = {deck[offset++], deck[offset++]}; // Add the opponent cards to the board.
204
+ opponent_hand.insert(opponent_hand.end(), board.begin(), board.end());
205
+ const int comparison = compare_ranks(hero_rank, evaluate_7(opponent_hand)); // Compare the hero hand to the opponent hand.
206
+ if (comparison < 0) {
207
+ any_better = true;
208
+ break;
209
+ }
210
+ if (comparison == 0) {
211
+ any_tied = true;
212
+ }
213
+ }
214
+
215
+ if (any_better) {
216
+ losses += 1;
217
+ } else if (any_tied) {
218
+ ties += 1;
219
+ } else {
220
+ wins += 1;
221
+ }
222
+ }
223
+
224
+ const double total = static_cast<double>(iterations);
225
+ return EquityResult{
226
+ wins,
227
+ ties,
228
+ losses,
229
+ iterations,
230
+ wins / total,
231
+ ties / total,
232
+ losses / total,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Estimates hero equity against a weighted one-opponent range.
238
+ */
239
+ EquityResult estimate_equity_vs_range(
240
+ const std::vector<Card>& hero_cards,
241
+ const std::vector<Card>& board_cards,
242
+ const std::vector<RangeCombo>& opponent_range,
243
+ const std::vector<Card>& dead_cards,
244
+ int iterations,
245
+ std::uint64_t seed
246
+ ) {
247
+ if (hero_cards.size() != 2) {
248
+ throw std::invalid_argument("exactly two hero cards are required");
249
+ }
250
+ if (board_cards.size() > 5) {
251
+ throw std::invalid_argument("board cannot contain more than five cards");
252
+ }
253
+ if (opponent_range.empty()) {
254
+ throw std::invalid_argument("opponent range cannot be empty");
255
+ }
256
+ if (iterations < 1) {
257
+ throw std::invalid_argument("iterations must be positive");
258
+ }
259
+
260
+ std::vector<Card> known = hero_cards;
261
+ known.insert(known.end(), board_cards.begin(), board_cards.end());
262
+ known.insert(known.end(), dead_cards.begin(), dead_cards.end());
263
+ ensure_unique(known);
264
+
265
+ const int board_needed = static_cast<int>(5 - board_cards.size());
266
+ std::vector<Card> base_deck = remaining_deck(known);
267
+ if (board_needed + 2 > static_cast<int>(base_deck.size())) {
268
+ throw std::invalid_argument("not enough unknown cards remain");
269
+ }
270
+
271
+ const std::vector<RangeCombo> live_range = filter_range(opponent_range, known);
272
+ std::mt19937_64 rng(seed);
273
+ int wins = 0;
274
+ int ties = 0;
275
+ int losses = 0;
276
+
277
+ for (int iteration = 0; iteration < iterations; ++iteration) {
278
+ const RangeCombo opponent_combo = sample_combo(live_range, rng);
279
+ std::vector<Card> deck = deck_without_combo(base_deck, opponent_combo);
280
+ std::shuffle(deck.begin(), deck.end(), rng);
281
+
282
+ std::vector<Card> board = board_cards;
283
+ int offset = 0;
284
+ for (int index = 0; index < board_needed; ++index) {
285
+ board.push_back(deck[offset++]);
286
+ }
287
+
288
+ std::vector<Card> hero_hand = hero_cards;
289
+ hero_hand.insert(hero_hand.end(), board.begin(), board.end());
290
+ std::vector<Card> opponent_hand = {opponent_combo.first, opponent_combo.second};
291
+ opponent_hand.insert(opponent_hand.end(), board.begin(), board.end());
292
+
293
+ const int comparison = compare_ranks(evaluate_7(hero_hand), evaluate_7(opponent_hand));
294
+ if (comparison > 0) {
295
+ wins += 1;
296
+ } else if (comparison == 0) {
297
+ ties += 1;
298
+ } else {
299
+ losses += 1;
300
+ }
301
+ }
302
+
303
+ const double total = static_cast<double>(iterations);
304
+ return EquityResult{
305
+ wins,
306
+ ties,
307
+ losses,
308
+ iterations,
309
+ wins / total,
310
+ ties / total,
311
+ losses / total,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Parses range entries like "AhAd:1,KsQs:0.5".
317
+ * The range will be in the order they appear in the range CSV string.
318
+ */
319
+ std::vector<RangeCombo> parse_range_csv(const std::string& range_csv) {
320
+ std::vector<RangeCombo> range;
321
+ std::stringstream stream(range_csv);
322
+ std::string item;
323
+ while (std::getline(stream, item, ',')) {
324
+ if (item.empty()) {
325
+ continue;
326
+ }
327
+ const std::size_t separator = item.find(':');
328
+ const std::string combo_text = separator == std::string::npos ? item : item.substr(0, separator);
329
+ const std::string weight_text = separator == std::string::npos ? "1" : item.substr(separator + 1);
330
+ if (combo_text.size() != 4) {
331
+ throw std::invalid_argument("range combos must be four card characters like AhAd");
332
+ }
333
+
334
+ RangeCombo combo{
335
+ parse_card(combo_text.substr(0, 2)),
336
+ parse_card(combo_text.substr(2, 2)),
337
+ std::stod(weight_text),
338
+ };
339
+ if (card_key(combo.first) == card_key(combo.second)) {
340
+ throw std::invalid_argument("range combo cards must be unique");
341
+ }
342
+ if (combo.weight <= 0.0) {
343
+ throw std::invalid_argument("range combo weights must be positive");
344
+ }
345
+ range.push_back(combo);
346
+ }
347
+
348
+ if (range.empty()) {
349
+ throw std::invalid_argument("opponent range cannot be empty");
350
+ }
351
+ return range;
352
+ }
353
+
354
+ /**
355
+ * Converts an equity result to a JSON string.
356
+ * The JSON string will be in the format of:
357
+ * { "wins": <wins>,
358
+ * "ties": <ties>,
359
+ * "losses": <losses>,
360
+ * "iterations": <iterations>,
361
+ * "win_probability": <win_probability>,
362
+ * "tie_probability": <tie_probability>,
363
+ * "loss_probability": <loss_probability>
364
+ * }
365
+ */
366
+ std::string equity_to_json(const EquityResult& result) {
367
+ std::ostringstream output;
368
+ output << std::setprecision(17);
369
+ output << "{\"wins\":" << result.wins
370
+ << ",\"ties\":" << result.ties
371
+ << ",\"losses\":" << result.losses
372
+ << ",\"iterations\":" << result.iterations
373
+ << ",\"win_probability\":" << result.win_probability
374
+ << ",\"tie_probability\":" << result.tie_probability
375
+ << ",\"loss_probability\":" << result.loss_probability
376
+ << "}";
377
+ return output.str();
378
+ }
379
+
380
+ }
telltale/native/poker_solver/equity.hpp ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include "evaluator.hpp"
4
+
5
+ #include <cstdint>
6
+ #include <string>
7
+ #include <vector>
8
+
9
+ namespace telltale::poker {
10
+
11
+ /**
12
+ * Describes the result of a Monte Carlo equity estimation.
13
+ */
14
+ struct EquityResult {
15
+ int wins;
16
+ int ties;
17
+ int losses;
18
+ int iterations;
19
+ double win_probability;
20
+ double tie_probability;
21
+ double loss_probability;
22
+ };
23
+
24
+ /**
25
+ * Describes a weighted two-card opponent range combo (e.g. "AhAd:1,KsQs:0.5").
26
+ * This will be used to sample opponent hole cards from a weighted range.
27
+ */
28
+ struct RangeCombo {
29
+ Card first;
30
+ Card second;
31
+ double weight;
32
+ };
33
+
34
+ /**
35
+ * Estimates the equity of a hand using a Monte Carlo simulation.
36
+ * The simulation will be run for the given number of iterations and the seed will be used to randomize the simulation.
37
+ */
38
+ EquityResult estimate_equity(
39
+ const std::vector<Card>& hero_cards,
40
+ const std::vector<Card>& board_cards,
41
+ int num_opponents,
42
+ const std::vector<Card>& dead_cards,
43
+ int iterations,
44
+ std::uint64_t seed
45
+ );
46
+
47
+ /**
48
+ * Estimates hero equity against a weighted one-opponent range.
49
+ */
50
+ EquityResult estimate_equity_vs_range(
51
+ const std::vector<Card>& hero_cards,
52
+ const std::vector<Card>& board_cards,
53
+ const std::vector<RangeCombo>& opponent_range,
54
+ const std::vector<Card>& dead_cards,
55
+ int iterations,
56
+ std::uint64_t seed
57
+ );
58
+
59
+ /**
60
+ * Parses range entries like "AhAd:1,KsQs:0.5".
61
+ */
62
+ std::vector<RangeCombo> parse_range_csv(const std::string& range_csv);
63
+
64
+ /**
65
+ * Converts an equity result to a JSON string.
66
+ * The JSON string will be in the format of:
67
+ * { "wins": <wins>,
68
+ * "ties": <ties>,
69
+ * "losses": <losses>,
70
+ * "equity": <win_probability + (tie_probability * 0.5)>,
71
+ * "iterations": <iterations>,
72
+ * "win_probability": <win_probability>,
73
+ * "tie_probability": <tie_probability>,
74
+ * "loss_probability": <loss_probability>
75
+ * }
76
+ */
77
+ std::string equity_to_json(const EquityResult& result);
78
+
79
+ }
telltale/native/poker_solver/evaluator.cpp ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "evaluator.hpp"
2
+
3
+ #include <algorithm>
4
+ #include <array>
5
+ #include <map>
6
+ #include <sstream>
7
+ #include <stdexcept>
8
+
9
+ namespace telltale::poker {
10
+
11
+ namespace {
12
+
13
+ /**
14
+ * Converts a card rank character to its corresponding integer value.
15
+ * For example, 'A' returns 14, 'K' returns 13, 'Q' returns 12, etc.
16
+ */
17
+ int rank_value(char rank) {
18
+ const std::string ranks = "23456789TJQKA";
19
+ const auto index = ranks.find(rank);
20
+ if (index == std::string::npos) {
21
+ throw std::invalid_argument("invalid card rank");
22
+ }
23
+ return static_cast<int>(index) + 2;
24
+ }
25
+
26
+ /**
27
+ * Returns the label for a hand rank category.
28
+ * For example, 8 returns "straight flush", 7 returns "four of a kind", etc.
29
+ */
30
+ std::string label_for_category(int category) {
31
+ static const std::array<std::string, 9> labels = {
32
+ "high card",
33
+ "one pair",
34
+ "two pair",
35
+ "three of a kind",
36
+ "straight",
37
+ "flush",
38
+ "full house",
39
+ "four of a kind",
40
+ "straight flush",
41
+ };
42
+ return labels.at(category);
43
+ }
44
+
45
+ /**
46
+ * Returns the ranks with a count other than the excluded count.
47
+ * For example, if the counts are {2: 2, 3: 2, 4: 1}, then the ranks with count other than 2 are {3, 4}.
48
+ * This will be used to get the kickers for a hand rank.
49
+ */
50
+ std::vector<int> ranks_with_count_other_than(const std::map<int, int>& counts, int excluded_count) {
51
+ std::vector<int> ranks;
52
+ for (const auto& [rank, count] : counts) {
53
+ if (count != excluded_count) {
54
+ ranks.push_back(rank);
55
+ }
56
+ }
57
+ std::sort(ranks.begin(), ranks.end(), std::greater<int>());
58
+ return ranks;
59
+ }
60
+
61
+ /**
62
+ * Appends a vector of integers to a string stream as a JSON array.
63
+ * For example, [1, 2, 3] will be appended as "[1,2,3]".
64
+ */
65
+ void append_int_array(std::ostringstream& output, const std::vector<int>& values) {
66
+ output << "[";
67
+ for (std::size_t index = 0; index < values.size(); ++index) {
68
+ if (index > 0) {
69
+ output << ",";
70
+ }
71
+ output << values[index];
72
+ }
73
+ output << "]";
74
+ }
75
+
76
+ }
77
+
78
+ /**
79
+ * Parses a card from a string.
80
+ * The string should be two characters, the first being the rank and the second being the suit.
81
+ * For example, "Ah" is the ace of hearts, "Ks" is the king of spades, "2c" is the 2 of clubs, etc.
82
+ */
83
+ Card parse_card(const std::string& value) {
84
+ if (value.size() != 2) {
85
+ throw std::invalid_argument("card must be two characters");
86
+ }
87
+ const char suit = value[1];
88
+ if (suit != 'c' && suit != 'd' && suit != 'h' && suit != 's') {
89
+ throw std::invalid_argument("invalid card suit");
90
+ }
91
+ return Card{rank_value(value[0]), suit};
92
+ }
93
+
94
+
95
+ /**
96
+ * Parses a list of cards from a CSV string.
97
+ * For example, "Ah,Ks,2c" is the ace of hearts, the king of spades, and the 2 of clubs.
98
+ * The cards are returned in the order they appear in the string.
99
+ */
100
+ std::vector<Card> parse_cards_csv(const char* values) {
101
+ std::vector<Card> cards;
102
+ if (values == nullptr || values[0] == '\0') {
103
+ return cards;
104
+ }
105
+ std::stringstream stream(values);
106
+ std::string item;
107
+ while (std::getline(stream, item, ',')) {
108
+ if (!item.empty()) {
109
+ cards.push_back(parse_card(item));
110
+ }
111
+ }
112
+ return cards;
113
+ }
114
+
115
+ /**
116
+ * Evaluates a 5-card hand and returns a HandRank object.
117
+ * You can read the note.md file for more detail and explanation on how this implementation works.
118
+ */
119
+ HandRank evaluate_5(const std::array<Card, 5>& cards) {
120
+ std::vector<int> values;
121
+ values.reserve(5);
122
+ std::map<int, int> counts;
123
+ bool is_flush = true;
124
+ const char suit = cards[0].suit;
125
+ for (const auto& card : cards) {
126
+ values.push_back(card.rank);
127
+ counts[card.rank] += 1;
128
+ if (card.suit != suit) {
129
+ is_flush = false;
130
+ }
131
+ }
132
+ std::sort(values.begin(), values.end(), std::greater<int>());
133
+
134
+ std::vector<int> unique_values = values;
135
+ unique_values.erase(std::unique(unique_values.begin(), unique_values.end()), unique_values.end());
136
+
137
+ bool is_straight = false;
138
+ int straight_high = 0;
139
+ if (unique_values.size() == 5) {
140
+ if (unique_values[0] - unique_values[4] == 4) {
141
+ is_straight = true;
142
+ straight_high = unique_values[0];
143
+ } else if (unique_values == std::vector<int>{14, 5, 4, 3, 2}) {
144
+ is_straight = true;
145
+ straight_high = 5;
146
+ }
147
+ }
148
+
149
+ std::vector<std::pair<int, int>> by_count;
150
+ for (const auto& [rank, count] : counts) {
151
+ by_count.emplace_back(rank, count);
152
+ }
153
+ std::sort(by_count.begin(), by_count.end(), [](const auto& left, const auto& right) {
154
+ if (left.second != right.second) {
155
+ return left.second > right.second;
156
+ }
157
+ return left.first > right.first;
158
+ });
159
+
160
+ if (is_straight && is_flush) {
161
+ return HandRank{8, {straight_high}, label_for_category(8)};
162
+ }
163
+ if (by_count[0].second == 4) {
164
+ return HandRank{7, {by_count[0].first, by_count[1].first}, label_for_category(7)};
165
+ }
166
+ if (by_count[0].second == 3 && by_count[1].second == 2) {
167
+ return HandRank{6, {by_count[0].first, by_count[1].first}, label_for_category(6)};
168
+ }
169
+ if (is_flush) {
170
+ return HandRank{5, values, label_for_category(5)};
171
+ }
172
+ if (is_straight) {
173
+ return HandRank{4, {straight_high}, label_for_category(4)};
174
+ }
175
+ if (by_count[0].second == 3) {
176
+ auto kickers = ranks_with_count_other_than(counts, 3);
177
+ std::vector<int> tiebreakers = {by_count[0].first};
178
+ tiebreakers.insert(tiebreakers.end(), kickers.begin(), kickers.end());
179
+ return HandRank{3, tiebreakers, label_for_category(3)};
180
+ }
181
+ if (by_count[0].second == 2 && by_count[1].second == 2) {
182
+ const int high_pair = std::max(by_count[0].first, by_count[1].first);
183
+ const int low_pair = std::min(by_count[0].first, by_count[1].first);
184
+ return HandRank{2, {high_pair, low_pair, by_count[2].first}, label_for_category(2)};
185
+ }
186
+ if (by_count[0].second == 2) {
187
+ auto kickers = ranks_with_count_other_than(counts, 2);
188
+ std::vector<int> tiebreakers = {by_count[0].first};
189
+ tiebreakers.insert(tiebreakers.end(), kickers.begin(), kickers.end());
190
+ return HandRank{1, tiebreakers, label_for_category(1)};
191
+ }
192
+ return HandRank{0, values, label_for_category(0)};
193
+ }
194
+
195
+ /**
196
+ * Evaluates a 7-card hand and returns a HandRank object.
197
+ * First, we will need to iterate over all possible combinations of 5 cards and evaluate the hand rank.
198
+ * Then, we will compare the hand rank to the best possible hand rank and return the best hand rank.
199
+ */
200
+ HandRank evaluate_7(const std::vector<Card>& cards) {
201
+ if (cards.size() < 7) {
202
+ throw std::invalid_argument("evaluate_7 requires at least seven cards");
203
+ }
204
+ bool has_best = false;
205
+ HandRank best;
206
+ for (std::size_t a = 0; a < cards.size() - 4; ++a) {
207
+ for (std::size_t b = a + 1; b < cards.size() - 3; ++b) {
208
+ for (std::size_t c = b + 1; c < cards.size() - 2; ++c) {
209
+ for (std::size_t d = c + 1; d < cards.size() - 1; ++d) {
210
+ for (std::size_t e = d + 1; e < cards.size(); ++e) {
211
+ const std::array<Card, 5> hand = {cards[a], cards[b], cards[c], cards[d], cards[e]};
212
+ const HandRank rank = evaluate_5(hand);
213
+ if (!has_best || compare_ranks(rank, best) > 0) {
214
+ best = rank;
215
+ has_best = true;
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+ return best;
223
+ }
224
+
225
+ /**
226
+ * Compares two hand ranks.
227
+ * Returns -1 if the left hand rank is lower than the right hand rank,
228
+ * 0 if they are equal, and 1 if the left hand rank is higher than the right hand rank.
229
+ */
230
+ int compare_ranks(const HandRank& left, const HandRank& right) {
231
+ if (left.category != right.category) {
232
+ return left.category > right.category ? 1 : -1;
233
+ }
234
+ const std::size_t limit = std::min(left.tiebreakers.size(), right.tiebreakers.size());
235
+ for (std::size_t index = 0; index < limit; ++index) {
236
+ if (left.tiebreakers[index] != right.tiebreakers[index]) {
237
+ return left.tiebreakers[index] > right.tiebreakers[index] ? 1 : -1;
238
+ }
239
+ }
240
+ if (left.tiebreakers.size() == right.tiebreakers.size()) {
241
+ return 0;
242
+ }
243
+ return left.tiebreakers.size() > right.tiebreakers.size() ? 1 : -1;
244
+ }
245
+
246
+ /**
247
+ * Converts a hand rank to a JSON string.
248
+ * The JSON string will be in the format of:
249
+ * { "category": <category>, "tiebreakers": [<tiebreakers>], "label": "<label>" }
250
+ */
251
+ std::string rank_to_json(const HandRank& rank) {
252
+ std::ostringstream output;
253
+ output << "{\"category\":" << rank.category << ",\"tiebreakers\":";
254
+ append_int_array(output, rank.tiebreakers);
255
+ output << ",\"label\":\"" << rank.label << "\"}";
256
+ return output.str();
257
+ }
258
+
259
+ }
telltale/native/poker_solver/evaluator.hpp ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include <array>
4
+ #include <string>
5
+ #include <vector>
6
+
7
+ namespace telltale::poker {
8
+
9
+ /**
10
+ * Describes a card in a poker hand.
11
+ */
12
+ struct Card {
13
+ int rank;
14
+ char suit;
15
+ };
16
+
17
+ /**
18
+ * Describes a hand rank in a poker hand.
19
+ * There are 9 categories of hand ranks in poker, and they are ordered from highest (straight flush) to lowest (high card).
20
+ */
21
+ struct HandRank {
22
+ int category; // the category of the hand rank.
23
+ std::vector<int> tiebreakers; // tiebreakers are used to break ties within a same category.
24
+ std::string label; // the label of the hand rank.
25
+ };
26
+
27
+ /**
28
+ * Parses a card from a string.
29
+ * The string should be two characters, the first being the rank and the second being the suit.
30
+ * For example, "Ah" is the ace of hearts, "Ks" is the king of spades, "2c" is the 2 of clubs, etc.
31
+ */
32
+ Card parse_card(const std::string& value);
33
+
34
+ /**
35
+ * Parses a list of cards from a CSV string.
36
+ * For example, "Ah,Ks,2c" is the ace of hearts, the king of spades, and the 2 of clubs.
37
+ * The cards are returned in the order they appear in the string.
38
+ */
39
+ std::vector<Card> parse_cards_csv(const char* values);
40
+
41
+
42
+ /**
43
+ * Evaluates a 5-card hand. This follws the same logic as the Python implementation.
44
+ */
45
+ HandRank evaluate_5(const std::array<Card, 5>& cards);
46
+
47
+ /**
48
+ * Evaluates a 7-card hand.
49
+ */
50
+ HandRank evaluate_7(const std::vector<Card>& cards);
51
+
52
+ /**
53
+ * Compares two hand ranks.
54
+ * Returns -1 if the left hand rank is lower than the right hand rank,
55
+ * 0 if they are equal, and 1 if the left hand rank is higher than the right hand rank.
56
+ */
57
+ int compare_ranks(const HandRank& left, const HandRank& right);
58
+
59
+ /**
60
+ * Converts a hand rank to a JSON string.
61
+ */
62
+ std::string rank_to_json(const HandRank& rank);
63
+
64
+ }
telltale/native/poker_solver/note.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ In this document, I want to go through the poker solver and attempt to explain how the whole thing works. I want to do this so that a) I know that I understand how it all works and b) someone who is looking over this codebase can get a quick introduction to how it works.
2
+
3
+ The main idea in making this solver is that I don't want to create the most game theory optimal (GTO) poker solver. This is a small game where you are playing with small LLMs. I want the personality of the agents that are playing the game, the way the game is being played, and things like that to also dictate the strategy of any individial agent. So, there will definitely be cases where the solver will not theoretically/qunatitatively reflect the most sound and reasonable play. But, that is okay. This solver will just serve as a way for the agents to get actual objective information about the game, so that they can reason about what their next move should be on their own.
4
+
5
+ ## Evaluating a hand of poker
6
+
7
+ The first thing that we will need to understand is how to evaluate a hand of poker. Out of the 2 hole cards that each player has, and the 5 community cards that is common to all players, we will need to find the best-5 card hand. We will evaluate this by ranking each possible combination and comparing it to the best possible hand that you can make. To do that, we will use a `HandRank` class that will have category, tiebreakers, and label as its attributes. There are only 9 categories of cards in No-limit Texas Hold'em, so we will rank them according to the strength of the hand as follows:
8
+
9
+
10
+ | Category | Label | Tiebreakers (in order) |
11
+ | -------- | --------------- | ----------------------------------- |
12
+ | 0 | high card | all five ranks, high to low |
13
+ | 1 | one pair | pair rank, then kickers high to low |
14
+ | 2 | two pair | high pair, low pair, kicker |
15
+ | 3 | three of a kind | trips rank, then kickers |
16
+ | 4 | straight | high card of the straight |
17
+ | 5 | flush | all five ranks, high to low |
18
+ | 6 | full house | trips rank, then pair rank |
19
+ | 7 | four of a kind | quad rank, then kicker |
20
+ | 8 | straight flush | high card of the straight |
21
+
22
+
23
+ A good thing about this is that a higher category always beats a lower category. And, for cards in the same category, we can just use the tiebreaker cards. Therefore, once we have a `HandRank`, comparing two hands is just a basic comparison:
24
+
25
+ 1. Compare `category` first. A higher category always wins.
26
+ 2. If categories are equal, compare `tiebreakers` one element at a time, left to right.
27
+ 3. If all compared tiebreakers match, the hands tie.
28
+
29
+ `evaluate_5()` takes a hand and returns the HandRank object for it. To do that, it first collects some basic facts about the five cards: how many of each rank appear, whether all five cards share the same suit (a flush), and whether the distinct ranks form a consecutive run (a straight), with a special case for the wheel straight `A-2-3-4-5` where the ace plays low and the high card is 5. It then walks down the category list from strongest to weakest straight and returns as soon as it finds a match, filling in the appropriate tiebreakers along the way. For hands with pairs or better, kickers are the ranks that are not part of the main combination, sorted from highest to lowest.
30
+
31
+ In Hold'em we rarely evaluate exactly five cards on their own. `evaluate_7()` takes all seven cards (two hole cards plus five community cards), tries every possible five-card subset, there are 21 of them, runs `evaluate_5()` on each, and keeps the strongest result.
32
+
33
+ ## Some Important Decision Features to Know About
34
+
35
+ This was just basic hand evaluation. It is important now as we move forward to get to something that will actually help us give some information about how strong one's current position is at a given moment and can help us in our decision making.
36
+
37
+ ### Equity
38
+
39
+ Equity tries to answer the question that given what we know and what is still unknown, **what share of the pot do we expect to win** if the hand runs out?
40
+
41
+ Conceptually:
42
+
43
+ $$
44
+ \text{equity} = \frac{\mathbb{E}[\text{amount won by hero}]}{\text{total pot}}
45
+ $$
46
+
47
+ We never compute that expectation in closed form. Instead, `estimate_equity()` in `equity.cpp` runs a **Monte Carlo simulation**, where we complete the hand thousands of times at random, count outcomes, and use sample frequencies as probabilities. The same `EquityResult` shape is also returned by `estimate_equity_vs_range()`, which samples opponent hands from a **weighted range** instead of uniformly from the deck.
48
+
49
+ Before any trial starts, the code builds a **known card set** = hero + board + dead cards, checks that no card appears twice, and builds a **remaining deck** of everything else in the standard 52-card deck.
50
+
51
+ It also computes how many unknown cards each trial needs:
52
+
53
+ $$
54
+ \text{boardneeded} = 5 - |\text{board}|, \quad
55
+ \text{cardsneeded} = \text{boardneeded} + 2 \cdot \text{numopponents}
56
+ $$
57
+
58
+ If `cardsneeded` exceeds the remaining deck size, estimation throws an error saying that there are not enough cards left to deal.
59
+
60
+ #### Standard mode: `estimate_equity()`
61
+
62
+ This is what `PokerPolicyWriter` calls during live play. Opponent hole cards are **uniformly random**: any live two-card combo is equally likely, conditional on not conflicting with known cards.
63
+
64
+ **One trial:**
65
+
66
+ 1. Shuffle the remaining deck with the seeded RNG.
67
+ 2. Deal `boardneeded` cards off the top to complete the board to five cards.
68
+ 3. Evaluate the hero with `evaluate_7(hero + board)`.
69
+ 4. For each opponent, deal two hole cards from the deck and evaluate with `evaluate_7(opponent + board)`.
70
+ 5. Classify the trial:
71
+ - **Loss**: at least one opponent's hand beats the hero (comparison stops early on the first loss).
72
+ - **Tie**: no opponent beats the hero, but at least one opponent ties.
73
+ - **Win**: the hero has the sole best hand.
74
+
75
+ Step 5 is important for multiway pots. The hero only wins a trial when nobody beats them and nobody chops. One player outdrawing us is enough for the whole trial to count as a loss, even if we would have tied someone else.
76
+
77
+ After $N$ iterations:
78
+
79
+ $$
80
+ P(\text{win}) = \frac{\text{wins}}{N}, \quad
81
+ P(\text{tie}) = \frac{\text{ties}}{N}, \quad
82
+ P(\text{loss}) = \frac{\text{losses}}{N}
83
+ $$
84
+
85
+ Hero equity combines wins and ties:
86
+
87
+ $$
88
+ \text{heroequity} = P(\text{win}) + 0.5 \cdot P(\text{tie})
89
+ $$
90
+
91
+ The factor of $\frac{1}{2}$ on ties reflects chopping: if we tie for the best hand, we only recover half of the contested pot on average.
92
+
93
+ #### Range mode: `estimate_equity_vs_range()`
94
+
95
+ The second entry point models a **single opponent** whose hand is drawn from a weighted combo list rather than uniformly from the deck. Each `RangeCombo` is two cards plus a positive weight, e.g. `AhAd:1,KsQs:0.5` via `parse_range_csv()`.
96
+
97
+ **One trial:**
98
+
99
+ 1. Filter the range to **live combos**: drop any combo that shares a card with hero, board, or dead cards.
100
+ 2. Sample one combo with probability proportional to its weight.
101
+ 3. Remove that combo's two cards from the deck, shuffle what is left, and deal out the remaining board cards.
102
+ 4. Compare hero vs the sampled opponent hand head-to-head (win / tie / loss).
103
+
104
+ Range mode is heads-up only: there is no `num_opponents` parameter because the opponent hand is defined by the range sample. If every combo conflicts with known cards, estimation throws (`opponent range has no live combos`).
105
+
106
+ ### Pot Odds
107
+
108
+ Equity tells us how strong our hand is, but pot odds tell us whether the price of continuing is worth paying. When we face a bet, we need to put in more chips to stay in the hand. Pot odds measure that cost as a fraction of the total pot we are fighting for after we call.
109
+
110
+ If there is nothing to call, pot odds are zero. Otherwise:
111
+
112
+ $$
113
+ \text{potodds} = \frac{\text{amounttocall}}{\text{potsize} + \text{amounttocall}}
114
+ $$
115
+
116
+ The denominator is the pot after we call, because that is the total prize we are contesting once we pay the outstanding amount. For example, if the pot is 100 and we must call 50, then:
117
+
118
+ $$
119
+ \text{potodds} = \frac{50}{100 + 50} = \frac{1}{3} \approx 0.33
120
+ $$
121
+
122
+ So we are risking one third of the final pot to win the whole thing. In the simplest heads-up, one-street model, a call is roughly break-even when:
123
+
124
+ $$
125
+ \text{heroequity} \approx \text{potodds}
126
+ $$
127
+
128
+ If our equity is below pot odds, calling loses money on average; if it is above, calling wins money on average. Real poker is more complicated because future betting streets exist, but this is still a useful baseline.
129
+
130
+ ### Stack-to-Pot Ratio
131
+
132
+ Stack-to-pot ratio (SPR) measures how deep our remaining stack is relative to the pot. It tells us how much room we have left to bet on later streets, and how close we are to being committed.
133
+
134
+ $$
135
+ \text{SPR} = \frac{\text{herostack}}{\text{potsize}}
136
+ $$
137
+
138
+ If the pot is empty, we define SPR as the hero's full stack so the value is still meaningful as a large number.
139
+
140
+ A low SPR means the pot is already large compared to what we have left. For example, $\text{SPR} = 0.8$ means our stack is only 80% of the pot, one more reasonable bet can put us all in. A high SPR means we are still deep relative to the pot and have more room to maneuver.
141
+
142
+ ### Board Texture
143
+
144
+ Board texture is a label for how coordinated and dangerous the community cards are. Two players can have the same equity number on very different boards: a dry rainbow flop is stable, while a monotone connected flop creates many straight and flush possibilities for opponents we have not seen yet.
145
+
146
+ `board_texture()` in `policy.py` assigns one label once at least three community cards are out. Before the flop, or with fewer than three board cards, the texture is `"dry"`.
147
+
148
+ The function looks at three properties of the board:
149
+
150
+ 1. **Suit pattern**: how many distinct suits appear.
151
+ 2. **Pairing**: whether any rank appears more than once.
152
+ 3. **Connectivity**: whether the ranks contain a three-card straight window (ranks within a span of 4, including the wheel case $A$-$2$-$3$-$4$-$5$).
153
+
154
+ The labels are assigned in priority order:
155
+
156
+
157
+ | Label | Meaning |
158
+ | ----------- | ----------------------------------------------- |
159
+ | `monotone` | All board cards share one suit |
160
+ | `wet` | Connected board that is also two-tone or paired |
161
+ | `connected` | Straight-draw potential, but not wet |
162
+ | `paired` | At least one paired rank on board |
163
+ | `two_tone` | Exactly two suits on board |
164
+ | `dry` | None of the above |
165
+
166
+
167
+ `"wet"` boards are the most volatile: they combine connectivity with flush or full-house texture. `"dry"` boards change less when new cards arrive.
168
+
169
+ # How does the Solver Recommend?
170
+
171
+ `recommend_action_json()` in `action_policy.cpp` turns `DecisionFeatures` (equity, pot odds, SPR, board texture, legal actions, and history) into a JSON recommendation. It does **not** run CFR or build opponent ranges. It scores each legal action with some weights, normalizes them into probabilities, and returns the top pick plus metadata for agents and prompts.
172
+
173
+ `PokerPolicyWriter` in `policy.py` is the usual entry point: estimate equity -> build features -> call `native.recommend_action()` → wrap as `PokerDecision`.
174
+
175
+ ## How the recommendation is built
176
+
177
+ - The policy uses two different weight tables: passive line (check / bet / all-in) vs facing a bet (fold / call / raise / all-in).
178
+ - Each street has a value line (when to bet for value) and a raise line (when raising is reasonable):
179
+
180
+ | Street | Value line | Raise line |
181
+ | ------ | ---------- | ---------- |
182
+ | preflop | 0.63 | 0.74 |
183
+ | flop | 0.60 | 0.72 |
184
+ | turn | 0.58 | 0.70 |
185
+ | river | 0.55 | 0.67 |
186
+
187
+ - If we must call and $\text{equity} + 0.03 < \text{potodds}$, the fold weight spikes. That encodes "don't pay more than our fair share of the pot without a cushion."
188
+ - Wet and monotone boards apply a caution multiplier (down to 0.72) on bets and raises. Scary runouts deserve less eagerness to build pots without a strong hand.
189
+ - With three or more players left, bet/raise weights are multiplied by 0.72. More opponents means someone is more likely to have us beat.
190
+ - `previous_aggression_count` shrinks call/raise weights via an aggression penalty. Stations and repeat bettors are modeled as less attractive to engage lightly.
191
+ - `street_action_count` adds a small pressure bonus to betting weights. Repeated action on a street nudges toward continuing the story with a bet.
192
+ - All-in only gets meaningful weight when equity is very high (>= 0.82–0.84 depending on line) and SPR is shallow (<= 1.15-1.25). Deep stacks with medium strength should not default to shoving.
193
+ - We, will then divide each action's weight by the sum. If nothing qualified, fall back to a uniform distribution over legal actions.
194
+ - We will then pick `suggested_action`, the highest probability wins. `decision_margin` is the gap to the runner-up; `confidence` blends margin with $|\text{equity} - \text{potodds}|$.
195
+ - `suggested_amount` defaults to roughly half-pot (clamped to min-raise and stack). `amount_options` exposes small / medium / large / all-in chip counts from pot fractions.
196
+
197
+ ## Extra output buckets
198
+
199
+ | Field | Buckets | Purpose |
200
+ | ----- | ------- | ------- |
201
+ | `hand_strength_bucket` | weak (< 0.42), medium, strong (<= 0.62), monster (>= 0.82) | Plain-language hand strength for prompts |
202
+ | `spr_bucket` | low (<= 1.5), medium (<= 4.0), deep | How committed we are relative to the pot |
203
+ | `abstract_actions` | fold, check, call, bet_33/66/100, raise_2_5x, raise_pot, all_in | Advisory sizing vocabulary when those lines are legal |
204
+ | `risk_label` | low / medium / high | Rough risk of the top action given SPR, board, and action type |
205
+
telltale/poker/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from telltale.poker.policy import PokerDecision, PokerPolicyConfig, PokerPolicyWriter
2
+
3
+ __all__ = ["PokerDecision", "PokerPolicyConfig", "PokerPolicyWriter"]
telltale/poker/native.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ from dataclasses import dataclass
5
+ import hashlib
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Sequence
11
+
12
+ from telltale.game.cards import Card
13
+
14
+
15
+ CardLike = Card | str
16
+ _ROOT = Path(__file__).resolve().parents[2]
17
+ _SOURCE_DIR = _ROOT / "telltale" / "native" / "poker_solver"
18
+ _BUILD_DIR = _ROOT / "build" / "native_poker_solver"
19
+ _LIB_NAME = "libtelltale_poker_solver.dylib" if sys.platform == "darwin" else "libtelltale_poker_solver.so"
20
+ _LIB_PATH = _BUILD_DIR / _LIB_NAME
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class HandRank:
25
+ category: int
26
+ tiebreakers: tuple[int, ...]
27
+ label: str
28
+
29
+ def as_tuple(self) -> tuple[int, ...]:
30
+ return (self.category, *self.tiebreakers)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class EquityResult:
35
+ wins: int
36
+ ties: int
37
+ losses: int
38
+ iterations: int
39
+ win_probability: float
40
+ tie_probability: float
41
+ loss_probability: float
42
+
43
+ @property
44
+ def equity(self) -> float:
45
+ return self.win_probability + (self.tie_probability * 0.5)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class RangeCombo:
50
+ first: CardLike
51
+ second: CardLike
52
+ weight: float = 1.0
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class DecisionFeatures:
57
+ street: str
58
+ hero_equity: float
59
+ pot_size: int
60
+ amount_to_call: int
61
+ pot_odds: float
62
+ stack_to_pot_ratio: float
63
+ players_remaining: int
64
+ can_check: bool
65
+ legal_actions: tuple[str, ...]
66
+ hero_stack: int = 0
67
+ minimum_raise_amount: int = 1
68
+ board_texture: str = "dry"
69
+ street_action_count: int = 0
70
+ previous_aggression_count: int = 0
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class PolicyRecommendation:
75
+ probabilities: dict[str, float]
76
+ suggested_action: str
77
+ suggested_amount: int
78
+ equity: float
79
+ pot_odds: float
80
+ confidence: float
81
+ explanation: str
82
+ risk_label: str = "low"
83
+ board_texture: str = "dry"
84
+ decision_margin: float = 0.0
85
+ amount_options: dict[str, int] | None = None
86
+ hand_strength_bucket: str = "medium"
87
+ spr_bucket: str = "medium"
88
+ abstract_actions: tuple[str, ...] = ()
89
+
90
+
91
+ class NativePokerSolver:
92
+ def estimate_equity(
93
+ self,
94
+ hero_cards: Sequence[CardLike],
95
+ board_cards: Sequence[CardLike] = (),
96
+ num_opponents: int = 1,
97
+ dead_cards: Sequence[CardLike] | None = None,
98
+ iterations: int = 1000,
99
+ seed: int | str | bytes | None = None,
100
+ opponent_count: int | None = None,
101
+ simulations: int | None = None,
102
+ ) -> EquityResult:
103
+ if opponent_count is not None:
104
+ num_opponents = opponent_count
105
+ if simulations is not None:
106
+ iterations = simulations
107
+ return estimate_equity(
108
+ hero_cards=hero_cards,
109
+ board_cards=board_cards,
110
+ num_opponents=num_opponents,
111
+ dead_cards=dead_cards,
112
+ iterations=iterations,
113
+ seed=seed,
114
+ )
115
+
116
+ def estimate_equity_vs_range(
117
+ self,
118
+ hero_cards: Sequence[CardLike],
119
+ board_cards: Sequence[CardLike],
120
+ opponent_range: Sequence[RangeCombo],
121
+ dead_cards: Sequence[CardLike] | None = None,
122
+ iterations: int = 1000,
123
+ seed: int | str | bytes | None = None,
124
+ ) -> EquityResult:
125
+ return estimate_equity_vs_range(
126
+ hero_cards=hero_cards,
127
+ board_cards=board_cards,
128
+ opponent_range=opponent_range,
129
+ dead_cards=dead_cards,
130
+ iterations=iterations,
131
+ seed=seed,
132
+ )
133
+
134
+
135
+ def native_available() -> bool:
136
+ try:
137
+ _load_library()
138
+ except RuntimeError:
139
+ return False
140
+ return True
141
+
142
+
143
+ def evaluate_7(cards: Sequence[CardLike]) -> HandRank:
144
+ data = _call_json("telltale_evaluate7", _cards_csv(cards))
145
+ return HandRank(data["category"], tuple(data["tiebreakers"]), data["label"])
146
+
147
+
148
+ def compare_hands(cards_a: Sequence[CardLike], cards_b: Sequence[CardLike]) -> int:
149
+ data = _call_json("telltale_compare7", _cards_csv(cards_a), _cards_csv(cards_b))
150
+ return int(data["comparison"])
151
+
152
+
153
+ def estimate_equity(
154
+ hero_cards: Sequence[CardLike],
155
+ board_cards: Sequence[CardLike],
156
+ num_opponents: int,
157
+ dead_cards: Sequence[CardLike] | None = None,
158
+ iterations: int = 1000,
159
+ seed: int | str | bytes | None = None,
160
+ ) -> EquityResult:
161
+ data = _call_json(
162
+ "telltale_estimate_equity",
163
+ _cards_csv(hero_cards),
164
+ _cards_csv(board_cards),
165
+ int(num_opponents),
166
+ _cards_csv(dead_cards or ()),
167
+ int(iterations),
168
+ _seed_to_uint64(seed),
169
+ )
170
+ return EquityResult(
171
+ wins=data["wins"],
172
+ ties=data["ties"],
173
+ losses=data["losses"],
174
+ iterations=data["iterations"],
175
+ win_probability=data["win_probability"],
176
+ tie_probability=data["tie_probability"],
177
+ loss_probability=data["loss_probability"],
178
+ )
179
+
180
+
181
+ def estimate_equity_vs_range(
182
+ hero_cards: Sequence[CardLike],
183
+ board_cards: Sequence[CardLike],
184
+ opponent_range: Sequence[RangeCombo],
185
+ dead_cards: Sequence[CardLike] | None = None,
186
+ iterations: int = 1000,
187
+ seed: int | str | bytes | None = None,
188
+ ) -> EquityResult:
189
+ data = _call_json(
190
+ "telltale_estimate_equity_vs_range",
191
+ _cards_csv(hero_cards),
192
+ _cards_csv(board_cards),
193
+ _range_csv(opponent_range),
194
+ _cards_csv(dead_cards or ()),
195
+ int(iterations),
196
+ _seed_to_uint64(seed),
197
+ )
198
+ return EquityResult(
199
+ wins=data["wins"],
200
+ ties=data["ties"],
201
+ losses=data["losses"],
202
+ iterations=data["iterations"],
203
+ win_probability=data["win_probability"],
204
+ tie_probability=data["tie_probability"],
205
+ loss_probability=data["loss_probability"],
206
+ )
207
+
208
+
209
+ def recommend_action(features: DecisionFeatures) -> PolicyRecommendation:
210
+ data = _call_json(
211
+ "telltale_recommend_action",
212
+ features.street,
213
+ float(features.hero_equity),
214
+ int(features.pot_size),
215
+ int(features.amount_to_call),
216
+ float(features.pot_odds),
217
+ float(features.stack_to_pot_ratio),
218
+ int(features.players_remaining),
219
+ bool(features.can_check),
220
+ ",".join(features.legal_actions),
221
+ int(features.hero_stack),
222
+ int(features.minimum_raise_amount),
223
+ features.board_texture,
224
+ int(features.street_action_count),
225
+ int(features.previous_aggression_count),
226
+ )
227
+ return PolicyRecommendation(
228
+ probabilities={str(action): float(probability) for action, probability in data["probabilities"].items()},
229
+ suggested_action=str(data["suggested_action"]),
230
+ suggested_amount=int(data["suggested_amount"]),
231
+ equity=float(data["equity"]),
232
+ pot_odds=float(data["pot_odds"]),
233
+ confidence=float(data["confidence"]),
234
+ explanation=str(data["explanation"]),
235
+ risk_label=str(data.get("risk_label", "low")),
236
+ board_texture=str(data.get("board_texture", features.board_texture)),
237
+ decision_margin=float(data.get("decision_margin", 0.0)),
238
+ amount_options={str(label): int(amount) for label, amount in data.get("amount_options", {}).items()},
239
+ hand_strength_bucket=str(data.get("hand_strength_bucket", "medium")),
240
+ spr_bucket=str(data.get("spr_bucket", "medium")),
241
+ abstract_actions=tuple(str(action) for action in data.get("abstract_actions", ())),
242
+ )
243
+
244
+
245
+ def _load_library() -> ctypes.CDLL:
246
+ if not _LIB_PATH.exists() or _native_sources_are_newer():
247
+ _build_library()
248
+ library = ctypes.CDLL(str(_LIB_PATH))
249
+ library.telltale_evaluate7.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
250
+ library.telltale_evaluate7.restype = ctypes.c_int
251
+ library.telltale_compare7.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
252
+ library.telltale_compare7.restype = ctypes.c_int
253
+ library.telltale_estimate_equity.argtypes = [
254
+ ctypes.c_char_p,
255
+ ctypes.c_char_p,
256
+ ctypes.c_int,
257
+ ctypes.c_char_p,
258
+ ctypes.c_int,
259
+ ctypes.c_ulonglong,
260
+ ctypes.c_char_p,
261
+ ctypes.c_int,
262
+ ]
263
+ library.telltale_estimate_equity.restype = ctypes.c_int
264
+ library.telltale_estimate_equity_vs_range.argtypes = [
265
+ ctypes.c_char_p,
266
+ ctypes.c_char_p,
267
+ ctypes.c_char_p,
268
+ ctypes.c_char_p,
269
+ ctypes.c_int,
270
+ ctypes.c_ulonglong,
271
+ ctypes.c_char_p,
272
+ ctypes.c_int,
273
+ ]
274
+ library.telltale_estimate_equity_vs_range.restype = ctypes.c_int
275
+ library.telltale_recommend_action.argtypes = [
276
+ ctypes.c_char_p,
277
+ ctypes.c_double,
278
+ ctypes.c_int,
279
+ ctypes.c_int,
280
+ ctypes.c_double,
281
+ ctypes.c_double,
282
+ ctypes.c_int,
283
+ ctypes.c_bool,
284
+ ctypes.c_char_p,
285
+ ctypes.c_int,
286
+ ctypes.c_int,
287
+ ctypes.c_char_p,
288
+ ctypes.c_int,
289
+ ctypes.c_int,
290
+ ctypes.c_char_p,
291
+ ctypes.c_int,
292
+ ]
293
+ library.telltale_recommend_action.restype = ctypes.c_int
294
+ return library
295
+
296
+
297
+ def _build_library() -> None:
298
+ _BUILD_DIR.mkdir(parents=True, exist_ok=True)
299
+ command = [
300
+ "c++",
301
+ "-std=c++17",
302
+ "-O3",
303
+ "-shared",
304
+ "-fPIC",
305
+ str(_SOURCE_DIR / "bindings.cpp"),
306
+ str(_SOURCE_DIR / "evaluator.cpp"),
307
+ str(_SOURCE_DIR / "equity.cpp"),
308
+ str(_SOURCE_DIR / "action_policy.cpp"),
309
+ "-o",
310
+ str(_LIB_PATH),
311
+ ]
312
+ try:
313
+ subprocess.run(command, check=True, capture_output=True, text=True)
314
+ except (OSError, subprocess.CalledProcessError) as error:
315
+ stderr = getattr(error, "stderr", "")
316
+ raise RuntimeError(f"native poker solver build failed: {stderr}") from error
317
+
318
+
319
+ def _native_sources_are_newer() -> bool:
320
+ if not _LIB_PATH.exists():
321
+ return True
322
+ library_mtime = _LIB_PATH.stat().st_mtime
323
+ for source in _SOURCE_DIR.glob("*.*"):
324
+ if source.suffix in {".cpp", ".hpp"} and source.stat().st_mtime > library_mtime:
325
+ return True
326
+ return False
327
+
328
+
329
+ def _call_json(function_name: str, *args) -> dict:
330
+ library = _load_library()
331
+ function = getattr(library, function_name)
332
+ encoded_args = [_encode_arg(arg) for arg in args]
333
+ output = ctypes.create_string_buffer(4096)
334
+ status = function(*encoded_args, output, ctypes.sizeof(output))
335
+ if status > ctypes.sizeof(output):
336
+ output = ctypes.create_string_buffer(status)
337
+ status = function(*encoded_args, output, ctypes.sizeof(output))
338
+ if status != 0:
339
+ raise RuntimeError(f"native call {function_name} failed with status {status}")
340
+ data = json.loads(output.value.decode("utf-8"))
341
+ if "error" in data:
342
+ raise ValueError(data["error"])
343
+ return data
344
+
345
+
346
+ def _encode_arg(value):
347
+ if isinstance(value, str):
348
+ return value.encode("utf-8")
349
+ return value
350
+
351
+
352
+ def _cards_csv(cards: Sequence[CardLike]) -> str:
353
+ return ",".join(str(card) for card in cards)
354
+
355
+
356
+ def _range_csv(opponent_range: Sequence[RangeCombo]) -> str:
357
+ return ",".join(
358
+ f"{combo.first}{combo.second}:{float(combo.weight)}"
359
+ for combo in opponent_range
360
+ )
361
+
362
+
363
+ def _seed_to_uint64(seed: int | str | bytes | None) -> int:
364
+ if seed is None:
365
+ return 0
366
+ if isinstance(seed, int):
367
+ return seed % (2**64)
368
+ if isinstance(seed, str):
369
+ seed = seed.encode("utf-8")
370
+ digest = hashlib.sha256(seed).digest()
371
+ return int.from_bytes(digest[:8], "little")
telltale/poker/policy.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from telltale.game.holdem import ActionType, HandState, PlayerState
6
+ from telltale.poker import native
7
+
8
+
9
+ RANK_ORDER = "23456789TJQKA"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PokerPolicyConfig:
14
+ simulations: int = 1_500
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class PokerDecision:
19
+ action: ActionType
20
+ amount: int = 0
21
+ equity: float = 0.0
22
+ pot_odds: float = 0.0
23
+ reason: str = ""
24
+
25
+
26
+ class PokerPolicyWriter:
27
+ """Writes solver equity into legal Hold'em actions."""
28
+
29
+ def __init__(
30
+ self,
31
+ solver: native.NativePokerSolver | None = None,
32
+ config: PokerPolicyConfig | None = None,
33
+ ):
34
+ self.solver = solver or native.NativePokerSolver()
35
+ self.config = config or PokerPolicyConfig()
36
+
37
+ def choose_action(
38
+ self,
39
+ hand: HandState,
40
+ player_id: str | None = None,
41
+ seed: int | str | bytes | None = None,
42
+ ) -> PokerDecision:
43
+ player = _actor_or_player(hand, player_id)
44
+ legal = hand.legal_actions(player.player_id)
45
+ if not legal:
46
+ return PokerDecision(ActionType.CHECK, reason="no legal betting action")
47
+
48
+ opponent_count = _active_opponent_count(hand, player)
49
+ result = self.solver.estimate_equity(
50
+ player.hole_cards,
51
+ hand.board_cards,
52
+ num_opponents=opponent_count,
53
+ iterations=self.config.simulations,
54
+ seed=seed,
55
+ )
56
+ pot_odds = _pot_odds(hand, player)
57
+ outstanding = hand.current_max_bet() - player.current_bet
58
+ features = native.DecisionFeatures(
59
+ street=hand.street.value,
60
+ hero_equity=result.equity,
61
+ pot_size=sum(hand.pot_contributions.values()),
62
+ amount_to_call=outstanding,
63
+ pot_odds=pot_odds,
64
+ stack_to_pot_ratio=_stack_to_pot_ratio(hand, player),
65
+ players_remaining=opponent_count + 1,
66
+ can_check=ActionType.CHECK in legal,
67
+ legal_actions=tuple(sorted(action.value for action in legal)),
68
+ hero_stack=player.stack,
69
+ minimum_raise_amount=hand.minimum_raise_amount,
70
+ board_texture=board_texture(hand),
71
+ street_action_count=_street_action_count(hand),
72
+ previous_aggression_count=_previous_aggression_count(hand),
73
+ )
74
+ recommendation = native.recommend_action(features)
75
+ chosen = ActionType(recommendation.suggested_action)
76
+
77
+ if chosen in {ActionType.BET, ActionType.RAISE}:
78
+ amount = _legal_bet_or_raise_amount(hand, player, chosen, recommendation.suggested_amount)
79
+ elif chosen == ActionType.ALL_IN:
80
+ amount = player.stack
81
+ else:
82
+ amount = 0
83
+ return PokerDecision(
84
+ chosen,
85
+ amount=amount,
86
+ equity=result.equity,
87
+ pot_odds=pot_odds,
88
+ reason=recommendation.explanation,
89
+ )
90
+
91
+ def apply_action(
92
+ self,
93
+ hand: HandState,
94
+ player_id: str | None = None,
95
+ seed: int | str | bytes | None = None,
96
+ ) -> PokerDecision:
97
+ decision = self.choose_action(hand, player_id=player_id, seed=seed)
98
+ hand.apply_action(decision.action, amount=decision.amount, player_id=player_id)
99
+ return decision
100
+
101
+
102
+ def _actor_or_player(hand: HandState, player_id: str | None) -> PlayerState:
103
+ if player_id is None:
104
+ if hand.current_actor_index is None:
105
+ raise ValueError("there is no current actor")
106
+ return hand.players[hand.current_actor_index]
107
+ for player in hand.players:
108
+ if player.player_id == player_id:
109
+ return player
110
+ raise ValueError(f"unknown player id: {player_id}")
111
+
112
+
113
+ def _active_opponent_count(hand: HandState, player: PlayerState) -> int:
114
+ opponents = 0
115
+ for other in hand.players:
116
+ if other.player_id != player.player_id and not other.has_folded:
117
+ opponents += 1
118
+ return max(1, opponents)
119
+
120
+
121
+ def _pot_odds(hand: HandState, player: PlayerState) -> float:
122
+ outstanding = hand.current_max_bet() - player.current_bet
123
+ if outstanding <= 0:
124
+ return 0.0
125
+ pot = sum(hand.pot_contributions.values())
126
+ return outstanding / (pot + outstanding)
127
+
128
+
129
+ def _legal_bet_or_raise_amount(
130
+ hand: HandState,
131
+ player: PlayerState,
132
+ action: ActionType,
133
+ suggested_amount: int,
134
+ ) -> int:
135
+ if action == ActionType.BET:
136
+ return min(player.stack, max(hand.minimum_raise_amount, suggested_amount))
137
+ if action == ActionType.RAISE:
138
+ outstanding = hand.current_max_bet() - player.current_bet
139
+ minimum_amount = outstanding + hand.minimum_raise_amount
140
+ return min(player.stack, max(minimum_amount, suggested_amount))
141
+ return 0
142
+
143
+
144
+ def _stack_to_pot_ratio(hand: HandState, player: PlayerState) -> float:
145
+ pot = sum(hand.pot_contributions.values())
146
+ if pot <= 0:
147
+ return float(player.stack)
148
+ return player.stack / pot
149
+
150
+
151
+ def board_texture(hand: HandState) -> str:
152
+ board = hand.board_cards
153
+ if len(board) < 3:
154
+ return "dry"
155
+
156
+ ranks = [_rank_value(card.rank) for card in board]
157
+ suits = [card.suit for card in board]
158
+ unique_suits = set(suits)
159
+ paired = len(set(ranks)) < len(ranks)
160
+ monotone = len(unique_suits) == 1
161
+ two_tone = len(unique_suits) == 2
162
+ connected = _is_connected(ranks)
163
+
164
+ if monotone:
165
+ return "monotone"
166
+ if connected and (two_tone or paired):
167
+ return "wet"
168
+ if connected:
169
+ return "connected"
170
+ if paired:
171
+ return "paired"
172
+ if two_tone:
173
+ return "two_tone"
174
+ return "dry"
175
+
176
+
177
+ def _rank_value(rank: str) -> int:
178
+ return RANK_ORDER.index(rank) + 2
179
+
180
+
181
+ def _is_connected(ranks: list[int]) -> bool:
182
+ unique = sorted(set(ranks))
183
+ if len(unique) < 3:
184
+ return False
185
+ for window_start in range(len(unique) - 2):
186
+ window = unique[window_start : window_start + 3]
187
+ if window[-1] - window[0] <= 4:
188
+ return True
189
+ if 14 in unique:
190
+ wheel = sorted({1 if rank == 14 else rank for rank in unique})
191
+ for window_start in range(len(wheel) - 2):
192
+ window = wheel[window_start : window_start + 3]
193
+ if window[-1] - window[0] <= 4:
194
+ return True
195
+ return False
196
+
197
+
198
+ def _street_action_count(hand: HandState) -> int:
199
+ return sum(1 for record in hand.action_history if record.street == hand.street)
200
+
201
+
202
+ def _previous_aggression_count(hand: HandState) -> int:
203
+ return sum(
204
+ 1
205
+ for record in hand.action_history
206
+ if record.action in {ActionType.BET, ActionType.RAISE, ActionType.ALL_IN}
207
+ )
telltale/server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
telltale/server/api.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from random import Random
7
+ from typing import Any
8
+
9
+ from telltale.agents.decision import AgentDecision, parse_agent_decision, validate_and_repair
10
+ from telltale.agents.dialogue import PlayerUtterance
11
+ from telltale.agents.memory import AgentMemory
12
+ from telltale.agents.profiles import (
13
+ DEFAULT_FINAL_BOSS_ID,
14
+ AgentProfile,
15
+ get_agent_profile,
16
+ profiles_for_floor,
17
+ )
18
+ from telltale.agents.prompts import build_agent_prompt
19
+ from telltale.agents.trace import TraceLogger
20
+ from telltale.game.economy import RunState, RunStatus, derive_seed
21
+ from telltale.game.floors import FLOOR_CONFIGS, FloorConfig
22
+ from telltale.game.holdem import ActionType, HandState, PlayerState, PokerError, Street
23
+ from telltale.game.perks import PERK_DEFINITIONS
24
+ from telltale.models.llama_runtime import LocalTextRuntime, RuntimeSettings
25
+ from telltale.models.stt import STTRuntime
26
+ from telltale.models.tts import TTSRuntime
27
+ from telltale.poker.policy import PokerDecision, PokerPolicyConfig, PokerPolicyWriter
28
+ from telltale.server.events import EventBatch, EventBuilder
29
+
30
+
31
+ HUMAN_PLAYER_ID = "player"
32
+ DEMO_SEED = 77713
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class RunSettings:
37
+ model_mode: str | None = None
38
+ seed: int | None = None
39
+ tts_enabled: bool = False
40
+ stt_enabled: bool = False
41
+ trace_enabled: bool = True
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TraceExport:
46
+ run_id: str
47
+ path: str | None
48
+ content: str
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return asdict(self)
52
+
53
+
54
+ @dataclass
55
+ class GameSession:
56
+ run: RunState
57
+ runtime: LocalTextRuntime
58
+ tts: TTSRuntime
59
+ stt: STTRuntime
60
+ trace: TraceLogger
61
+ policy: PokerPolicyWriter
62
+ sequence: int = 0
63
+ hand_counter: int = 0
64
+ dealer_index: int = 0
65
+ opponent_profiles: list[AgentProfile] | None = None
66
+ memories: dict[str, AgentMemory] | None = None
67
+ latest_speech: dict[str, str] | None = None
68
+ last_player_utterance: PlayerUtterance | None = None
69
+
70
+ def __post_init__(self) -> None:
71
+ self.opponent_profiles = self.opponent_profiles or []
72
+ self.memories = self.memories or {}
73
+ self.latest_speech = self.latest_speech or {}
74
+
75
+
76
+ _SESSIONS: dict[str, GameSession] = {}
77
+
78
+
79
+ def start_run(seed: int | None = None, settings: RunSettings | dict[str, Any] | None = None) -> dict[str, Any]:
80
+ run_settings = _coerce_settings(settings)
81
+ run_seed = seed if seed is not None else run_settings.seed
82
+ run = RunState.start(seed=run_seed, bankroll=260)
83
+ run.enter_current_floor()
84
+ runtime_settings = RuntimeSettings.from_env()
85
+ runtime_settings = RuntimeSettings(
86
+ mode=run_settings.model_mode or runtime_settings.mode,
87
+ model_name=runtime_settings.model_name,
88
+ gguf_path=runtime_settings.gguf_path,
89
+ context_size=runtime_settings.context_size,
90
+ max_tokens=runtime_settings.max_tokens,
91
+ temperature=runtime_settings.temperature,
92
+ seed=runtime_settings.seed,
93
+ )
94
+ session = GameSession(
95
+ run=run,
96
+ runtime=LocalTextRuntime(runtime_settings),
97
+ tts=TTSRuntime(enabled=run_settings.tts_enabled),
98
+ stt=STTRuntime(enabled=run_settings.stt_enabled),
99
+ trace=TraceLogger(run.run_id),
100
+ policy=PokerPolicyWriter(config=PokerPolicyConfig(simulations=250)),
101
+ )
102
+ _SESSIONS[run.run_id] = session
103
+ builder = EventBuilder(run.run_id, session.sequence)
104
+ builder.emit("run_started", seed=run.seed, bankroll=run.bankroll)
105
+ builder.emit("floor_intro", **_floor_payload(run))
106
+ _start_hand(session, builder)
107
+ batch = _continue_until_player_turn(session, builder)
108
+ return batch.public_state
109
+
110
+
111
+ def list_floors() -> dict[str, Any]:
112
+ return {
113
+ "floors": [_floor_config_payload(floor) for floor in FLOOR_CONFIGS],
114
+ "total_floors": len(FLOOR_CONFIGS),
115
+ }
116
+
117
+
118
+ def get_state(run_id: str) -> dict[str, Any]:
119
+ return _public_state(_get_session(run_id))
120
+
121
+
122
+ def submit_player_action(
123
+ run_id: str,
124
+ action: str,
125
+ amount: int | None = None,
126
+ utterance: str | None = None,
127
+ audio: bytes | None = None,
128
+ ) -> dict[str, Any]:
129
+ session = _get_session(run_id)
130
+ builder = EventBuilder(run_id, session.sequence)
131
+ if audio:
132
+ transcription = session.stt.transcribe(audio)
133
+ if transcription.text and not utterance:
134
+ utterance = transcription.text
135
+ elif transcription.error:
136
+ builder.emit("stt_failed", error=transcription.error)
137
+ session.last_player_utterance = PlayerUtterance(raw_text=utterance or "")
138
+ hand = _require_hand(session)
139
+ if hand.street == Street.COMPLETE:
140
+ _resolve_completed_hand(session, builder)
141
+ else:
142
+ actor = _current_actor(hand)
143
+ if actor.player_id != HUMAN_PLAYER_ID:
144
+ raise ValueError("it is not the player's turn")
145
+ legal = {item.value for item in hand.legal_actions(HUMAN_PLAYER_ID)}
146
+ if action not in legal:
147
+ raise ValueError(f"{action} is not legal right now")
148
+ paid = _apply_action(hand, HUMAN_PLAYER_ID, ActionType(action), amount or 0)
149
+ builder.emit(
150
+ "action_taken",
151
+ player_id=HUMAN_PLAYER_ID,
152
+ name="You",
153
+ action=action,
154
+ amount=paid,
155
+ utterance=utterance or "",
156
+ )
157
+ if utterance:
158
+ session.latest_speech[HUMAN_PLAYER_ID] = utterance
159
+ builder.emit("player_spoke", player_id=HUMAN_PLAYER_ID, text=utterance)
160
+ if hand.street == Street.COMPLETE:
161
+ _resolve_completed_hand(session, builder)
162
+ session.sequence = builder.sequence
163
+ return _continue_until_player_turn(session, builder).to_dict()
164
+
165
+
166
+ def continue_until_player_turn(run_id: str) -> dict[str, Any]:
167
+ session = _get_session(run_id)
168
+ builder = EventBuilder(run_id, session.sequence)
169
+ return _continue_until_player_turn(session, builder).to_dict()
170
+
171
+
172
+ def choose_reward(run_id: str, perk_id: str) -> dict[str, Any]:
173
+ session = _get_session(run_id)
174
+ builder = EventBuilder(run_id, session.sequence)
175
+ session.run.choose_reward(perk_id)
176
+ builder.emit("reward_chosen", perk_id=perk_id)
177
+ if session.run.status == RunStatus.ACTIVE:
178
+ session.run.enter_current_floor()
179
+ builder.emit("floor_intro", **_floor_payload(session.run))
180
+ _start_hand(session, builder)
181
+ session.sequence = builder.sequence
182
+ return _continue_until_player_turn(session, builder).to_dict()
183
+
184
+
185
+ def export_trace(run_id: str) -> dict[str, Any]:
186
+ session = _get_session(run_id)
187
+ path = str(session.trace.path) if session.trace.path.exists() else None
188
+ return TraceExport(run_id=run_id, path=path, content=session.trace.export_text()).to_dict()
189
+
190
+
191
+ def transcribe_player_audio(run_id: str, audio: bytes) -> dict[str, Any]:
192
+ session = _get_session(run_id)
193
+ result = session.stt.transcribe(audio)
194
+ return {
195
+ "text": result.text,
196
+ "confidence": result.confidence,
197
+ "disabled": result.disabled,
198
+ "error": result.error,
199
+ }
200
+
201
+
202
+ def get_tts_audio(audio_id: str) -> dict[str, Any]:
203
+ if "/" in audio_id or "\\" in audio_id or audio_id in {"", ".", ".."}:
204
+ raise ValueError("invalid audio id")
205
+ path = (Path("runs/tts_cache") / audio_id).resolve()
206
+ cache_root = Path("runs/tts_cache").resolve()
207
+ if cache_root not in path.parents:
208
+ raise ValueError("invalid audio id")
209
+ if not path.is_file():
210
+ raise KeyError(f"unknown audio id: {audio_id}")
211
+ return {"path": str(path), "mime_type": "audio/wav"}
212
+
213
+
214
+ def reset_sessions() -> None:
215
+ _SESSIONS.clear()
216
+
217
+
218
+ def _continue_until_player_turn(session: GameSession, builder: EventBuilder) -> EventBatch:
219
+ while session.run.status == RunStatus.ACTIVE and not session.run.awaiting_reward:
220
+ hand = _require_hand(session)
221
+ if hand.street == Street.COMPLETE:
222
+ _resolve_completed_hand(session, builder)
223
+ continue
224
+ actor = _current_actor(hand)
225
+ if actor.player_id == HUMAN_PLAYER_ID:
226
+ builder.emit("player_turn", legal_actions=_legal_actions_payload(hand), pot=_pot(hand))
227
+ break
228
+ _agent_turn(session, builder, actor)
229
+ session.sequence = builder.sequence
230
+ public_state = _public_state(session)
231
+ return EventBatch(session.run.run_id, list(builder.events), public_state)
232
+
233
+
234
+ def _agent_turn(session: GameSession, builder: EventBuilder, actor: PlayerState) -> None:
235
+ hand = _require_hand(session)
236
+ profile = _profile_for_player(session, actor.player_id)
237
+ memory = session.memories.setdefault(profile.agent_id, AgentMemory.neutral(profile.agent_id))
238
+ legal = hand.legal_actions(actor.player_id)
239
+ solver_decision = session.policy.choose_action(
240
+ hand,
241
+ player_id=actor.player_id,
242
+ seed=derive_seed(session.run.seed, "policy", hand.hand_id, len(hand.action_history)),
243
+ )
244
+ public_state = hand.to_public_state(viewer_player_id=actor.player_id)
245
+ private_state = {
246
+ "hole_cards": [str(card) for card in actor.hole_cards],
247
+ "stack": actor.stack,
248
+ "hand_summary": _hand_summary(actor, hand),
249
+ }
250
+ utterance = session.last_player_utterance
251
+ prompt = build_agent_prompt(
252
+ profile,
253
+ memory,
254
+ public_state,
255
+ private_state,
256
+ solver_decision,
257
+ utterance,
258
+ legal,
259
+ )
260
+ generation = session.runtime.generate(
261
+ prompt,
262
+ context={
263
+ "agent_name": profile.name,
264
+ "suggested_action": solver_decision.action.value,
265
+ "suggested_amount": solver_decision.amount,
266
+ "player_utterance": utterance.raw_text if utterance else "",
267
+ },
268
+ )
269
+ repair_applied = False
270
+ repair_reason = ""
271
+ try:
272
+ decision = parse_agent_decision(generation.text)
273
+ decision = validate_and_repair(decision, legal, solver_decision)
274
+ except Exception as error:
275
+ repair_applied = True
276
+ repair_reason = f"model output repaired after parse/validation error: {error}"
277
+ decision = AgentDecision(
278
+ action=solver_decision.action,
279
+ amount=solver_decision.amount,
280
+ speech=f"{profile.name}: I will keep this legal.",
281
+ honest_rationale=solver_decision.reason or "The repaired action follows the solver recommendation.",
282
+ emotional_state="composed",
283
+ memory_delta=memory_delta_empty(),
284
+ )
285
+ before = memory.to_dict()
286
+ paid = _safe_apply_agent_decision(hand, actor.player_id, decision, solver_decision)
287
+ memory.apply_delta(decision.memory_delta)
288
+ after = memory.to_dict()
289
+ session.latest_speech[actor.player_id] = decision.speech
290
+ builder.emit(
291
+ "action_taken",
292
+ player_id=actor.player_id,
293
+ name=actor.name,
294
+ action=_action_value(decision.action),
295
+ amount=paid,
296
+ )
297
+ builder.emit("agent_spoke", player_id=actor.player_id, name=actor.name, text=decision.speech)
298
+ tts = session.tts.synthesize(decision.speech, profile.voice_id)
299
+ if tts.audio_path:
300
+ audio_id = Path(tts.audio_path).name
301
+ builder.emit(
302
+ "tts_ready",
303
+ player_id=actor.player_id,
304
+ audio_id=audio_id,
305
+ audio_url=f"/api/audio/{audio_id}",
306
+ mime_type=tts.mime_type,
307
+ )
308
+ elif tts.error and not tts.disabled:
309
+ builder.emit("tts_failed", player_id=actor.player_id, error=tts.error)
310
+ session.trace.record(
311
+ floor_index=session.run.floor_index,
312
+ hand_id=hand.hand_id,
313
+ action_index=len(hand.action_history),
314
+ agent_id=profile.agent_id,
315
+ public_state=public_state,
316
+ private_agent_state=private_state,
317
+ solver_recommendation=solver_decision,
318
+ player_utterance=utterance.to_dict() if utterance else None,
319
+ memory_before=before,
320
+ prompt=prompt,
321
+ raw_model_output=generation.text,
322
+ parsed_model_output=decision,
323
+ repair_applied=repair_applied,
324
+ repair_reason=repair_reason,
325
+ final_action=_action_value(decision.action),
326
+ final_amount=paid,
327
+ speech=decision.speech,
328
+ honest_rationale=decision.honest_rationale,
329
+ memory_delta=decision.memory_delta,
330
+ memory_after=after,
331
+ runtime_mode=session.runtime.mode,
332
+ model_metadata=generation.metadata | {
333
+ "tokens_per_second": generation.tokens_per_second,
334
+ "latency_ms": generation.latency_ms,
335
+ },
336
+ )
337
+ if hand.street == Street.COMPLETE:
338
+ _resolve_completed_hand(session, builder)
339
+
340
+
341
+ def _safe_apply_agent_decision(
342
+ hand: HandState,
343
+ player_id: str,
344
+ decision: AgentDecision,
345
+ solver_decision: PokerDecision,
346
+ ) -> int:
347
+ try:
348
+ return _apply_action(hand, player_id, ActionType(_action_value(decision.action)), decision.amount)
349
+ except (PokerError, ValueError):
350
+ return _apply_action(hand, player_id, solver_decision.action, solver_decision.amount)
351
+
352
+
353
+ def _apply_action(hand: HandState, player_id: str, action: ActionType, amount: int) -> int:
354
+ before = hand.pot_contributions[player_id]
355
+ hand.apply_action(action, amount=amount, player_id=player_id)
356
+ return hand.pot_contributions[player_id] - before
357
+
358
+
359
+ def _resolve_completed_hand(session: GameSession, builder: EventBuilder) -> None:
360
+ hand = _require_hand(session)
361
+ if hand.street != Street.COMPLETE:
362
+ return
363
+ player = _player_by_id(hand, HUMAN_PLAYER_ID)
364
+ opponents = [item for item in hand.players if item.player_id != HUMAN_PLAYER_ID]
365
+ if session.run.current_table is not None:
366
+ session.run.current_table.player_stack = player.stack
367
+ session.run.current_table.opponent_stacks = [opponent.stack for opponent in opponents]
368
+ session.run.current_table.hand_index += 1
369
+ builder.emit(
370
+ "hand_complete",
371
+ player_stack=player.stack,
372
+ opponent_stacks=[opponent.stack for opponent in opponents],
373
+ pot=_pot(hand),
374
+ board_cards=[str(card) for card in hand.board_cards],
375
+ )
376
+ floor = session.run.current_floor
377
+ if player.stack <= 0:
378
+ session.run.lose_current_floor(all_in_loss=True)
379
+ if session.run.status == RunStatus.LOST:
380
+ builder.emit("run_lost", reason="player_busted", objective="Win all five floors.")
381
+ elif session.run.status == RunStatus.ACTIVE:
382
+ builder.emit("debt_accepted", debt_markers=session.run.continues, bankroll=session.run.bankroll)
383
+ session.run.enter_current_floor()
384
+ builder.emit("floor_intro", **_floor_payload(session.run))
385
+ _start_hand(session, builder)
386
+ return
387
+ if player.stack >= floor.win_target or all(opponent.stack <= 0 for opponent in opponents):
388
+ session.run.win_current_floor(ending_stack=player.stack)
389
+ builder.emit("floor_won", floor_number=floor.floor_number, bankroll=session.run.bankroll)
390
+ if session.run.status == RunStatus.WON:
391
+ builder.emit("run_won", objective="You cleared the casino.")
392
+ else:
393
+ builder.emit("reward_offered", rewards=session.run.available_rewards)
394
+ return
395
+ _start_hand(session, builder)
396
+
397
+
398
+ def _start_hand(session: GameSession, builder: EventBuilder) -> None:
399
+ run = session.run
400
+ if run.current_table is None:
401
+ run.enter_current_floor()
402
+ assert run.current_table is not None
403
+ profiles = _select_profiles(run.floor_index, len(run.current_table.opponent_stacks), run.seed)
404
+ session.opponent_profiles = profiles
405
+ for profile in profiles:
406
+ session.memories.setdefault(profile.agent_id, AgentMemory.neutral(profile.agent_id))
407
+ player_stack = run.current_table.player_stack
408
+ opponent_stacks = run.current_table.opponent_stacks
409
+ if player_stack <= 0:
410
+ run.lose_current_floor(all_in_loss=True)
411
+ return
412
+ players = [PlayerState(HUMAN_PLAYER_ID, "You", 0, player_stack, is_human=True)]
413
+ for index, (profile, stack) in enumerate(zip(profiles, opponent_stacks, strict=False), start=1):
414
+ if stack > 0:
415
+ players.append(PlayerState(profile.agent_id, profile.name, index, stack))
416
+ if len(players) < 2:
417
+ run.win_current_floor(ending_stack=player_stack)
418
+ return
419
+ session.hand_counter += 1
420
+ hand_seed = run.hand_seed(hand_index=session.hand_counter)
421
+ dealer_index = session.dealer_index % len(players)
422
+ if run.current_table.pending_metadata.get("player_acts_last_first_hand") and run.current_table.hand_index == 0:
423
+ dealer_index = max(0, len(players) - 2)
424
+ run.current_hand = HandState.start(
425
+ players,
426
+ seed=hand_seed,
427
+ dealer_button_index=dealer_index,
428
+ small_blind=run.current_table.small_blind,
429
+ big_blind=run.current_table.big_blind,
430
+ hand_id=f"{run.run_id}-h{session.hand_counter}",
431
+ )
432
+ session.dealer_index = (dealer_index + 1) % len(players)
433
+ builder.emit(
434
+ "hand_started",
435
+ hand_id=run.current_hand.hand_id,
436
+ floor_number=run.current_floor.floor_number,
437
+ blinds={"small": run.current_table.small_blind, "big": run.current_table.big_blind},
438
+ )
439
+ builder.emit("cards_dealt", player_cards=[str(card) for card in players[0].hole_cards])
440
+
441
+
442
+ def _public_state(session: GameSession) -> dict[str, Any]:
443
+ run = session.run
444
+ hand = run.current_hand
445
+ hand_state = hand.to_public_state(HUMAN_PLAYER_ID) if hand is not None else None
446
+ floor = run.current_floor if run.status == RunStatus.ACTIVE else None
447
+ legal_actions = hand_state["legal_actions"] if hand_state else []
448
+ return {
449
+ "run_id": run.run_id,
450
+ "seed": run.seed,
451
+ "status": run.status.value,
452
+ "objective": "Win the run by clearing all five floors. Current floor win target is the next chip goal.",
453
+ "floor_index": run.floor_index,
454
+ "floor": _floor_payload(run) if floor else None,
455
+ "bankroll": run.bankroll,
456
+ "debt_markers": run.continues,
457
+ "active_perks": [perk.serialize() for perk in run.active_perks],
458
+ "completed_floors": list(run.completed_floors),
459
+ "hand": hand_state,
460
+ "pot": _pot(hand) if hand else 0,
461
+ "legal_actions": legal_actions,
462
+ "latest_speech": dict(session.latest_speech or {}),
463
+ "reward_choices": [_perk_payload(perk_id) for perk_id in run.available_rewards],
464
+ "awaiting_reward": run.awaiting_reward,
465
+ "model": session.runtime.metadata(),
466
+ "voice": {"tts_enabled": session.tts.enabled, "stt_enabled": session.stt.enabled},
467
+ "trace_available": bool(session.trace.records or session.trace.path.exists()),
468
+ }
469
+
470
+
471
+ def _floor_config_payload(floor: FloorConfig) -> dict[str, Any]:
472
+ return {
473
+ "floor_number": floor.floor_number,
474
+ "name": floor.name,
475
+ "buy_in": floor.buy_in,
476
+ "win_target": floor.win_target,
477
+ "small_blind": floor.blinds.small_blind,
478
+ "big_blind": floor.blinds.big_blind,
479
+ "is_boss": floor.is_boss,
480
+ "opponent_count_min": floor.opponent_count_min,
481
+ "opponent_count_max": floor.opponent_count_max,
482
+ }
483
+
484
+
485
+ def _floor_payload(run: RunState) -> dict[str, Any]:
486
+ return {
487
+ **_floor_config_payload(run.current_floor),
488
+ "total_floors": len(FLOOR_CONFIGS),
489
+ }
490
+
491
+
492
+ def _perk_payload(perk_id: str) -> dict[str, Any]:
493
+ for perk in PERK_DEFINITIONS:
494
+ if perk.perk_id == perk_id:
495
+ return perk.serialize()
496
+ return {"perk_id": perk_id, "name": perk_id, "description": ""}
497
+
498
+
499
+ def _select_profiles(floor_index: int, opponent_count: int, seed: str) -> list[AgentProfile]:
500
+ floor_number = floor_index + 1
501
+ if FLOOR_CONFIGS[floor_index].is_boss:
502
+ boss = get_agent_profile(DEFAULT_FINAL_BOSS_ID)
503
+ pool = [profile for profile in profiles_for_floor(floor_number) if profile.agent_id != boss.agent_id]
504
+ return [boss, *pool[: max(0, opponent_count - 1)]]
505
+ pool = list(profiles_for_floor(floor_number, include_bosses=False))
506
+ Random(derive_seed(seed, "profiles", floor_number)).shuffle(pool)
507
+ return pool[:opponent_count]
508
+
509
+
510
+ def _profile_for_player(session: GameSession, player_id: str) -> AgentProfile:
511
+ for profile in session.opponent_profiles or []:
512
+ if profile.agent_id == player_id:
513
+ return profile
514
+ return get_agent_profile(player_id)
515
+
516
+
517
+ def _legal_actions_payload(hand: HandState) -> list[str]:
518
+ return sorted(action.value for action in hand.legal_actions(HUMAN_PLAYER_ID))
519
+
520
+
521
+ def _hand_summary(player: PlayerState, hand: HandState) -> str:
522
+ return f"{player.name} has {' '.join(str(card) for card in player.hole_cards)} on {hand.street.value}."
523
+
524
+
525
+ def _current_actor(hand: HandState) -> PlayerState:
526
+ if hand.current_actor_index is None:
527
+ raise PokerError("there is no current actor")
528
+ return hand.players[hand.current_actor_index]
529
+
530
+
531
+ def _player_by_id(hand: HandState, player_id: str) -> PlayerState:
532
+ for player in hand.players:
533
+ if player.player_id == player_id:
534
+ return player
535
+ raise PokerError(f"unknown player id: {player_id}")
536
+
537
+
538
+ def _require_hand(session: GameSession) -> HandState:
539
+ if session.run.current_hand is None:
540
+ raise PokerError("no active hand")
541
+ return session.run.current_hand
542
+
543
+
544
+ def _pot(hand: HandState | None) -> int:
545
+ if hand is None:
546
+ return 0
547
+ return sum(hand.pot_contributions.values())
548
+
549
+
550
+ def _coerce_settings(settings: RunSettings | dict[str, Any] | None) -> RunSettings:
551
+ if settings is None:
552
+ return RunSettings()
553
+ if isinstance(settings, RunSettings):
554
+ return settings
555
+ return RunSettings(**settings)
556
+
557
+
558
+ def _get_session(run_id: str) -> GameSession:
559
+ try:
560
+ return _SESSIONS[run_id]
561
+ except KeyError as error:
562
+ raise KeyError(f"unknown run id: {run_id}") from error
563
+
564
+
565
+ def _action_value(action: ActionType | str | Enum) -> str:
566
+ if isinstance(action, Enum):
567
+ return str(action.value)
568
+ return str(action)
569
+
570
+
571
+ def memory_delta_empty():
572
+ from telltale.agents.memory import MemoryDelta
573
+
574
+ return MemoryDelta()
575
+
576
+
577
+ __all__ = [
578
+ "DEMO_SEED",
579
+ "HUMAN_PLAYER_ID",
580
+ "RunSettings",
581
+ "TraceExport",
582
+ "choose_reward",
583
+ "continue_until_player_turn",
584
+ "export_trace",
585
+ "get_tts_audio",
586
+ "get_state",
587
+ "reset_sessions",
588
+ "start_run",
589
+ "submit_player_action",
590
+ "transcribe_player_audio",
591
+ ]
telltale/server/events.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from uuid import uuid4
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class GameEvent:
11
+ event_id: str
12
+ event_type: str
13
+ run_id: str
14
+ sequence: int
15
+ payload: dict[str, Any] = field(default_factory=dict)
16
+
17
+ def to_dict(self) -> dict[str, Any]:
18
+ return {
19
+ "event_id": self.event_id,
20
+ "event_type": self.event_type,
21
+ "run_id": self.run_id,
22
+ "sequence": self.sequence,
23
+ "payload": self.payload,
24
+ }
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class EventBatch:
29
+ run_id: str
30
+ events: list[GameEvent]
31
+ public_state: dict[str, Any]
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "run_id": self.run_id,
36
+ "events": [event.to_dict() for event in self.events],
37
+ "public_state": self.public_state,
38
+ }
39
+
40
+ def to_json(self) -> str:
41
+ return json.dumps(self.to_dict(), default=str)
42
+
43
+
44
+ class EventBuilder:
45
+ def __init__(self, run_id: str, start_sequence: int = 0):
46
+ self.run_id = run_id
47
+ self._sequence = start_sequence
48
+ self.events: list[GameEvent] = []
49
+
50
+ @property
51
+ def sequence(self) -> int:
52
+ return self._sequence
53
+
54
+ def emit(self, event_type: str, **payload: Any) -> GameEvent:
55
+ self._sequence += 1
56
+ event = GameEvent(
57
+ event_id=str(uuid4()),
58
+ event_type=event_type,
59
+ run_id=self.run_id,
60
+ sequence=self._sequence,
61
+ payload=payload,
62
+ )
63
+ self.events.append(event)
64
+ return event
telltale/server/schemas.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from telltale.game.economy import RunStatus
6
+ from telltale.game.holdem import ActionType, Street
7
+
8
+
9
+ class ActionRequest(BaseModel):
10
+ player_id: str
11
+ action: ActionType
12
+ amount: int = Field(default=0, ge=0)
13
+ table_talk: "PlayerUtteranceRequest | None" = None
14
+
15
+
16
+ class PlayerUtteranceRequest(BaseModel):
17
+ raw_text: str = ""
18
+ target_agent_id: str | None = None
19
+
20
+
21
+ class AgentMemoryUpdateSchema(BaseModel):
22
+ respect_for_player: float | None = None
23
+ fear_of_player: float | None = None
24
+ charmed_by_player: float | None = None
25
+ grudge_against_player: float | None = None
26
+ recent_player_patterns: list[str] = Field(default_factory=list)
27
+ recent_dialogue_impressions: list[str] = Field(default_factory=list)
28
+ summary: str | None = None
29
+
30
+
31
+ class AgentDecisionSchema(BaseModel):
32
+ action: ActionType
33
+ amount: int = Field(default=0, ge=0)
34
+ speech: str
35
+ honest_rationale: str
36
+ emotional_state: str
37
+ memory_delta: AgentMemoryUpdateSchema = Field(default_factory=AgentMemoryUpdateSchema)
38
+ source: str = "model"
39
+
40
+
41
+ class PlayerPublicState(BaseModel):
42
+ player_id: str
43
+ name: str
44
+ seat_index: int
45
+ stack: int
46
+ hole_cards: list[str]
47
+ current_bet: int
48
+ has_folded: bool
49
+ is_all_in: bool
50
+ is_human: bool
51
+
52
+
53
+ class HandPublicState(BaseModel):
54
+ hand_id: str
55
+ street: Street
56
+ board_cards: list[str]
57
+ players: list[PlayerPublicState]
58
+ pot_contributions: dict[str, int]
59
+ current_actor_index: int | None
60
+ legal_actions: list[ActionType]
61
+ action_history: list[dict]
62
+
63
+
64
+ class PerkPublicState(BaseModel):
65
+ perk_id: str
66
+ name: str
67
+ description: str
68
+ trigger_timing: str
69
+ remaining_uses: int | None = None
70
+ remaining_floors: int | None = None
71
+ metadata: dict = Field(default_factory=dict)
72
+
73
+
74
+ class TablePublicState(BaseModel):
75
+ floor_number: int
76
+ seed: str
77
+ player_stack: int
78
+ opponent_stacks: list[int]
79
+ small_blind: int
80
+ big_blind: int
81
+ hand_index: int
82
+ pending_metadata: dict = Field(default_factory=dict)
83
+
84
+
85
+ class RunPublicState(BaseModel):
86
+ run_id: str
87
+ seed: str
88
+ floor_index: int
89
+ floor_number: int | None
90
+ bankroll: int
91
+ continues: int
92
+ active_perks: list[PerkPublicState]
93
+ completed_floors: list[int]
94
+ status: RunStatus
95
+ current_table: TablePublicState | None
96
+ available_rewards: list[str]
97
+ awaiting_reward: bool
98
+ event_log: list[dict]