chore: drop CF→AWS migration tooling and stale JS-port references

The CF→AWS data migration (closed 2026-05-16) is long done and the
tooling isn't wired into any production path. Remove the one-shot binary,
its support package, and the migration runbook.

In live code, replace 'JS-parity' / 'same shape as JS' / 'cross-runtime
KV migration' comments with the real, stable reason for each behavior
(wire-format invariant, null-vs-zero distinction, CloudWatch alarm field
name, etc.). 24 files touched across lolschedule, loldle, wordle, twentyq,
trading, misc, util, server, metrics, ai, keylock.

- delete cmd/migrate_cf_data/
- delete internal/migration/
- delete docs/cf-to-aws-migration-runbook.md
This commit is contained in:
tiennm99 committed 2026-05-25 09:39:17 +07:00
1 parent c901f3ad40
commit ec77a24db7
47 files changed
+142 -1491

No files matched your search

+5 -6
View File
@@ -1,4 +1,4 @@
// Package loldle ports the JS loldle classic mode — guess the League
// Package loldle implements the loldle classic mode — guess the League
// champion from attribute hints (gender, species, regions, etc.).
package loldle
@@ -9,8 +9,8 @@ import (
)
// Champion is one row of champions.json. Field tags match loldle.net's
// scraped schema verbatim so the embedded JSON file lifts unmodified from
// the JS source.
// scraped schema verbatim so the embedded JSON file can be regenerated from
// the upstream scrape without a transform step.
type Champion struct {
ChampionName string `json:"championName"`
Gender string `json:"gender"`
@@ -22,9 +22,8 @@ type Champion struct {
ReleaseDate string `json:"release_date"` // YYYY-MM-DD
}
// rawChampions holds the embedded JSON byte stream. The data file is copied
// byte-for-byte from src/modules/loldle/champions.json in the JS repo so
// dictionaries are identical across runtimes (no normalization at port time).
// rawChampions holds the embedded JSON byte stream — the loldle.net champion
// dictionary scraped offline and checked into data/champions.json.
//
//go:embed data/champions.json
var rawChampions []byte
+18 -16
View File
@@ -4,8 +4,8 @@ import (
"strings"
)
// AttrType classifies how a single attribute is compared. Matches the JS
// source's "exact" | "multi" | "year" discriminator.
// AttrType classifies how a single attribute is compared:
// "exact" | "multi" | "year".
type AttrType string
const (
@@ -14,16 +14,18 @@ const (
attrYear AttrType = "year"
)
// Result categories. Match JS strings byte-for-byte (handlers/render rely on
// these literals via the marker maps).
// Result categories. handlers/render rely on these literals via the marker
// maps, so renaming a constant requires updating those maps in lockstep.
const (
ResultCorrect = "correct"
ResultPartial = "partial"
ResultWrong = "wrong"
)
// AttributeRow describes one attribute's comparison output. JS shape:
// {key, label, type, guessValue, targetValue, result, direction?}.
// AttributeRow describes one attribute's comparison output as one row of
// the render board: key/label identify the row, type drives the comparison
// algorithm, result is the rendered marker, direction is set only for
// wrong year-type rows ("up"/"down").
type AttributeRow struct {
Key string
Label string
@@ -35,8 +37,8 @@ type AttributeRow struct {
}
// classicAttributes is the ordered comparison spec. Order matters for both
// render output and the JS-port test that asserts `compareChampions(...)`
// returns rows in this exact sequence.
// render output and the test that asserts CompareChampions returns rows in
// this exact sequence — reordering changes the on-screen board.
var classicAttributes = []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact},
{Key: "species", Label: "Species", Type: attrMulti},
@@ -51,7 +53,7 @@ var classicAttributes = []AttributeRow{
// Values are compared per attr.Type:
// - exact: case-insensitive string equality.
// - multi: set comparison; full match → correct, partial overlap → partial,
// no overlap → wrong. Two empty sets are correct (matches JS).
// no overlap → wrong. Two empty sets are correct.
// - year: parses leading 4 digits; equal → correct, else wrong + a
// direction hint ("up" if guess<target, "down" if guess>target).
func CompareChampions(guess, target *Champion) []AttributeRow {
@@ -126,8 +128,9 @@ func asStringSlice(v any) []string {
case []string:
return x
case string:
// JS toSet falls back to splitting on "," when the value isn't an array.
// Champions.json never produces this branch but parity matters.
// Defensive: champions.json never sends a comma-joined string for a
// multi-valued attribute, but if a future data refresh ever does,
// split on "," instead of treating it as a single token.
if x == "" {
return nil
}
@@ -138,7 +141,7 @@ func asStringSlice(v any) []string {
// compareMultiValue: full match (case-insensitive, order-independent) →
// correct; any intersection → partial; otherwise wrong. Two empty sets
// are correct (matches JS, e.g. for hypothetical "no positions" champs).
// are correct (e.g. for a hypothetical "no positions" champion).
func compareMultiValue(guess, target []string) string {
g := toLowerSet(guess)
t := toLowerSet(target)
@@ -182,8 +185,7 @@ func setsEqual(a, b map[string]struct{}) bool {
return true
}
// parseYear extracts the first 4 digits of s. Returns 0 if absent/non-numeric
// — JS regex parity (`^(\d{4})`).
// parseYear extracts the first 4 digits of s. Returns 0 if absent/non-numeric.
func parseYear(s string) int {
if len(s) < 4 {
return 0
@@ -232,8 +234,8 @@ func compareYear(g, t int) (result, direction string) {
return ResultWrong, "down"
}
// formatValue mirrors JS's `formatValue`: empty → "—", array → comma-joined,
// otherwise toString.
// formatValue renders a value cell: empty → "—", array → comma-joined,
// otherwise stringified.
func formatValue(v any) string {
switch x := v.(type) {
case nil:
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"testing"
)
// Fixtures lifted from tests/modules/loldle/compare.test.js so any future
// drift between Go and JS scoring shows up as a test failure here.
// Fixtures cover the compare-scoring rules. Drift in the scoring algorithm
// or the canonical attribute order surfaces here as a test failure.
var aatrox = &Champion{
ChampionName: "Aatrox",
+5 -5
View File
@@ -2,9 +2,9 @@ package loldle
import "fmt"
// attemptFlavor returns a one-word reaction to a winning attempt. Branches
// match the JS source exactly (1 → "First try!", 2 → "Sharp!", final →
// "Phew — last one!", final-2 → "Close call!", else "Nice.").
// attemptFlavor returns a one-word reaction to a winning attempt:
// 1 → "First try!", 2 → "Sharp!", final → "Phew — last one!",
// final-2 → "Close call!", else "Nice.".
func attemptFlavor(attempt, max int) string {
if attempt <= 1 {
return "First try!"
@@ -27,11 +27,11 @@ func attemptFlavor(attempt, max int) string {
// < 60min → "3m 14s" (or "3m" when seconds == 0)
// otherwise → "1h 12m" (or "1h" when remaining minutes == 0)
//
// JS parity. Negative inputs clamp to 0 (matches JS's Math.max(0, …)).
// Negative inputs clamp to 0.
func formatDuration(ms int64) string {
total := ms / 1000
if ms%1000 >= 500 {
total++ // round-half-up to match JS Math.round
total++ // round-half-up
}
if total < 0 {
total = 0
+2 -2
View File
@@ -13,7 +13,7 @@ func TestAttemptFlavor(t *testing.T) {
6: "Close call!",
7: "Close call!",
8: "Phew — last one!",
9: "Phew — last one!", // attempt > max — defensive; matches JS >=
9: "Phew — last one!", // attempt > max — defensive (`>=` branch)
}
for attempt, want := range cases {
if got := attemptFlavor(attempt, maxAttempts); got != want {
@@ -28,7 +28,7 @@ func TestFormatDuration(t *testing.T) {
want string
}{
{0, "0s"},
{500, "1s"}, // round-half-up to JS Math.round
{500, "1s"}, // round-half-up
{499, "0s"},
{42_000, "42s"},
{60_000, "1m"},
+2 -1
View File
@@ -37,7 +37,8 @@ func (s *state) findByName(name string) *Champion {
// rehydrateGuesses recomputes board rows from the stored championNames.
// Champions removed from champions.json since the round started are skipped
// (returns the surviving prefix), matching JS.
// (returns the surviving prefix) so a data refresh never breaks an active
// round.
func (s *state) rehydrateGuesses(g *gameState) []boardEntry {
target := s.findByName(g.Target)
if target == nil {
+2 -3
View File
@@ -3,9 +3,8 @@ package loldle
import "strings"
// normalizeName folds a name to a comparable form: lowercase, alphanumeric
// only. JS-parity with util/normalize-name.js — `String(s).toLowerCase().
// replace(/[^a-z0-9]/g, "")`. Used for case/space/punctuation-insensitive
// lookup so "Kai'Sa", "kaisa", and "KAI SA" all collapse to the same key.
// only. Used for case/space/punctuation-insensitive lookup so "Kai'Sa",
// "kaisa", and "KAI SA" all collapse to the same key.
func normalizeName(s string) string {
lower := strings.ToLower(s)
out := make([]byte, 0, len(lower))
+2 -2
View File
@@ -63,8 +63,8 @@ func buildRows(championName string, results []AttributeRow) []guessRow {
}
// formatRowGroups joins one or more guess-row groups into a single <pre>
// block. Label column width is the max label across ALL groups, so stacked
// guesses on a board align with each other (JS parity).
// block. Label column width is the max label across ALL groups so stacked
// guesses on a board align with each other.
func formatRowGroups(groups [][]guessRow) string {
width := 0
for _, g := range groups {
+1 -2
View File
@@ -7,8 +7,7 @@ import (
// TestRenderGuess_AlignsLabelColumn locks the monospace column-alignment
// invariant: the label column must be padded to the longest label so stacked
// guesses on the board line up. Captures the JS render contract that the
// rest of the test suite never exercises.
// guesses on the board line up.
func TestRenderGuess_AlignsLabelColumn(t *testing.T) {
rows := []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Male", Result: ResultCorrect},
+10 -9
View File
@@ -15,11 +15,11 @@ const (
MaxGuessesCap = 10
)
// gameState is the per-subject KV record. Field tags match JS exactly so a
// JS-written round decodes cleanly. StartedAt is *int64 because the JS
// source initialises it to `null` (timer doesn't start until first guess) —
// gameState is the per-subject KV record.
//
// StartedAt is *int64 because the timer doesn't start until the first guess;
// using time.Time would marshal as "0001-01-01T00:00:00Z" instead of null
// and break wire-format parity.
// and lose that distinction.
//
// Guesses is just championNames; comparison rows are recomputed at render
// time against current champions.json so a weekly data refresh updates
@@ -30,8 +30,9 @@ type gameState struct {
StartedAt *int64 `json:"startedAt"` // ms-since-epoch | null
}
// stats lifetime score. JS shape — note no LastResultAt field (differs from
// wordle's stats; the JS loldle source omits it, parity dictates we do too).
// stats lifetime score. No LastResultAt field by design (differs from
// wordle's Stats — loldle only ever needed running streaks, not "last
// played at").
type stats struct {
Played int `json:"played"`
Wins int `json:"wins"`
@@ -80,8 +81,8 @@ func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
return nil
}
// loadStats returns lifetime score; missing → fresh-zero record (matches
// the JS `?? {…}` fallback).
// loadStats returns lifetime score; missing → fresh-zero record so callers
// never need a nil check.
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) {
var s stats
err := kv.GetJSON(ctx, statsKey(subject), &s)
@@ -118,7 +119,7 @@ func recordResult(ctx context.Context, kv storage.KVStore, subject string, won b
// getMaxGuesses returns the effective round length: the per-subject override
// if set and in range, otherwise MaxGuesses. Out-of-range values are
// silently ignored (matches JS).
// silently ignored — better to serve the default than 500 the user.
func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) {
var cfg roundConfig
err := kv.GetJSON(ctx, configKey(subject), &cfg)
+3 -1
View File
@@ -31,7 +31,9 @@ func TestGameState_StartedAtAsNumber(t *testing.T) {
}
func TestStats_NoLastResultAtField(t *testing.T) {
// JS loldle stats schema differs from wordle: no lastResultAt. Lock that.
// loldle's stats schema deliberately differs from wordle's — no
// lastResultAt field. Lock that, since adding the field would silently
// change the on-disk shape for every existing player.
b, _ := json.Marshal(stats{})
want := `{"played":0,"wins":0,"streak":0,"bestStreak":0}`
if string(b) != want {
+8 -10
View File
@@ -1,14 +1,13 @@
// Package lolschedule ports the JS lolschedule module — LoL esports match
// schedule via lolesports.com's persisted API.
// Package lolschedule serves LoL esports match schedules via lolesports.com's
// persisted API plus a daily push to subscribers.
//
// Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule
// Auth: x-api-key header (the public key embedded in lolesports.com's web
// client — no registration). If Riot ever rotates it, lift the new value
// Auth: x-api-key header — the public key embedded in lolesports.com's web
// client (no registration). If Riot ever rotates it, lift the new value
// from their public JS bundle.
//
// Cache strategy: KV-backed, 120s fresh window with 60-minute stale
// fallback. Same shape as the JS source so cross-runtime KV migration
// round-trips byte-for-byte.
// Cache strategy: KV-backed cacheRecord with a 120s fresh window and a
// 60-minute stale fallback (stale-while-error).
package lolschedule
import (
@@ -30,7 +29,7 @@ const (
apiURL = "https://esports-api.lolesports.com/persisted/gw/getSchedule"
// apiKey is the public lolesports.com web client key (not a secret).
// gosec flags it as a hardcoded credential; the value is shipped in
// Riot's own public JS bundle and serves the live site too.
// Riot's own public web bundle and serves the live site too.
// #nosec G101
apiKey = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"
userAgent = "miti99bot/0.1 (https://t.me/miti99bot)"
@@ -104,8 +103,7 @@ type schedulePage struct {
} `json:"data"`
}
// cacheRecord is the KV value: timestamp + events. Same shape as JS so KV
// export/import migration round-trips.
// cacheRecord is the KV value: fetch timestamp (ms-epoch) + events.
type cacheRecord struct {
Ts int64 `json:"ts"` // ms-since-epoch when fetched
Events []ScheduleEvent `json:"events"`
+2 -1
View File
@@ -53,7 +53,8 @@ func addDays(date time.Time, days int) time.Time {
}
// splitParts breaks the trimmed input into [dd, mm?, yyyy?] string parts.
// Mirrors JS splitParts: dash- or slash-separated, or 1/2/4/8-digit unbroken.
// Accepts dash- or slash-separated values, or a 1/2/4/8-digit unbroken
// run (today, this-month, this-year, full ddmmyyyy).
func splitParts(trimmed string) ([]string, string) {
if strings.ContainsAny(trimmed, "-/") {
// Replace both delimiters with a single one, then split.
+3 -3
View File
@@ -31,9 +31,9 @@ const defaultTarget = "VNG"
// %s slots: target (escaped), sender mention, sender mention.
const trongTruongHopTemplate = "Trong trường hợp nhóm này bị điều tra bởi %s, %s khẳng định không liên quan tới nhóm hoặc những cá nhân khác trong nhóm này. %s không rõ tại sao lại có mặt ở đây vào thời điểm này, có lẽ tài khoản đã được thêm bởi một bên thứ ba."
// lastPing mirrors the JS bot's wire format: { at: <ms-since-epoch number> }.
// Stored as int64 ms-epoch (not time.Time → RFC3339) so a future cross-runtime
// KV export/import migration round-trips byte-for-byte.
// lastPing is the value stored at the `last_ping` key: { at: <ms-since-epoch> }.
// int64 ms-epoch (not time.Time → RFC3339) keeps the on-disk shape compact
// and consistent with every other timestamp field in the bot's KV.
type lastPing struct {
At int64 `json:"at"`
}
+5 -6
View File
@@ -45,9 +45,8 @@ func TestPing_WritesLastPingKV(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
// Drive the KV side directly: lock the wire format (ms-epoch number, not
// RFC3339 string). A JS-written {at: 1700000000000} must round-trip into
// the Go struct without a custom decoder.
// Drive the KV side directly: lock the wire format as a ms-epoch number
// (not an RFC3339 string), matching every other timestamp in the bot's KV.
if err := kv.PutJSON(ctx, lastPingKey, lastPing{At: time.Now().UTC().UnixMilli()}); err != nil {
t.Fatalf("PutJSON: %v", err)
}
@@ -60,16 +59,16 @@ func TestPing_WritesLastPingKV(t *testing.T) {
t.Errorf("read-back lastPing.At = %d, want positive ms-epoch", got.At)
}
// Also verify a value with the JS-shape decodes correctly.
// Verify a hand-written {"at": <ms-epoch>} document decodes correctly.
if err := kv.Put(ctx, lastPingKey, []byte(`{"at":1700000000000}`)); err != nil {
t.Fatal(err)
}
got = lastPing{}
if err := kv.GetJSON(ctx, lastPingKey, &got); err != nil {
t.Fatalf("GetJSON js-shape: %v", err)
t.Fatalf("GetJSON ms-epoch shape: %v", err)
}
if got.At != 1700000000000 {
t.Errorf("js-shape round-trip: At = %d, want 1700000000000", got.At)
t.Errorf("ms-epoch round-trip: At = %d, want 1700000000000", got.At)
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
// Package trading is a paper-trading module for VN stocks. Per-user
// portfolio + buy/sell at market price + stats with P&L. Ported from the
// JS trading module in miti99bot; SQL-based history + retention cron are
// out of scope for this v1 port.
// portfolio + buy/sell at market price + stats with P&L. SQL-based trade
// history and a retention cron are out of scope today; the current
// implementation keeps only the live portfolio in KV.
package trading
import (
@@ -37,7 +37,7 @@ func FormatStock(n float64) string {
// FormatPnL renders a signed VND delta + percentage line, e.g.
// "+1.234 VND (+12.34%)" or "-500.000 VND (-5.00%)". When invested is zero
// the percentage is reported as 0.00 to avoid division-by-zero — matches JS.
// the percentage is reported as 0.00 to avoid division-by-zero.
func FormatPnL(currentValue, invested float64) string {
diff := currentValue - invested
pct := 0.0
+1 -1
View File
@@ -19,7 +19,7 @@ type Portfolio struct {
}
// PortfolioMeta tracks invested cost basis for P&L. CreatedAt is purely
// informational; carried for parity with JS schema.
// informational (ms-epoch when the portfolio first existed).
type PortfolioMeta struct {
Invested float64 `json:"invested"`
CreatedAt int64 `json:"createdAt"`
+4 -2
View File
@@ -42,7 +42,8 @@ func (s *state) randomSeed() string {
return seeds[s.rng.IntN(len(seeds))]
}
// fallbackRoundStart matches JS roundstart fallback when the model fails.
// fallbackRoundStart returns a generic category + hint pair the round can
// start with when the LLM call fails or returns unparseable output.
func fallbackRoundStart() (string, string) {
return "object", "it is something you might encounter in everyday life"
}
@@ -91,7 +92,8 @@ func (s *state) handleTwentyq(ctx context.Context, b *bot.Bot, update *models.Up
if err != nil {
return err
}
// Solved-but-lingering rounds → start fresh transparently (JS-parity).
// Solved-but-lingering rounds → start fresh transparently rather than
// reject the user with "round already solved".
if game != nil && game.Solved {
// Best-effort delete: a hard failure here means the solved-state
// branch re-enters every call and the user is stuck. Log but keep
+7 -6
View File
@@ -6,8 +6,8 @@ import (
"strings"
)
// Judgement is the canonical shape after parse + normalize. JS-parity field
// names — IsGuess for "is_guess", Answer ∈ {"yes","no"}.
// Judgement is the canonical shape after parse + normalize.
// Answer ∈ {"yes","no"}.
type Judgement struct {
IsGuess bool `json:"is_guess"`
Answer string `json:"answer"`
@@ -26,7 +26,8 @@ const defaultHint = "I couldn't fully parse that — try a clear yes/no question
var fenceRe = regexp.MustCompile("(?i)```(?:json)?")
// parseJSON returns the first balanced {...} JSON object found in `text`.
// Returns nil on parse failure — matches JS parseJudgementJson tolerance.
// Returns nil on parse failure — tolerant by design since LLM output is
// untrusted and we'd rather fall back to defaults than 500.
func parseJSON(text string) map[string]any {
if text == "" {
return nil
@@ -73,7 +74,7 @@ func parseJSON(text string) map[string]any {
}
// normalizeJudgement coerces a parsed payload to the canonical Judgement
// shape with safe defaults. JS-parity behaviour.
// shape with safe defaults (answer="no", hint=defaultHint, is_guess=false).
func normalizeJudgement(payload map[string]any) Judgement {
out := Judgement{Answer: "no", Hint: defaultHint}
if payload == nil {
@@ -110,8 +111,8 @@ func redactSecret(hint, target string) string {
return re.ReplaceAllString(hint, "(redacted)")
}
// parseRoundStart returns (category, initialHint) or zero values + nil on
// any failure. Caller substitutes JS-parity fallbacks.
// parseRoundStart returns (category, initialHint, ok). ok=false on any
// failure so the caller can substitute fallbacks rather than serve garbage.
func parseRoundStart(payload map[string]any) (string, string, bool) {
if payload == nil {
return "", "", false
+2 -2
View File
@@ -7,8 +7,8 @@ import (
const historyWindow = 5
// buildSystemPrompt: per-turn judge prompt. JS-parity verbatim. The LLM is
// instructed to emit one-line JSON; parser.go does the parse.
// buildSystemPrompt: per-turn judge prompt. The LLM is instructed to emit
// one-line JSON; parser.go does the parse.
func buildSystemPrompt(g GameState) string {
recent := g.Turns
if len(recent) > historyWindow {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/tiennm99/miti99bot/internal/storage"
)
// Turn is one Q&A entry. JS-parity field names.
// Turn is one Q&A entry stored in the game's history.
type Turn struct {
Text string `json:"text"`
IsGuess bool `json:"isGuess"`
+2 -1
View File
@@ -11,7 +11,8 @@ const (
)
// openEndedRe rejects open-ended questions before spending a Gemini call.
// JS-parity prefix list.
// The prefix list is intentionally narrow (canonical interrogatives only)
// to avoid blocking borderline yes/no phrasings.
var openEndedRe = regexp.MustCompile(`(?i)^\s*(what|how|why|which|who|where|when|tell me|describe|explain)\b`)
// ValidateResult is the public outcome of validateQuestion. Either OK + the
+9 -12
View File
@@ -15,14 +15,11 @@ import (
"github.com/go-telegram/bot/models"
)
// SubjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game
// state), otherwise → user ID. Returns "" when no usable id is present
// (caller should reply with a "cannot identify chat" error). Channels and
// unknown chat types fall through to From.ID.
//
// Canonical shape: the wordle module previously had an explicit
// ChatTypePrivate branch returning From.ID, which is identical to the
// default branch — folded together here.
// SubjectFor returns the identity key per-module state should be scoped by:
// group/supergroup → chat ID (shared game state), otherwise → user ID.
// Returns "" when no usable id is present (caller should reply with a
// "cannot identify chat" error). Channels and unknown chat types fall
// through to From.ID.
func SubjectFor(msg *models.Message) string {
if msg == nil {
return ""
@@ -39,7 +36,7 @@ func SubjectFor(msg *models.Message) string {
}
// ArgAfterCommand returns everything after the first space in text, trimmed.
// Works for `/cmd arg`, `/cmd@bot arg`, etc. JS-parity.
// Works for `/cmd arg`, `/cmd@bot arg`, etc.
func ArgAfterCommand(text string) string {
if text == "" {
return ""
@@ -88,9 +85,9 @@ func ReplyHTML(ctx context.Context, b *bot.Bot, msg *models.Message, text string
}
// WinRate computes wins/played as a percentage rounded to nearest int.
// math.Round matches JS Math.round (round half away from zero for positive
// inputs); plain int(...) truncation would render 2/3 as 66% where JS shows
// 67%. Returns 0 when played == 0 (avoids NaN).
// Uses math.Round (round half away from zero for positive inputs) so 2/3
// renders as 67%, not 66% as plain int(...) truncation would give.
// Returns 0 when played == 0 (avoids NaN).
func WinRate(wins, played int) int {
if played <= 0 {
return 0
+1 -1
View File
@@ -32,7 +32,7 @@ func infoCommand() modules.Command {
}
chatID := fmt.Sprintf("%d", msg.Chat.ID)
// Telegram omits message_thread_id outside forum topics, so a 0
// here is "no thread", same as JS's `?? "n/a"`.
// here means "no thread" — render as "n/a" instead of "0".
threadID := "n/a"
if msg.MessageThreadID != 0 {
threadID = fmt.Sprintf("%d", msg.MessageThreadID)
+1 -2
View File
@@ -57,8 +57,7 @@ func stickerIDCommand() modules.Command {
}
}
// stickerFrom pulls the sticker out of the *replied-to* message, mirroring the
// JS handler: ctx.message.reply_to_message.sticker.
// stickerFrom pulls the sticker out of the *replied-to* message.
func stickerFrom(msg *models.Message) *models.Sticker {
if msg.ReplyToMessage == nil {
return nil
+6 -7
View File
@@ -1,21 +1,20 @@
// Package wordle ports the JS wordle module — classic 5-letter word-guess
// game, scored letter-by-letter green/yellow/grey.
// Package wordle implements the classic 5-letter word-guess game, scored
// letter-by-letter green/yellow/grey.
package wordle
// WordLength is wordle's fixed 5. Exposed so render.go and tests can reuse it
// without magic numbers.
const WordLength = 5
// LetterResult labels a single guessed letter's state. Values match the JS
// wire format byte-for-byte: "correct" | "partial" | "wrong".
// LetterResult labels a single guessed letter's state. Values are part of
// the stored game's JSON shape: "correct" | "partial" | "wrong".
const (
ResultCorrect = "correct"
ResultPartial = "partial"
ResultWrong = "wrong"
)
// LetterScore is the JSON shape stored in KV per guess. Field tags match JS
// exactly so a saved JS game round-trips through Go without a custom decoder.
// LetterScore is the JSON shape stored in KV per guess.
type LetterScore struct {
Letter string `json:"letter"`
Result string `json:"result"`
@@ -65,7 +64,7 @@ func CompareWords(guess, target string) []LetterScore {
// indexOfByte returns the first index of c in s, or -1.
// (bytes.IndexByte gives the same answer; inlined to keep this file
// dependency-free and emphasize the JS-parity origin.)
// dependency-free since the scoring algorithm is the whole point.)
func indexOfByte(s []byte, c byte) int {
for i, b := range s {
if b == c {
+1 -2
View File
@@ -22,8 +22,7 @@ type state struct {
locks keylock.Map // per-subject mutex; serialises Get→mutate→Put
}
// rejectMessage maps a validation failure into the user-facing reply. JS
// parity word-for-word.
// rejectMessage maps a validation failure into the user-facing reply.
func rejectMessage(reason rejectReason) string {
switch reason {
case reasonEmpty:
+7 -8
View File
@@ -2,8 +2,7 @@ package wordle
import "strings"
// normalizeWord lowercases input and strips anything outside a-z. JS parity:
// `String(input).toLowerCase().replace(/[^a-z]/g, "")`.
// normalizeWord lowercases input and strips anything outside a-z.
func normalizeWord(input string) string {
lower := strings.ToLower(input)
out := make([]byte, 0, len(lower))
@@ -16,9 +15,9 @@ func normalizeWord(input string) string {
return string(out)
}
// rejectReason classifies why validateGuess returned not-ok. Values match the
// JS source's discriminated-union strings so the user-facing reply mapping
// (handlers.rejectReason) stays parallel.
// rejectReason classifies why validateGuess returned not-ok. The user-facing
// reply mapping in handlers branches on these values, so renaming a constant
// here requires updating that mapping.
type rejectReason string
const (
@@ -27,9 +26,9 @@ const (
reasonUnknown rejectReason = "unknown"
)
// guessResult mirrors JS's `{ok: true, word} | {ok: false, reason, word}`.
// Word is always populated (the normalized form), even on failure, so callers
// can include it in error messages if desired.
// guessResult is the validateGuess outcome. Word is always populated with the
// normalized form, even on failure, so callers can include it in error
// messages without re-normalizing.
type guessResult struct {
OK bool
Word string
+11 -9
View File
@@ -11,11 +11,11 @@ import (
// MaxGuesses is the standard wordle round length.
const MaxGuesses = 6
// Cloud Firestore has no native per-document TTL equivalent to Cloudflare KV
// — saved games linger until manually cleaned. Out of scope today; tracked
// in port plan as a future cron.
// Note: the KV store has no native per-document TTL — saved games linger
// until manually cleaned. Out of scope today; could be added via a sweep
// cron if storage cost ever matters.
// GuessRecord is one entry in a game's history. JSON shape locks JS parity:
// GuessRecord is one entry in a game's history:
//
// { "word": "crane", "results": [{"letter":"c","result":"correct"}, ...] }
type GuessRecord struct {
@@ -24,11 +24,12 @@ type GuessRecord struct {
}
// GameState is the per-subject KV record for an in-progress (or finished)
// round. Field tags match JS exactly so a JS-written round decodes cleanly.
// round.
//
// `giveup` is always emitted (initialized to false on /wordle_new). Do NOT
// add omitempty — the JS source serializes the field unconditionally and
// cross-runtime migration depends on shape parity.
// add omitempty — the field is part of the stored document's shape, so
// emitting it unconditionally keeps already-saved games self-describing
// when inspected via raw KV dumps.
type GameState struct {
Target string `json:"target"`
Guesses []GuessRecord `json:"guesses"`
@@ -38,7 +39,8 @@ type GameState struct {
}
// Stats is the lifetime score record. lastResultAt is *int64 so an unplayed
// account marshals as `"lastResultAt": null` matching JS's initial shape.
// account marshals as `"lastResultAt": null` — distinguishes "never played"
// from "played at epoch zero".
type Stats struct {
Played int `json:"played"`
Wins int `json:"wins"`
@@ -73,7 +75,7 @@ func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameSt
}
// loadStats returns lifetime stats; missing → fresh-zero record (with
// LastResultAt=nil), matching the JS `?? {…}` fallback.
// LastResultAt=nil) so callers never need a nil check.
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
var s Stats
err := kv.GetJSON(ctx, statsKey(subject), &s)
+4 -3
View File
@@ -9,8 +9,9 @@ import (
)
func TestStats_DefaultLastResultAtIsNull(t *testing.T) {
// JS shape: `{ ..., lastResultAt: null }` — Go's *int64 must marshal
// as null when nil to keep cross-runtime KV documents compatible.
// Go's *int64 must marshal as null when nil, so unplayed accounts emit
// `"lastResultAt": null` and the field stays distinguishable from
// "played at ms-epoch 0".
s := Stats{}
b, err := json.Marshal(s)
if err != nil {
@@ -33,7 +34,7 @@ func TestStats_WithResultMarshalsAsNumber(t *testing.T) {
}
}
func TestGameState_JSONShapeMatchesJS(t *testing.T) {
func TestGameState_JSONShapeIsStable(t *testing.T) {
g := GameState{
Target: "crane",
Guesses: []GuessRecord{
+2 -3
View File
@@ -5,9 +5,8 @@ import (
"strings"
)
// rawWords holds the raw words.txt bytes embedded at compile time. The file
// was extracted byte-for-byte from the JS source's words-data.js so the Go
// and JS bots have identical dictionaries.
// rawWords holds the raw words.txt bytes embedded at compile time. One word
// per line, lowercase, exactly WordLength a-z; see loadWords for validation.
//
//go:embed data/words.txt
var rawWords string