Skip to content
markrcotePublic

About

Discord bot for randomly generating Old West names and playing cards and whatever

Resources

Stars

1 star

Watchers

1 watching

Forks

Latest commit

 

History

621 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

saloonbot

Lint and test

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.

Commands

Names

  • /wwname [gender] [number]: Generates number Old West names by choosing a random first name of the given gender and a random surname. The gender argument accepts any string starting with f or m. If no gender is given, a random one is chosen. If not provided, number defaults to 1. The output is a newline-joined list of names in the form of <gender emoji> first_name last_name.

Cards

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_min bots (0–6, default 3) sit down as soon as the game is created, and the table is kept between npc_min and npc_max (0–6, default 5) bots for as long as the game runs — departing bots are replaced, overriding the server-wide /npclimits for 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 like dusty-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 in CHANGELOG.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 /newgame do). With min > 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).

Metadata

  • /version: Outputs the current git SHA plus the last commit's date and summary.

LLM Bot Players

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.

Personalities

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

NPC memory

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.

NPC relationships

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.

Configuration

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.

Metrics

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.

CLI

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.py

At 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

Watching a game

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 staging

It 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).

Development

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.

SQLite for local development

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.py

Development Scenarios

There are three development compose files, each designed for a different workflow:

  1. compose.dev-bot-local.yml - Runs server + redis in Docker, allowing you to run bot.py locally
  2. compose.dev-server-local.yml - Runs bot + redis in Docker, allowing you to run server.py locally
  3. compose.dev-redis-only.yml - Runs redis only in Docker, allowing you to run both components locally

Using Helper Scripts

For convenience, helper scripts are provided for each scenario:

Run bot locally (server in Docker)

./dev-bot.sh

This starts the server and redis containers (server uses SQLite), then runs the bot locally. Requires DISCORD_TOKEN and DISCORD_GUILDS environment variables.

Run server locally (bot in Docker)

./dev-server.sh

This 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.

Run both components locally (redis only in Docker)

./dev-redis.sh

This 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.py

LLM_PROVIDER=fake avoids hitting a real, billed API — set it to openai/claude yourself if you specifically want to test the real client.

Manual Usage

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 down

Production/Staging Deployment

Environment-specific compose files are provided:

  • compose-production.yml — production deployment
  • compose.staging.yml — staging deployment

Run the appropriate environment directly:

docker compose -f compose-production.yml up -d
docker compose -f compose.staging.yml up -d

If 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 -d

This 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.timer

Schema migrations run automatically on server startup — deploy new code and restart; no manual SQL needed.

Secrets setup (one-time per host)

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.

Tests

Unit Tests

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

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.py

Or use the helper script:

./run-e2e-tests.sh

Requirements:

  • 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.

Flaky Test Detection

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 20

Options: --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.

Measuring LLM Cost

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 1

llm_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.

Guarding a long unattended run

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 12

It 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.

Backstop if the watchdog's machine or tunnel dies

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 teardown

It 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.

About

Discord bot for randomly generating Old West names and playing cards and whatever

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages