mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 12:17:10 +00:00
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:
1 parent
c901f3ad40
commit
ec77a24db7
47 files changed
+142
-1491
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user