Discord bot originally built for randomly generating Old West names and now wandering off into card games.
See DISCORD_SETUP.md for instructions on creating and configuring a Discord bot for this app.
Names were scraped from Mithril and Mages.
/wwname [gender] [number]: GeneratesnumberOld West names by choosing a random first name of the givengenderand a random surname. Thegenderargument accepts any string starting withform. If nogenderis given, a random one is chosen. If not provided,numberdefaults to 1. The output is a newline-joined list of names in the form of<gender emoji> first_name last_name.
All gameplay actions have slash commands. As a convenience, join, leave, bet <amount>, hit, and stand also work typed as plain messages in the game's channel (no slash prefix).
Slash commands:
/help— show all available commands./newgame [npc_min] [npc_max](admin) — starts a new game of blackjack with bot players with Wild West personalities.npc_minbots (0–6, default 3) sit down as soon as the game is created, and the table is kept betweennpc_minandnpc_max(0–6, default 5) bots for as long as the game runs — departing bots are replaced, overriding the server-wide/npclimitsfor this game. A game with bots and no humans starts playing on its own at the slower ambient pace; anyone who/joins within the first few seconds is dealt into the first hand, and anyone who joins later is dealt in within the normal between-hands gap (10 seconds by default) rather than waiting out the slower ambient one. Bots use AI-powered decisions if an API key is configured, otherwise they fall back to basic strategy. Each game gets a memorable Old West ID likedusty-saloon(shown when the game is created and in/debug) instead of a raw UUID./joingame— sit down at the table./leavegame— leave the current game./bet <amount>— place a bet during the betting phase./hit— draw another card on your turn./stand— hold your hand on your turn./wad— privately check your current balance (only visible to you)./stats— view your saloon record (hands played, winnings, etc.; only visible to you)./saloon— show the saloon's name, town, and active tables./changelog— show what's new at the saloon: the last 5 entries inCHANGELOG.md, or the last week's worth, whichever is more./stopgame(admin) — end the current game immediately and return all unresolved bets to players./checkwallet <target>(admin) — check any player's or NPC's wallet balance./setwallet <target> <amount>(admin) — set a player's or NPC's wallet to an exact amount./givechips <target> <amount>(admin) — adjust a player's or NPC's wallet by a delta (negative takes chips away)./npclimits [min] [max](admin) — view or set the server-wide NPC autofill min/max per table, for games that don't have their own (games started with/newgamedo). Withmin > 0, tables stay populated with NPCs even when no humans are present./npcrelationships <npc>(admin) — list an NPC's relationships (partner, type, strength, and the story behind them)./addnpc [count](admin) — add one or more roster NPCs to the current game (default: 1)./removenpc [name](admin) — remove an NPC from the current game; omit name to remove any NPC./usage [days](admin) — show a summary of LLM token usage (default 7 days, up to 90), broken down by purpose, model, and provider./debug(admin) — dump full internal state for troubleshooting, including live LLM provider health (up/down, last success/failure, whether NPCs are currently on AI or fallback strategy).
/version: Outputs the current git SHA plus the last commit's date and summary.
SaloonBot supports AI-powered bot players with Wild West personalities. Bots use an LLM to make real gameplay decisions (hit/stand/bet) and deliver in-character quips during the game.
Bots are drawn from a pool of 15 archetypes and 4 historical figures, each with a distinct voice and betting temperament. Bot quips appear in sepia-colored embeds to distinguish them from regular game messages.
Archetypes: The Grizzled Prospector, The Drunk Cowboy, The Snake Oil Salesman, The Prim Schoolmarm, The Bounty Hunter, The Frontier Preacher, The Railroad Baron, The Half-Broke Drifter, The Card Sharp, The Saloon Singer, The Greenhorn Deputy, The Apache Tracker, The Patent Medicine Widow, The Retired Outlaw, The Railroad Cook
Historical figures: Doc Holliday, Calamity Jane, Jesse James, Wild Bill Hickok
AI bots remember their nights at the table. While seated, each bot keeps track of what happens around it — bets, busts, wins, and banter — and when it leaves (including deciding on its own to "call it a night"), it condenses the session into a short memory that persists in the database. The next time that NPC sits down, its recent memories feed into its prompts, so a regular NPC can reference past sessions and the people it played with. Memory depth follows SALOON_DETAIL_LEVEL: low disables the feature, medium recalls 1 recent session, high recalls 3.
Roster NPCs have history with each other. A newly created NPC has a 70% chance of arriving with 1–3 pre-existing relationships to other roster NPCs — friendships, rivalries, or something more complicated — each with an LLM-written backstory note. Relationships also form and deepen through play: sharing a table strengthens a pair's bond (+5 per session), strangers who play together have a small chance of striking up a new relationship, and every 20 strength points the relationship's note is rewritten (and its type can shift — a friendship can sour into something complicated). When two related NPCs sit at the same table, both play with their shared history in their prompts, gated by SALOON_DETAIL_LEVEL: low omits it, medium injects the type and a one-sentence note, high the full note. Admins can inspect any NPC's relationships with /npcrelationships.
| Variable | Default | Description |
|---|---|---|
LLM_PROVIDER |
openai |
LLM provider: openai, claude, none (disables the LLM client entirely — bots always use basic strategy), or fake (deterministic offline provider for testing) |
ANTHROPIC_API_KEY |
— | API key for Claude; supports ANTHROPIC_API_KEY_FILE |
OPENAI_API_KEY |
— | API key for OpenAI; supports OPENAI_API_KEY_FILE |
LLM_MODEL |
provider default | Override model (claude-haiku-4-5 / gpt-4o-mini) |
LLM_TIMEOUT |
5 |
Seconds before falling back to basic strategy |
LLM_HEALTHCHECK_INTERVAL |
300 |
Seconds between periodic re-checks of the LLM provider (detects credit exhaustion/outages and recovery without a restart) |
LLM_DOWN_GRACE_PERIOD |
120 |
Seconds a provider must keep failing before it's reported down for alerting/metrics — absorbs transient blips (e.g. DNS not ready right after a restart) without paging |
LLM_SESSION_MEMORY_TIMEOUT |
15 |
Seconds allowed for the background session-memory call |
BLACKJACK_NPC_DEPARTURE_BASE |
0.02 |
Baseline per-hand chance an NPC calls it a night |
BLACKJACK_NPC_DEPARTURE_RAMP |
0.28 |
Extra departure chance once an NPC has seen a full session |
BLACKJACK_AMBIENT_SPEED_MULTIPLIER |
2.0 |
Multiplier applied to dramatic/dealer-card/result pauses on a table with no human players |
BLACKJACK_AMBIENT_TIME_BETWEEN_HANDS_MIN |
120 |
Minimum seconds between hands on an all-NPC ambient table |
BLACKJACK_AMBIENT_TIME_BETWEEN_HANDS_MAX |
300 |
Maximum seconds between hands on an all-NPC ambient table (actual delay is randomized within this range) |
WALLET_REPLENISH_INTERVAL |
300 |
Seconds between idle-NPC wallet replenishment passes |
METRICS_PORT |
9400 |
Port for the Prometheus /metrics endpoint |
Exactly one provider is active at a time, chosen by LLM_PROVIDER (default openai) — the corresponding API key is required for openai/claude; an unset or invalid key (or LLM_PROVIDER=none) means bot players still join the game but use basic blackjack strategy instead of AI decisions. The provider is periodically re-checked while running, so credits running out or being topped up are picked up automatically.
NPC autofill (/npclimits [min] [max]) is a runtime setting, not an environment variable — see the /npclimits entry above. With min > 0, a table stays populated with NPCs indefinitely, running at the ambient pace above whenever no human is seated.
All four secret variables (DISCORD_TOKEN, DISCORD_GUILDS, ANTHROPIC_API_KEY, OPENAI_API_KEY) resolve in priority order: direct env var → <VAR>_FILE path → /run/secrets/<lowercase_var> → unset. Docker secrets mounted at /run/secrets/ are picked up automatically with no extra configuration.
The server exposes a Prometheus-format /metrics endpoint on METRICS_PORT (default 9400): LLM call/token counters (saloonbot_llm_calls_total, saloonbot_llm_input_tokens_total, saloonbot_llm_output_tokens_total, labeled by purpose/model/provider) provider health (saloonbot_llm_provider_up gauge, saloonbot_llm_provider_failures_total counter), and hands played (saloonbot_hands_total, labelled ambient="true" for all-NPC tables and "false" when a human is seated — the denominator for cost per hand). This repo only exposes the metrics — scraping, dashboards, and alerting are configured in whatever external Prometheus/Grafana setup you point at it.
A standalone CLI client (cli.py) lets you play and test the game locally without Discord.
export REDIS_HOST=localhost REDIS_PORT=6379
python cli.pyAt startup you'll be prompted for your name and how many LLM bot players to add (0–4). The CLI auto-joins the game and drops you into the command prompt.
Available commands:
| Command | Description |
|---|---|
join |
Sit down at the table (done automatically at startup) |
bet <amount> |
Place a bet during the betting phase |
hit |
Draw another card |
stand |
Hold your current hand |
leave |
Leave the game |
addnpc [count] |
Add roster NPC(s) to the current game |
removenpc [name] |
Remove an NPC (by name, or any if omitted) |
help |
Show command list |
quit |
Exit |
watch_game.py follows a running game's table talk on stdout, like watching it in a Discord channel. It is read-only (it never sends an action, so it can't affect the game) and works for any game, including a headless ambient table that posts nothing to Discord:
python watch_game.py --game-id dusty-saloon
REDIS_HOST=localhost python watch_game.py --game-id dusty-saloon # e.g. through an SSH tunnel to stagingIt prints each update as it arrives, tinted to match the bot's embed colours (quips, per-player wins/losses/pushes, showdown, shuffles, ante-up) when stdout is a terminal (NO_COLOR turns colour off), and exits when the game ends or on Ctrl-C. Updates are live pub/sub with no history, so only what happens after it connects is shown, and it doesn't reproduce the bot's per-message pacing delays. Its message classification is a copy of the rules in bot.py's message handler, so keep the two in step (see #256 for sharing them).
SaloonBot provides flexible development workflows using Docker Compose configurations. The bot consists of two main components: the Discord bot (bot.py) and the server component (server.py), both communicating through Redis. The server persists state to a database — MySQL in production, SQLite locally.
The server supports SQLite as a drop-in replacement for MySQL, controlled by the USE_SQLITE environment variable. All three dev scripts enable this automatically, so no MySQL container is needed for local development.
To use SQLite manually:
export USE_SQLITE=1 # enables SQLite
export SQLITE_PATH=saloonbot.db # optional; this is the default
python server.pyThere are three development compose files, each designed for a different workflow:
compose.dev-bot-local.yml- Runs server + redis in Docker, allowing you to runbot.pylocallycompose.dev-server-local.yml- Runs bot + redis in Docker, allowing you to runserver.pylocallycompose.dev-redis-only.yml- Runs redis only in Docker, allowing you to run both components locally
For convenience, helper scripts are provided for each scenario:
./dev-bot.shThis starts the server and redis containers (server uses SQLite), then runs the bot locally. Requires DISCORD_TOKEN and DISCORD_GUILDS environment variables.
./dev-server.shThis starts the bot and redis containers, then runs the server locally with SQLite. Requires discord_token.txt and discord_guilds.txt files for the bot container. Defaults LLM_PROVIDER=fake so local runs never hit a real (billed) API by accident — export LLM_PROVIDER=openai/claude yourself beforehand to test the real client.
./dev-redis.shThis starts redis in Docker. You can then run bot.py and server.py separately in different terminals:
# Terminal 1
export REDIS_HOST=localhost REDIS_PORT=6379 SALOONBOT_DEBUG=1
export DISCORD_TOKEN="your-token" DISCORD_GUILDS="your-guild-ids"
python bot.py
# Terminal 2
export REDIS_HOST=localhost REDIS_PORT=6379 USE_SQLITE=1 SALOONBOT_DEBUG=1 LLM_PROVIDER=fake
python server.pyLLM_PROVIDER=fake avoids hitting a real, billed API — set it to openai/claude yourself if you specifically want to test the real client.
You can also use Docker Compose directly without the helper scripts:
# Start services
docker compose -f compose.dev-bot-local.yml up -d
# View logs
docker compose -f compose.dev-bot-local.yml logs -f
# Stop services
docker compose -f compose.dev-bot-local.yml downEnvironment-specific compose files are provided:
compose-production.yml— production deploymentcompose.staging.yml— staging deployment
Run the appropriate environment directly:
docker compose -f compose-production.yml up -d
docker compose -f compose.staging.yml up -dIf you always deploy the same environment on a given host, you can symlink compose.yml to avoid specifying -f every time:
# Example: always use production on this host
ln -s compose-production.yml compose.yml
docker compose up -dThis runs all components (bot, server, and redis) in Docker containers.
For staging hosts that should track main automatically, staging-autodeploy.sh
fast-forwards the checkout to origin/main and re-runs
docker compose -f compose.staging.yml up -d when anything changed. It's meant
to run from a systemd timer or cron entry, complementing watchtower: watchtower
auto-updates images but reuses each container's existing config, so compose-file
changes (ports, env vars, new services) only land via a pull + up.
Reference unit files are in systemd/ — install with:
sudo cp systemd/saloonbot-autodeploy.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now saloonbot-autodeploy.timerSchema migrations run automatically on server startup — deploy new code and restart; no manual SQL needed.
Secrets are sourced from /etc/saloonbot/secrets/ on the host, which persists across reboots (unlike /run/secrets, which is a tmpfs and is wiped on restart). Run this once on each host:
mkdir -p /etc/saloonbot/secrets
chmod 700 /etc/saloonbot/secrets
# Required
echo -n "your-discord-token" > /etc/saloonbot/secrets/discord_token
echo -n "guild_id1,guild_id2" > /etc/saloonbot/secrets/discord_guilds
# Optional — LLM bot players. Create empty files if unused.
echo -n "your-anthropic-key" > /etc/saloonbot/secrets/anthropic_api_key
echo -n "" > /etc/saloonbot/secrets/openai_api_key
chmod 600 /etc/saloonbot/secrets/*All four secret files must exist (Docker Compose requires them even if empty). Docker mounts them into each container at /run/secrets/<name>, where SaloonBot picks them up automatically.
To run unit tests, run:
pytest test.py(python test.py also works, since it's a plain unittest suite.)
Under pytest, conftest.py caps the test process (and any server it starts) at 2 GB of address space, so a runaway test fails with MemoryError instead of exhausting the machine's RAM. Set SALOONBOT_TEST_MEM_LIMIT_MB to change the cap, or to 0 to disable it. Running the files directly with python skips the cap.
End-to-end tests validate the complete system integration using the Redis interface with real MySQL and Redis services (no mocking). These tests:
- Start Redis and MySQL via docker-compose
- Run the actual server process
- Simulate client interactions through Redis pub/sub
- Verify game logic, database persistence, and server behavior
To run end-to-end tests:
python test_e2e.pyOr use the helper script:
./run-e2e-tests.shRequirements:
- Docker and Docker Compose must be installed and running
- The tests will automatically start and stop the necessary services
Note: End-to-end tests take longer to run (~2 minutes) as they start/stop Docker containers and wait for game timing events.
hunt_flaky.py re-uses a single Docker stack across N runs per test case, making repeated flakiness detection roughly 100× faster than re-spinning containers each time.
source .venv/bin/activate
python hunt_flaky.py --runs 20Options: --runs N (default 100), --output FILE (JSON results), --class ClassName ... (limit to specific test classes).
The classes to run are discovered from test_e2e.py (every EndToEndTestCase subclass, in source order), so new e2e classes are picked up automatically. Plain unittest.TestCase classes such as TestGameIdColumnMigration aren't hunted; an EndToEndTestCase subclass that can't share the stack goes in EXCLUDED_CLASSES with its reason.
Two operator scripts turn SaloonBot's existing token-usage tracking (/usage, the llm_usage DB table) into an actual $ estimate of what running an NPC-only ("ambient") table costs — useful for finding the floor cost of running the saloon 24/7 with nobody playing.
start_ambient_table.py stands up (or tears down) a headless NPC-only table — it sets NPC autofill limits and creates a game with no guild_id/channel_id, so nothing posts to Discord:
export REDIS_HOST=localhost REDIS_PORT=6379
python start_ambient_table.py --min 3 --max 5
# ... let it run ...
python start_ambient_table.py --teardown --game-id <id>The script sets the casino-wide NPC autofill limits, which apply to every game without its own (games started with /newgame carry their own limits) — run this against a server with no other active games for a clean measurement.
llm_cost_report.py connects to the same DB the server uses and applies a small built-in pricing table to get_llm_usage_summary()'s token totals, printing a per-purpose/model/provider cost breakdown plus extrapolated day/week/month/year run rates:
export USE_SQLITE=1 SQLITE_PATH=saloonbot.db # or the MySQL_* vars, matching the server
python llm_cost_report.py --days 1llm_cost_report.py only takes whole-day windows and has no hand count. To price a shorter, exact window (e.g. a few hours of an ambient trial) and get cost per hand, query Prometheus over that window W instead, then apply the provider's per-million-token rates by hand:
sum by (purpose, model, provider) (increase(saloonbot_llm_input_tokens_total[W]))
sum by (purpose, model, provider) (increase(saloonbot_llm_output_tokens_total[W]))
sum(increase(saloonbot_hands_total{ambient="true"}[W]))
Divide the priced token totals by the hand count for cost per ambient hand. For cost per NPC-hour, use the average number of seated NPCs — every seated NPC bets once per hand, so it is npc_bet calls ÷ hands (increase(saloonbot_llm_calls_total{purpose="npc_bet"}[W]) over the hand count above) — times the window's hours. This holds when NPCs arrive and depart (e.g. --min 3 --max 5); note autofill only adds below min and trims above max, so such a table mostly sits at min.
The pricing table is hardcoded and dated in the script — check it's current before trusting the numbers for a real budgeting decision.
watch_ambient_cost.py watches the server's /metrics token counters while an ambient table runs, prices the growth since it started using the same pricing table, and tears the table down (the same npc_limits 0/0 + stop_game as --teardown) if spend runs away:
python start_ambient_table.py --min 3 --max 5 # note the game id
python watch_ambient_cost.py --game-id <id> --cap 1.50 --warn 0.50 --max-hours 12It aborts when cumulative spend reaches --cap (default $1.50), when usage comes from a model with no pricing on file (it can't bound the spend), or when /metrics stays unreachable for --unreachable-limit polls (default 3, --interval 300s) — it won't watch blind. It warns at --warn (default $0.50), or with --expected-hourly-cost when spend runs over 3× that rate. At --max-hours (default 12) it stops the table and exits cleanly. Exit codes: 0 finished, 2 aborted, 3 couldn't start watching (no baseline, or Redis unreachable so an abort would be impossible), 4 decided to stop the table but couldn't confirm it stopped — check by hand.
Things to know: start it right after the table, since only usage after its first poll is counted; the cost is a close estimate from token counters (scrape lag, failed calls aren't tracked), not the invoice, so the provider dashboard remains the source of truth; and Ctrl-C leaves the table running unwatched. It runs wherever it can reach the server's METRICS_PORT (--metrics-url, default http://localhost:9400/metrics) and Redis (--redis-host/--redis-port) — for a remote staging host, SSH tunnels to both work.
The watchdog and --teardown both run on your machine and reach staging through SSH tunnels, so if either goes away nothing can stop the table. deadman_teardown.py arms a one-shot systemd timer on the staging host that republishes the same teardown (npc_limits 0/0, then stop_game) through the saloonbot-redis container, independent of your machine:
python deadman_teardown.py arm --game-id <id> --hours 13 # a little beyond the watchdog's --max-hours
python deadman_teardown.py status # list armed timers
python deadman_teardown.py disarm --game-id <id> # after a normal teardownIt uses --host saloonbot-staging by default (an SSH alias) and needs passwordless sudo there: a system-level timer survives SSH logout, whereas a user timer would not without lingering enabled. Timers are transient, so they vanish on a reboot of the host. The teardown messages are unit-tested against start_ambient_table.teardown() so the two can't drift apart.