smpdl commited on
Commit ·
60daaa6
0
Parent(s):
Deploy ZeroGPU Space snapshot
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +237 -0
- LICENSE +21 -0
- README.md +123 -0
- app.py +222 -0
- configs/model_candidates.comparison.json +28 -0
- pyproject.toml +28 -0
- requirements.txt +11 -0
- scripts/evaluate_models.py +5 -0
- scripts/modal/evaluate_models.py +212 -0
- telltale/__init__.py +1 -0
- telltale/agents/__init__.py +26 -0
- telltale/agents/decision.py +218 -0
- telltale/agents/dialogue.py +20 -0
- telltale/agents/memory.py +135 -0
- telltale/agents/profiles.py +334 -0
- telltale/agents/prompts.py +117 -0
- telltale/agents/trace.py +45 -0
- telltale/game/__init__.py +23 -0
- telltale/game/cards.py +100 -0
- telltale/game/economy.py +329 -0
- telltale/game/engine.py +54 -0
- telltale/game/floors.py +112 -0
- telltale/game/holdem.py +626 -0
- telltale/game/perks.py +178 -0
- telltale/models/__init__.py +9 -0
- telltale/models/eval_cli.py +223 -0
- telltale/models/eval_prompts.py +238 -0
- telltale/models/eval_runner.py +456 -0
- telltale/models/llama_runtime.py +427 -0
- telltale/models/stt.py +25 -0
- telltale/models/tts.py +49 -0
- telltale/native/__init__.py +1 -0
- telltale/native/poker_solver/CMakeLists.txt +14 -0
- telltale/native/poker_solver/README.md +63 -0
- telltale/native/poker_solver/__init__.py +3 -0
- telltale/native/poker_solver/action_policy.cpp +394 -0
- telltale/native/poker_solver/action_policy.hpp +31 -0
- telltale/native/poker_solver/bindings.cpp +163 -0
- telltale/native/poker_solver/equity.cpp +380 -0
- telltale/native/poker_solver/equity.hpp +79 -0
- telltale/native/poker_solver/evaluator.cpp +259 -0
- telltale/native/poker_solver/evaluator.hpp +64 -0
- telltale/native/poker_solver/note.md +205 -0
- telltale/poker/__init__.py +3 -0
- telltale/poker/native.py +371 -0
- telltale/poker/policy.py +207 -0
- telltale/server/__init__.py +1 -0
- telltale/server/api.py +591 -0
- telltale/server/events.py +64 -0
- 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]
|