Files
tiennm99bot/internal/modules/loldle/rules.go
T
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

143 lines
4.1 KiB
Go

package loldle
import (
"errors"
"strings"
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
)
// Rejections of a guess, mapped to API errors in http_api.go and to chat
// replies in command.go.
var (
errUnknownChampion = errors.New("loldle: champion not found")
errAmbiguous = errors.New("loldle: ambiguous champion")
errDuplicate = errors.New("loldle: champion already guessed")
// errTargetGone is a round whose answer left champions.json after a
// data refresh; it can no longer be scored.
errTargetGone = errors.New("loldle: answer no longer in the data")
)
// Marks are one byte per classic attribute, in classicAttributes order:
// c (correct), p (partial), w (wrong), and for a wrong release year u (the
// answer is later) or d (earlier). A year that cannot be compared is w.
const (
markCorrect = 'c'
markPartial = 'p'
markWrong = 'w'
markUp = 'u'
markDown = 'd'
)
// rules is LoLdle for the guessgame engine. A guess's Word is the
// champion's canonical name.
type rules struct {
champions []Champion
// keepGone scores a guess against a pinned answer that left
// champions.json instead of refusing it with errTargetGone. The daily
// mode sets it: a pinned day cannot be re-picked, so its board must stay
// playable. Unlimited rounds leave it unset and replace the round.
keepGone bool
}
func (rules) Label() string { return "LoLdle Daily" }
func (rules) MaxGuesses() int { return MaxGuesses }
// Judge resolves input to one champion, refuses a repeat and scores it
// against answer.
func (r rules) Judge(input, answer string, prior []guessgame.Guess) (guessgame.Guess, error) {
target := findChampionByExactName(r.champions, answer)
if target == nil && r.keepGone {
return r.judgeGone(input, answer, prior)
}
guess, ambiguous := findChampionMatch(r.champions, input)
switch {
case ambiguous:
return guessgame.Guess{}, errAmbiguous
case guess == nil:
return guessgame.Guess{}, errUnknownChampion
}
for _, p := range prior {
if p.Word == guess.ChampionName {
return guessgame.Guess{}, errDuplicate
}
}
if target == nil {
return guessgame.Guess{}, errTargetGone
}
return guessgame.Guess{Word: guess.ChampionName, Marks: marksOf(CompareChampions(guess, target))}, nil
}
// judgeGone scores a guess against an answer that is no longer in the data.
// Its attributes are unknown, so every other champion scores wrong in every
// column; naming the answer itself still wins.
func (r rules) judgeGone(input, answer string, prior []guessgame.Guess) (guessgame.Guess, error) {
word := answer
if normalizeName(input) != normalizeName(answer) {
guess, ambiguous := findChampionMatch(r.champions, input)
switch {
case ambiguous:
return guessgame.Guess{}, errAmbiguous
case guess == nil:
return guessgame.Guess{}, errUnknownChampion
}
word = guess.ChampionName
}
for _, p := range prior {
if p.Word == word {
return guessgame.Guess{}, errDuplicate
}
}
if word == answer {
return guessgame.Guess{Word: word, Marks: strings.Repeat(string(rune(markCorrect)), len(classicAttributes))}, nil
}
return guessgame.Guess{Word: word, Marks: strings.Repeat(string(rune(markWrong)), len(classicAttributes))}, nil
}
func (rules) Marker(mark byte) string {
switch mark {
case markCorrect:
return "🟩"
case markPartial:
return "🟨"
}
return "🟥"
}
func (rules) AnswerText(answer string) string { return answer }
// marksOf encodes a comparison as marks.
func marksOf(rows []AttributeRow) string {
var b strings.Builder
for _, r := range rows {
switch {
case r.Result == ResultCorrect:
b.WriteByte(markCorrect)
case r.Result == ResultPartial:
b.WriteByte(markPartial)
case r.Direction == "up":
b.WriteByte(markUp)
case r.Direction == "down":
b.WriteByte(markDown)
default:
b.WriteByte(markWrong)
}
}
return b.String()
}
// markResult splits one mark into the comparison result and year arrow.
func markResult(mark byte) (result, direction string) {
switch mark {
case markCorrect:
return ResultCorrect, ""
case markPartial:
return ResultPartial, ""
case markUp:
return ResultWrong, "up"
case markDown:
return ResultWrong, "down"
}
return ResultWrong, ""
}