Skip to content

Repository files navigation

riichi-discord-bot

Polls a self-built Riichi City tournament and posts finished games to Discord, keeps a live standings message, and handles end-of-season rollover.

How it works

The league runs as one self-built tournament in Riichi City. The bot logs in with a dedicated game account, polls the tournament every POLL_INTERVAL_MS, and:

  • posts each newly finished game to the results channel as an embed
  • keeps a standings message in the status channel, edited in place
  • keeps a queue/ongoing message, deleted and reposted so it pings

State lives in JSON files (data/, mounted as a Docker volume in production):

File Holds
seen_games.json game IDs already posted, so restarts don't repost
status_state.json message IDs of the standings/queue messages
season_state.json current season number + label, and past seasons
archive/sNN-<label>.json full frozen record of a finished season

Scoring

Two different numbers show up in the bot's output, and they are not in the same units:

  • Table score — the raw end-of-game score (e.g. 83,300). Sums to 100,000 per four-player game. Used as-is.
  • Points — what the game was worth in the tournament (e.g. +103.3). The standings are cumulative points.

rankScore from the API is fixed-point with one implied decimal: a stored 12600 is 1260.0 in the client. Archives keep the raw value and every user-facing render goes through formatRankScore (src/stats.ts).

The game log returns only table scores, never the points a game was worth, so src/scoring.ts recomputes them:

points = (tableScore - returnPoints)/1000 + uma[rank] + oka if 1st

Season 1:  return 30000, uma 30 / 10 / -10 / -30, oka 20
Season 2:  return 25000, uma 15 /  5 /  -5 / -15, oka 0

An oka is whatever the return collects handed back to first place. A 30,000 return takes 5,000 a head, so it needs (30000 - 25000) * 4 / 1000 = 20 going to the winner for a table to sum to zero — that is Season 1. Season 2 returns to the 25,000 starting score, which collects nothing, so there is nothing to hand back and the uma alone balances the table. Either way a table must sum to zero; /season whatif warns about any ruleset that doesn't, because the shortfall turns into a per-game tax that makes the standings partly rank games played.

Season 2 was set up with a 30,000 return and later had its oka dropped, which left each table 20 short and cost every player 5 a game. It was corrected to the 25,000 return above, and the standings were recomputed and re-posted. See Source of truth below — this is why Discord, not Riichi City, is authoritative.

Settings are pinned by tests against real data: Season 1 replays all 137 archived games against the tournament's own final standings, and Season 2 is checked against the first game of the season. If the league's settings change, scoring.test.ts fails rather than the embeds going quietly wrong. Override via LEAGUE_RETURN_POINTS, LEAGUE_UMA, and LEAGUE_OKA (see .env.example) — though changing src/scoring.ts is preferable, since the tests pin that and an env override silently diverges from it.

Source of truth

Everything the bot posts is computed from raw table scores, by standings() in src/stats.ts — the live standings message, the season summary, the per-game points, and /season whatif. The tournament's own rankScore is fetched and archived but never displayed.

This is deliberate. Riichi City scores the tournament under the settings it was created with, and those can't be corrected retroactively; the league's can, by replaying the games. Since the Season 2 return-points fix, the two disagree on purpose — expect the bot's numbers and the in-client tournament standings to differ, and treat Discord as correct.

Two consequences worth knowing:

  • Archives record the settings their season was played under (settings in the archive JSON), so a finished season keeps reporting the standings it actually finished with rather than being re-scored under later rules. The Season 1 archive was backfilled with its own settings; archives written before that field existed fall back to the current ones.
  • Recomputing the table means paging the whole game history, so the tracker caches it and only recomputes when the tournament's leaderboard moves — which is exactly when a game has finished.

Seasons

The league reuses one tournament ID across seasons — the tournament is reset in place rather than recreated. That means the game history on Riichi City's side is destroyed at rollover, so the archive is the only surviving record.

An archive holds every game (players, scores, placements, timestamps), the league settings the season was played under, and the tournament's final leaderboard verbatim for reference.

Admin commands

/season is gated to server Administrators, or to DISCORD_ADMIN_IDS when set.

Command Does
/season status current season, player count, archives on disk, past seasons
/season archive snapshot the season to disk — read-only, safe to repeat
/season stats [season] post the season summary to the current channel
/season whatif <uma> [oka] [return_points] [season] [private] re-score the season under different settings and show the standings it would have given
/season repost [count] re-post the most recent game results (default 1)
/season rollover <new_label> <confirm> archive → back up channels → purge → post results → start next season

rollover requires confirm to exactly match the current season label, and aborts if the tournament returns zero games. Pass purge:false to roll the season over without clearing the channels.

What-if scoring

/season whatif replays every game recorded so far through an alternative uma/oka and shows the standings they would have produced, next to the current ones. Nothing is written and no live scoring changes — it only answers the question.

/season whatif uma:30,10,-10,-30
/season whatif uma:30,10 oka:20                      # two-value shorthand
/season whatif uma:15,5,-5,-15 return_points:25000
/season whatif uma:30,10 season:s01-spring-league-2026.json private:true

uma takes four values, or the two-value shorthand the rules are usually quoted as (30,1030/10/-10/-30). oka and return_points default to the current league settings, so passing uma alone answers "what if only the uma changed?". It reads the live season unless a season archive is named, and posts into the channel you ran it in — pass private:true to see it yourself first.

The output ranks everyone under the proposed settings, with each player's current place and their points change, plus the five biggest movers. If the proposed settings are not zero-sum — a table totalling anything but 0 — it says so, because that quietly makes the standings depend on games played: each game shifts a player's total by a fixed amount whatever they do.

The baseline column is the season's own settings — today's for the live season, the archived ones for a past season — computed by the same standings() the live table uses, so a what-if under the current settings is the current table. bun run stats -- --uma … does the same thing offline against an archive.

Re-posting game results

/season repost re-fetches the most recent games and posts them again — for when a scoring or formatting fix landed after the games were already announced. It adds messages rather than replacing them, so delete the outdated copies yourself; the bot has no way to know which message corresponded to which game.

It also marks those games as seen, so clearing seen_games.json by hand is not necessary and the poll loop will not follow up with duplicates.

Rollover order

  1. Archive the season from the API and verify it is non-empty.
  2. Back up each channel's full message history to data/archive/channels/, then re-read and verify the backup.
  3. Purge the channels (bulk delete under 14 days, one-by-one above that — the slow path is rate-limited to roughly one message per second).
  4. Post the final results into the freshly cleared results channel.
  5. Reset bot state and open the next season.

Nothing is deleted before its backup is written and verified. If the backup looks incomplete, the purge refuses and nothing is deleted.

Purging needs the Message Content intent. Without it Discord returns empty content for messages the bot did not write, so a backup would silently lose everything humans posted. Enable Bot → Privileged Gateway Intents → Message Content in the developer portal and set ENABLE_MESSAGE_CONTENT=true. If the channels are bot-only this is not needed — the refusal only triggers on unreadable non-bot messages.

The bot also needs Manage Messages and Read Message History in the channels it will purge.

After rollover, seen_games.json is seeded with the archived game IDs rather than emptied. If the tournament has not actually been reset in Riichi City yet, an empty set would make the bot repost the entire previous season into the just-cleared channel.

Polling is suspended for the duration of a rollover, so a poll landing mid-purge can't repost a standings message into the channel being cleared.

Running a rollover

The /season commands only exist once this version is deployed, so:

  1. Push to main — the GitHub Actions workflow builds ghcr.io/arnabp/riichi.
  2. Add DISCORD_GUILD_ID (and optionally DISCORD_ADMIN_IDS, ENABLE_MESSAGE_CONTENT) to the server's .env.
  3. docker compose pull && docker compose up -d, then check the logs for Registered /season in guild ….
  4. Run /season archive first and confirm the game count looks right.
  5. Run /season rollover new_label:"Summer League 2026" confirm:"Spring League 2026".
  6. Reset the tournament in Riichi City.

Steps 4 and 5 both write an archive, so running /season archive first costs nothing and confirms the whole path works before anything is deleted.

CLI

Both read from data/archive/ and need no Discord connection.

bun run archive              # snapshot every configured tournament
bun run archive -- 2         # just tournament 2
bun run stats                # summary of the most recent archive
bun run stats -- s01-spring-league-2026.json
bun run stats -- --player Mookjong
bun run stats -- --uma 30,10,-10,-30 --oka 20   # re-score under other settings
bun run stats -- --uma 30,10 --return 25000

bun run src/smoke-test.ts checks credentials and prints the latest few games without writing state or posting anything.

Setup

bun install
cp .env.example .env    # then fill it in
bun run dev

See .env.example for every variable. RIICHI_EMAIL_SUFFIX and RIICHI_DEVICE_ID have to be captured from the game client with tcpdump; the instructions are in that file.

Tests

bun test          # unit tests, no network
bunx tsc --noEmit # typecheck

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages