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 {