Files
tiennm99 629b513c91 feat(games): move /wordle and /loldle to the web games and add /loldledaily
/wordle and /loldle now send the wordle and loldle BotFather games in an
unlimited mode: each player gets their own word or champion, saved after
every guess, with a New game button. Cards the bot did not record as
unlimited, including the daily cards, play the daily puzzle. The helper
commands keep working on the player's round.

/loldledaily mirrors /wordledaily: one champion per day at 07:00 ICT,
subscribe and unsubscribe, the morning recap and the live group results.

The daily and unlimited machinery is shared in util/guessgame, the old
in-chat wordle module is removed, and a one-time startup migration carries
each player's stats and unfinished round over without changing old data.
2026-10-10 09:29:48 +07:00

236 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LoLdle
`loldle` is a League of Legends "guess the champion" game, after loldle.net's
classic mode. Each guess is scored on seven attributes of the champion, and
the colours say how close it is. It runs as a Telegram HTML5 game behind the
one BotFather game `loldle`, in two modes:
- **Unlimited:** each player has their own rounds, each with a random
champion. `/loldle` sends the card; the page's **New game** starts the next
round. The chat commands play the same rounds.
- **LoLdle Daily:** one shared champion a day for everyone, new at 07:00 ICT,
like [Wordle Daily](wordledaily.md): live group results, a 07:00 recap and
push, and a high score on the card.
The two modes share the engine in `internal/modules/util/guessgame` with
[Wordle](wordle.md) and [Wordle Daily](wordledaily.md).
## Commands
| Command | What it does |
|---|---|
| `/loldle` | Sends an unlimited card. Without the web game it shows the caller's board in the chat instead |
| `/loldle <champion>` | Plays the guess on the caller's round: the attribute rows, then `Guess n/max.`, or a sticker, a flavour line, the time and the streak at the end |
| `/loldle_giveup` | Ends the caller's round as a loss, with the give-up sticker, and reveals the champion |
| `/loldle_stats` | The caller's unlimited stats |
| `/loldle_setmax <1-10>` | Owner only. Sets the length of unlimited rounds started in this chat from now on |
| `/loldledaily` | Sends today's LoLdle Daily card |
| `/loldledaily_subscribe` | Opts the chat or forum topic into the 07:00 ICT push |
| `/loldledaily_unsubscribe` | Opts it out |
Champion names are matched loosely: case, spaces and punctuation do not
matter (`kaisa` is Kai'Sa), and a unique prefix is enough (`aat` is Aatrox).
An ambiguous prefix, an unknown name and a champion already guessed this
round are refused without using a guess.
Each player has one unlimited round, the same in every chat, and the page and
the chat commands play it together. In a group every member plays their own
round. When a round is over, the next `/loldle` or `/loldle <champion>` starts
a new one, as the in-chat game always did. Channels are refused, and so are
anonymous group admins and posts sent on behalf of a chat, as for
[Wordle](wordle.md#commands).
## Setup
1. **BotFather:** create a game with `/newgame` and the short name `loldle`.
2. **Public URL:** the same `GAME_BASE_URL` as the other games. The page is
served at `<GAME_BASE_URL>/games/loldle/`.
3. **Environment:** `LOLDLE_GAME_SECRET` is optional and must be at least 32
bytes. It derives the link-signing key (`tiennm99bot/loldle/token/v1`) and
the daily answer key (`tiennm99bot/loldle/answer/v1`). Unset, the bot token
is the root key.
4. Keep `loldle` in `MODULES`, or leave `MODULES` empty.
Without `GAME_BASE_URL`, a long enough secret or storage, the web game is
disabled: `/loldle` and its helpers still play in the chat, `/loldledaily`
and `/loldledaily_subscribe` answer "LoLdle isn't configured on this server.",
Play shows the same alert, and no route or cron exists.
`/loldledaily_unsubscribe` always works.
## The comparison
| Column | Compared | Colours |
|---|---|---|
| Gender | exact | green or red |
| Species | set | green when equal, yellow when they overlap, red otherwise |
| Range type | set | as above |
| Resource | exact | green or red |
| Region(s) | set | as above |
| Position(s) | set | as above |
| Release year | year | green when equal; red with ↑ when the answer is newer, ↓ when older |
The server stores each guess as the champion's name and seven marks in that
order: `c` correct, `p` partial, `w` wrong, and for the year `u` (answer
newer) or `d` (answer older). The page and the chat boards rebuild the cells
from the marks and the guessed champion's own values, so the answer's values
never leave the server.
## The page
Plain HTML, CSS and JavaScript embedded in the binary, no build step, under
the same strict CSP as the other games plus one image origin,
`https://ddragon.leagueoflegends.com`, for champion icons. It is dark,
mobile-first and works in Telegram's in-app browsers.
- **Header:** "LoLdle", the mode (`Daily #N · date` or `Unlimited · Round N`),
the player, and a guess counter.
- **Search:** an ARIA combobox. Typing lists up to 8 champions, those whose
name starts with the text first, then those that contain it, without the
ones already guessed; matching ignores case, spaces and punctuation. Arrow
keys move, Enter or a tap guesses, Escape or a tap outside closes. The list
comes from `champions.json`: names and icon ids, nothing else. If it fails
to load, the page retries with backoff (1 s doubling to 30 s, and at once
on the next keystroke), says so on Enter, and searches the open query
again when it arrives.
- **Board:** a Champion column with icon and name, then the seven columns,
newest guess first. On a narrow screen it scrolls sideways with the
Champion column pinned, and cells shrink at 768 and 480 pixels. A new row's
cells appear one after another (80 ms apart), except under
`prefers-reduced-motion`.
- **Icons:** `https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/<id>_0.jpg`.
An icon that fails to load is replaced by the champion's initial.
- **Screen readers:** each cell is labelled with its column, value, result
and, for the year, which way the answer lies; a new guess is announced the
same way.
- **End panel:** "You got it!" with the guess count, or "Game Over" with the
champion's icon, name and title; then the stats and guess distribution.
Daily adds a countdown to 07:00 ICT, **Share** and **Copy result**;
unlimited adds **New game** and a pointer to `/loldledaily`.
- **New game** in the header asks for a second tap when the round has
guesses.
## Unlimited rounds
- **Length:** a round allows 8 guesses unless the chat it starts in has a
`/loldle_setmax` value (1–10). A round keeps the length it started with.
- From a command, the chat is the command's chat (a group, or the private
chat).
- From the page (its first round, or **New game**), the chat is the card's.
An inline card uses 8.
- **New game** and an abandoned round follow [unlimited Wordle](wordle.md#rounds):
giving up a round with a guess counts as a loss and the page shows its
champion, an unguessed round is replaced, and a stale round number starts
nothing.
- **Stats:** played, wins, current streak (consecutive wins), best streak and
the guess distribution (up to 10).
- **No high score:** unlimited wins are not reported with `setGameScore`; a
round's length can differ by chat, so the scores would not compare.
- **Data refresh:** a round whose champion was removed from the data is
replaced without counting.
Which card opens which mode works as for Wordle: `/loldle` records its cards
(`ucard:<chat>:<message>`, refreshed once a day when played), and every other
card, `/loldledaily`, the push, an inline share, a card unplayed for 30 days or
a forwarded copy of a `/loldle` card, opens the daily champion. The cron `loldle_unlimited_cards` (`40 20 * * *`
UTC) forgets old records. See [unlimited Wordle](wordle.md#which-card-opens-which-mode).
## LoLdle Daily
Everything works as in [Wordle Daily](wordledaily.md), with these values:
- **Puzzle numbering:** puzzle #1 is 9 October 2026, the same epoch as Wordle
Daily, so both games share their day number.
- **Answers:** the champions sorted by name, walked in a keyed permutation per
cycle. The first resolution of a day pins its champion as `puzzle:<n>`, so
adding champions or changing the secret only affects days not started yet.
If a data refresh removes a champion that is already pinned, that day stays
playable: its attributes are unknown, so every guess scores 🟥 in every
column and the board ends in a loss that reveals the name (naming the
champion still wins, but the page's search no longer lists it).
- **Guesses:** 8. A win scores `9 − guesses` with `setGameScore`.
- **Live results:** one message per group topic and day, with one row of 7
squares per guess (🟩 correct, 🟨 partial, 🟥 wrong). No champion is named.
- **Push:** the cron `loldledaily_daily_push` (`0 0 * * *` UTC). The recap
opens with yesterday's champion, then the group streak (wins only), then the
finishers grouped by guess count:
```
LoLdle Daily #11 — yesterday's results
Champion: Ahri
🔥 Your group is on a 3 day streak!
👑 2/8: Alice, Dan
5/8: Eve
X/8: Bob
```
- **Share text:** `LoLdle Daily #11 2/8` and the square rows.
## HTTP API
Routes under `/games/loldle/`:
| Route | Body | Answer |
|---|---|---|
| `GET /`, `app.js`, `app.css` | | the page |
| `GET champions.json` | | `[{"name","id"}]`, cached for an hour |
| `POST api/state` | `{"token"}` | view |
| `POST api/guess` | `{"token","num"}` (daily) or `{"token","seq"}` (unlimited), plus `"name"` | view |
| `POST api/new` | `{"token","seq"}` | the next unlimited round; `403 bad_mode` on a daily card |
The view has `mode`, `player`, `max`, `columns` (`{key, label}` ×7),
`guesses` and `status`; daily adds `num`, `date` and `next_at`, unlimited adds
`seq` and `gave_up`, and after `api/new` gives up a round with guesses,
`abandoned` (`{name, id, title}`) names its champion. Each guess is `{name, id, marks, cells}`, a cell being
`{key, value, result, dir}` with the guessed champion's own value. `answer`
(`{name, id, title}`), `stats` and the daily `share` appear only once the game
is over.
Errors are `{"error","message"}`: `400 bad_request`, `401 bad_token` /
`expired`, `403 bad_mode`, `409 new_puzzle` / `new_round`, `422 unknown` /
`ambiguous` / `duplicate` / `finished`, `429 rate_limited` (60 requests a
minute per player).
## Storage
All in the `loldle` collection:
| Key | Holds |
|---|---|
| `config:<chat>` | the chat's `/loldle_setmax` value |
| `uround:<user>`, `ustats:<user>`, `ucard:<chat>:<message>` | unlimited rounds, stats and cards |
| `puzzle:<n>`, `play:<n>:<user>`, `dstats:<user>`, `cday:…`, `cstreak:…`, `subscribers`, `daily_push:last_date` | LoLdle Daily, as Wordle Daily's keys; the stats prefix is `dstats:` because `stats:` holds the in-chat game's old stats |
| `game:<subject>`, `stats:<subject>` | the in-chat game before unlimited mode; read once by the migration, never written |
## Carrying over the in-chat game
At startup `loldle.InitStore` copies the in-chat game's players into
unlimited mode, once (marker `loldle:legacy-unlimited` in the `system`
collection), whatever `MODULES` says:
- `stats:<user>` becomes the player's unlimited stats (`streak` → current,
`bestStreak` → best); the distribution starts at zero.
- `game:<user>`, a round still being played (the old game deleted finished
ones), becomes the player's current round, with its frozen length and start
time. Its colours are rebuilt from today's champion data; a guess naming a
champion no longer in the data is dropped, and a round whose answer is gone
is not carried.
- Group rounds and group stats (negative subjects) belong to no single player
and are not carried.
- Nothing already there is overwritten, and the old documents stay, so a
rollback is a redeploy.
## Champion data
`internal/modules/loldle/data/champions.json` holds 172 champions in
loldle.net's schema (gender, positions, species, resource, range type,
regions, release date). `id` (the Data Dragon key, which names the icon, e.g.
`MonkeyKing` for Wukong) and `title` were added by name from
`tiennm99/loldle`'s Data Dragon scrape. Data Dragon's tile files are
case-sensitive and one does not match its key: Fiddlesticks' tile is
`FiddleSticks_0.jpg`, so `tileIDs` in `champions.go` overrides that `id` at
load. Tests check that every champion has a unique, letters-only `id` and a
title, and that the override applies.
Locke, in that scrape but not here, is not added yet: this data needs his
species, positions and release date, which the scrape does not have.