mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(noitu): add the noitu HTML5 Telegram game
Serve the BotFather game noitu from the bot's HTTP server under /games/noitu when GAME_BASE_URL is set. /noitu sends the game, Play answers with a signed, expiring link, and the server validates every word against the embedded noitu dictionary (CC BY-SA 4.0), runs the bot opponent and turn timer, and reports the score with setGameScore. Modules can now register game-short-name callbacks and HTTP routes.
This commit is contained in:
1 parent
fe552f43e4
commit
6f30d13b2d
52 files changed
+42452
-35
No files matched your search
@@ -123,6 +123,7 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) {
|
||||
})
|
||||
}
|
||||
|
||||
installGames(b, reg, auth)
|
||||
// Registered LAST, and that placement is load-bearing: the bot library
|
||||
// returns the first handler whose matcher accepts an update, so every
|
||||
// Command above out-ranks the fallback. A name defined in code therefore
|
||||
@@ -306,3 +307,47 @@ func matchCommand(name, botUsername string, update *models.Update) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// installGames registers one handler per BotFather game short name, then a
|
||||
// catch-all for any other game callback.
|
||||
//
|
||||
// A Play press carries game_short_name and no callback data, so the Data-prefix
|
||||
// handlers never see it. The same panic barrier, auth gate and metrics as the
|
||||
// Data callbacks apply. The catch-all answers a game that is not loaded (a
|
||||
// short name created in BotFather whose module is missing from MODULES) with an
|
||||
// empty answer, so the client stops spinning instead of waiting forever.
|
||||
func installGames(b *bot.Bot, reg *Registry, auth Auth) {
|
||||
for short, game := range reg.games {
|
||||
gameCopy := game
|
||||
shortCopy := short
|
||||
b.RegisterHandler(bot.HandlerTypeCallbackQueryGameShortName, shortCopy, bot.MatchTypeExact,
|
||||
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
||||
defer recoverHandler("game", shortCopy, func() { answerEmptyCallback(ctx, b, update) })
|
||||
if !auth.Permits(gameCopy.Visibility, update) {
|
||||
answerEmptyCallback(ctx, b, update)
|
||||
return
|
||||
}
|
||||
if err := gameCopy.Handler(ctx, b, update); err != nil {
|
||||
metrics.IncError("game-handler-error")
|
||||
log.Error("game", "short_name", shortCopy, "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
b.RegisterHandlerMatchFunc(
|
||||
func(update *models.Update) bool {
|
||||
return update != nil && update.CallbackQuery != nil && update.CallbackQuery.GameShortName != ""
|
||||
},
|
||||
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
||||
defer recoverHandler("game", "unknown", nil)
|
||||
answerEmptyCallback(ctx, b, update)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// answerEmptyCallback stops the client's spinner without showing anything.
|
||||
func answerEmptyCallback(ctx context.Context, b *bot.Bot, update *models.Update) {
|
||||
if update == nil || update.CallbackQuery == nil {
|
||||
return
|
||||
}
|
||||
_, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
"github.com/tiennm99/tiennm99bot/internal/testutil"
|
||||
)
|
||||
|
||||
// installGame registers one game module whose Play handler is h.
|
||||
func installGame(t *testing.T, visibility modules.Visibility, h modules.CallbackHandler) *testutil.RecordingBot {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"arcade"}, map[string]modules.Factory{
|
||||
"arcade": func(modules.Deps) modules.Module {
|
||||
return modules.Module{Games: []modules.Game{{ShortName: "arcade", Visibility: visibility, Handler: h}}}
|
||||
},
|
||||
}, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("build registry: %v", err)
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: 1})
|
||||
return rb
|
||||
}
|
||||
|
||||
func answeredCallbacks(rb *testutil.RecordingBot) []testutil.SentCall {
|
||||
var out []testutil.SentCall
|
||||
for _, c := range rb.Sent() {
|
||||
if c.Method == "answerCallbackQuery" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestInstall_GameCallbackReachesHandler(t *testing.T) {
|
||||
var got *models.Update
|
||||
rb := installGame(t, modules.VisibilityPublic, func(_ context.Context, _ *bot.Bot, u *models.Update) error {
|
||||
got = u
|
||||
return nil
|
||||
})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, 42, 7, "arcade"))
|
||||
if got == nil || got.CallbackQuery.GameShortName != "arcade" {
|
||||
t.Fatalf("game handler not called; got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_GameCallbackDeniedAnswersEmpty(t *testing.T) {
|
||||
called := false
|
||||
rb := installGame(t, modules.VisibilityPrivate, func(context.Context, *bot.Bot, *models.Update) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, 42, 7, "arcade"))
|
||||
if called {
|
||||
t.Fatal("denied user reached the game handler")
|
||||
}
|
||||
answers := answeredCallbacks(rb)
|
||||
if len(answers) != 1 || answers[0].Form["url"] != "" || answers[0].Form["text"] != "" {
|
||||
t.Fatalf("want one empty answer, got %+v", answers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_GameCallbackPanicIsAnswered(t *testing.T) {
|
||||
rb := installGame(t, modules.VisibilityPublic, func(context.Context, *bot.Bot, *models.Update) error {
|
||||
panic("game exploded")
|
||||
})
|
||||
captureLogs(t, func() {
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, 42, 7, "arcade"))
|
||||
})
|
||||
if len(answeredCallbacks(rb)) != 1 {
|
||||
t.Fatalf("panicking game handler left the query unanswered; sent %+v", rb.Sent())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_UnknownGameShortNameAnswersEmpty(t *testing.T) {
|
||||
called := false
|
||||
rb := installGame(t, modules.VisibilityPublic, func(context.Context, *bot.Bot, *models.Update) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewInlineGameCallback(42, "inline-1", "other"))
|
||||
if called {
|
||||
t.Fatal("a different short name reached the arcade handler")
|
||||
}
|
||||
if len(answeredCallbacks(rb)) != 1 {
|
||||
t.Fatalf("unknown game was not answered; sent %+v", rb.Sent())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
@@ -43,6 +44,29 @@ type Callback struct {
|
||||
Handler CallbackHandler
|
||||
}
|
||||
|
||||
// Game registers the handler for a BotFather HTML5 game's Play button.
|
||||
//
|
||||
// Telegram delivers a Play press as a callback query that carries
|
||||
// game_short_name instead of callback data, so a Data-prefix Callback can never
|
||||
// see it. The handler must answer the query — with the game URL, or with an
|
||||
// alert explaining why the game cannot open — or the client keeps spinning.
|
||||
type Game struct {
|
||||
ShortName string // BotFather short name: ^[A-Za-z0-9_]{3,64}$, unique across modules
|
||||
Visibility Visibility
|
||||
Handler CallbackHandler
|
||||
}
|
||||
|
||||
// Route is an HTTP handler a module serves on the bot's HTTP server.
|
||||
//
|
||||
// Pattern is a net/http ServeMux path pattern without a method or host, and
|
||||
// must live under /games/<module>/ so a module can never shadow the health
|
||||
// route or another module's paths. A module that has nothing to serve (for
|
||||
// example because its public URL is not configured) returns no routes.
|
||||
type Route struct {
|
||||
Pattern string
|
||||
Handler http.Handler
|
||||
}
|
||||
|
||||
// CronHandler runs when a cron fires, driven by the in-process scheduler
|
||||
// (internal/cron). The handler receives the owning module's Deps — the same
|
||||
// bundle its Factory got, including its own storage Collection.
|
||||
@@ -75,6 +99,8 @@ type Module struct {
|
||||
Commands []Command
|
||||
Callbacks []Callback
|
||||
Crons []Cron
|
||||
Games []Game // optional; BotFather games whose Play button this module answers
|
||||
HTTP []Route // optional; routes served on the bot's HTTP server
|
||||
CommandHook func(ctx context.Context, name string, update *models.Update) // optional; called by dispatcher after each authorized command invocation. update carries the originating Telegram update so hooks can attribute usage to a user.
|
||||
Fallback *CommandFallback // optional; handles a /command no module registered. At most one across all modules.
|
||||
Inline *InlineQuery // optional; handles inline-mode queries. At most one across all modules.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
)
|
||||
|
||||
const (
|
||||
msgDisabled = "Trò chơi nối từ chưa được cấu hình trên máy chủ này."
|
||||
msgNoGameTarget = "Không xác định được tin nhắn trò chơi. Hãy gửi /noitu để chơi."
|
||||
)
|
||||
|
||||
// handlePlay answers a Play press with the game URL carrying a signed token,
|
||||
// or with an alert when the game cannot open. Every path answers the query,
|
||||
// so the client never keeps spinning.
|
||||
func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
q := update.CallbackQuery
|
||||
if !s.enabled() {
|
||||
return answerAlert(ctx, b, q.ID, msgDisabled)
|
||||
}
|
||||
c, form := playClaims(q)
|
||||
if form == "" {
|
||||
return answerAlert(ctx, b, q.ID, msgNoGameTarget)
|
||||
}
|
||||
c.Expiry = s.cfg.now().Add(tokenTTL).Unix()
|
||||
token, err := signToken(s.cfg.key, c)
|
||||
if err != nil {
|
||||
_ = answerAlert(ctx, b, q.ID, msgDisabled)
|
||||
return err
|
||||
}
|
||||
// The form tells whether ?game= shares arrive as inline messages; the IDs
|
||||
// themselves are never logged.
|
||||
log.Debug("noitu play", "address", form)
|
||||
_, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{
|
||||
CallbackQueryID: q.ID,
|
||||
URL: s.cfg.baseURL + routePrefix + "?t=" + url.QueryEscape(token),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// playClaims addresses the game message: the message itself when the bot can
|
||||
// see it, the inaccessible message's chat and id otherwise, and the inline
|
||||
// message id for a game sent via the bot. form is "" when none is present.
|
||||
func playClaims(q *models.CallbackQuery) (claims, string) {
|
||||
c := claims{UserID: q.From.ID, Name: truncateName(q.From.FirstName)}
|
||||
switch {
|
||||
case q.Message.Message != nil:
|
||||
c.ChatID, c.MessageID = q.Message.Message.Chat.ID, q.Message.Message.ID
|
||||
return c, "message"
|
||||
case q.Message.InaccessibleMessage != nil:
|
||||
c.ChatID, c.MessageID = q.Message.InaccessibleMessage.Chat.ID, q.Message.InaccessibleMessage.MessageID
|
||||
return c, "inaccessible_message"
|
||||
case q.InlineMessageID != "":
|
||||
c.InlineID = q.InlineMessageID
|
||||
return c, "inline_message"
|
||||
}
|
||||
return c, ""
|
||||
}
|
||||
|
||||
func answerAlert(ctx context.Context, b *bot.Bot, id, text string) error {
|
||||
_, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: id, Text: text, ShowAlert: true})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
const (
|
||||
msgChannel = "Không thể chơi nối từ trong kênh. Hãy dùng /noitu trong nhóm hoặc chat riêng với bot."
|
||||
msgSendGameFail = "Không gửi được trò chơi nối từ. Thử lại sau nhé."
|
||||
)
|
||||
|
||||
// handleCommand sends the BotFather game. No reply markup: Telegram then adds
|
||||
// the Play button itself, which is the button the game requires first.
|
||||
// The command works even when the game is disabled; Play explains why.
|
||||
func (s *service) handleCommand(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg.Chat.Type == models.ChatTypeChannel {
|
||||
return chathelper.Reply(ctx, b, msg, msgChannel)
|
||||
}
|
||||
_, err := b.SendGame(ctx, &bot.SendGameParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
MessageThreadID: msg.MessageThreadID,
|
||||
GameShorName: ShortName, // the library's field name is misspelled
|
||||
})
|
||||
if err != nil {
|
||||
_ = chathelper.Reply(ctx, b, msg, msgSendGameFail)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dict
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// corpus is tiennm99/noitu's data/dictionary.txt, embedded unmodified. It is
|
||||
// CC BY-SA 4.0 data; see data/LICENSE and data/ATTRIBUTION.md.
|
||||
//
|
||||
//go:embed data/dictionary.txt
|
||||
var corpus string
|
||||
|
||||
var (
|
||||
defaultOnce sync.Once
|
||||
defaultStore *Store
|
||||
errDefault error
|
||||
)
|
||||
|
||||
// Default parses the embedded corpus on first use and returns the shared
|
||||
// Store. Parsing takes tens of milliseconds and a few MB of heap, so it only
|
||||
// runs when the game is enabled.
|
||||
func Default() (*Store, error) {
|
||||
defaultOnce.Do(func() {
|
||||
defaultStore, errDefault = Parse(corpus)
|
||||
})
|
||||
return defaultStore, errDefault
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Dictionary Data Attribution
|
||||
|
||||
> Copied from `tiennm99/noitu` (`data/dictionary.txt` and `data/LICENSE`
|
||||
> unchanged, this file with one sentence changed: how the corpus ships in the
|
||||
> bot binary) for the bot's `noitu` game. Paths below such as
|
||||
> `data/dictionary.txt` and the `make` targets refer to that repository.
|
||||
|
||||
The Vietnamese dictionary data used by this game is **not** original work of this project.
|
||||
It is derived from Wiktionary text licensed under **CC BY-SA 4.0**, and this file records
|
||||
the attribution and the modifications required by that license.
|
||||
|
||||
## Source
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Original work | Entries of [Wiktionary tiếng Việt](https://vi.wiktionary.org/), written by its contributors |
|
||||
| Original license | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) (Wiktionary text is dual-licensed CC BY-SA / GFDL) — full text in [`LICENSE`](./LICENSE) |
|
||||
| Asset | [`viwiktionary-latest-pages-articles.xml.bz2`](https://dumps.wikimedia.org/viwiktionary/latest/viwiktionary-latest-pages-articles.xml.bz2) — the Wikimedia Foundation's dump of every page of the Vietnamese Wiktionary edition with its current wikitext, ~61 MB compressed, ~43,000 pages with a Vietnamese section |
|
||||
| Refresh | regenerated monthly by Wikimedia; `latest/` is repointed at each new run |
|
||||
|
||||
**The asset is not pinned.** Each refresh fetches whatever `latest/` currently points at,
|
||||
roughly monthly. The exact bytes the committed `data/dictionary.txt` was derived from are
|
||||
recorded in its `#@` header lines and carried into every `data/noitu.db` built from it, in its
|
||||
`meta` table:
|
||||
`source_sha256` (SHA-256 of the file as read), `source_pages` (pages with a Vietnamese
|
||||
section, redirects excluded) and `source_fetched_at` (the dump's own modification time).
|
||||
Two refreshes a month apart may differ by a few hundred words; the corpus's git history
|
||||
shows exactly which words and definitions changed, and the hash says which dump a given
|
||||
image shipped. Dated dumps under `dumps.wikimedia.org/viwiktionary/`
|
||||
exist should a build ever need reproducing.
|
||||
|
||||
The attribution chain has one link before this project: Wiktionary tiếng Việt's
|
||||
contributors, who wrote the entries. The dump is their text as the wiki stores it; this
|
||||
project's builder reads the wikitext itself.
|
||||
|
||||
## Modifications made by this project
|
||||
|
||||
`server/cmd/build-dictionary` transforms the dump into `data/dictionary.txt`, a sorted text
|
||||
corpus of the accepted words and their definition excerpts, and builds `data/noitu.db` from
|
||||
that corpus. Both are a **modified version** of the source data. Changes:
|
||||
|
||||
1. **Section selection** — read only the Vietnamese section of each page, in either of the
|
||||
two markup dialects the wiki currently uses (`{{-vie-}}` or `== {{langname|vi}} ==`).
|
||||
Pages outside the main namespace, redirects, and pages with no Vietnamese section were
|
||||
skipped. Other languages' sections on the same page were not read.
|
||||
2. **Length filter** — kept only words of **2 or more space-separated syllables**, as
|
||||
required by the nối từ game rules. Single-syllable entries were dropped.
|
||||
3. **Content filter** — dropped entries containing digits or punctuation, entries using
|
||||
letters Vietnamese does not have (f, j, w, z), and entries whose syllables do not fit
|
||||
Vietnamese phonotactics (a closed inventory of onsets, nuclei and codas). Diacritic-free
|
||||
Vietnamese words ("con cua") are kept.
|
||||
4. **Normalization** — all words Unicode NFC-normalized, lowercased, and
|
||||
whitespace-collapsed. Capitalized headwords (`Hà Nội`) become lowercase entries; nothing
|
||||
is removed on the basis of capitalization or part of speech. Two pages whose titles
|
||||
normalize to one word are merged into one entry.
|
||||
5. **Spelling aliases** — added an `aliases` table mapping alternative Vietnamese spellings
|
||||
to canonical entries. Two kinds: competing tone placement in open oa/oe/uy syllables
|
||||
(`hoà` → `hòa`, `thuý` → `thúy`), and i/y alternation in Sino-Vietnamese syllables
|
||||
(`quí` → `quý`, `lí` → `lý`). The majority are the i/y kind. These aliases are generated
|
||||
by this project and are not present upstream.
|
||||
6. **Definition text** — each entry carries an **excerpt and modification** of its
|
||||
definitions, in a `meanings` table: the text of each `#` definition line of the
|
||||
Vietnamese section, with wiki markup removed (links reduced to their display text,
|
||||
formatting and references dropped, a few context and link templates unwrapped, every
|
||||
other template removed whole), cut to at most **five senses of 200 characters** each,
|
||||
and labelled with the Vietnamese name of the part-of-speech heading it sat under
|
||||
(`danh từ`, `động từ`, …; empty when the heading was not one the builder knows). This
|
||||
is not the entry as written: senses past the fifth, text past 200 characters, and
|
||||
template-only definitions the builder does not understand are gone.
|
||||
7. **Added columns and tables** — `first` and `last` syllable columns, a `syllables`
|
||||
count, an index on `first`, a `syllables` out-degree table, the `meanings` table above,
|
||||
and a `meta` table recording provenance (source URL, SHA-256, page count, fetch time,
|
||||
licence). All added for game lookups.
|
||||
8. **Deduplication** — a generated spelling variant that is itself a real word, or that
|
||||
more than one word would claim, is discarded rather than recorded as an alias.
|
||||
9. **Dropped fields** — everything else in an entry was discarded: example sentences,
|
||||
quotations, translations, pronunciations, etymologies, synonyms, derived terms,
|
||||
categories, images and references. The derived database carries word forms and the
|
||||
definition excerpts described in item 6, and nothing else of the entry.
|
||||
|
||||
## Share-alike obligation
|
||||
|
||||
CC BY-SA 4.0 is a **share-alike** license. The derived corpus `data/dictionary.txt`, the
|
||||
database `data/noitu.db` built from it, and any distribution of either, remain licensed under
|
||||
**CC BY-SA 4.0** — including this repository, which distributes the corpus, and a container
|
||||
image or any other packaged build of this project. Because the database
|
||||
now redistributes edited excerpts of the entries' text and not only their headwords, the
|
||||
attribution and this record of modifications travel with it wherever it goes.
|
||||
|
||||
This obligation applies to the **data only**. The source code of this project is licensed
|
||||
separately under Apache-2.0 (see the repository root `LICENSE` and `NOTICE`). In tiennm99bot the corpus is embedded in the bot binary with go:embed,
|
||||
unmodified; CC BY-SA 4.0 applies to this data only, the bot's code remains Apache-2.0.
|
||||
|
||||
## How to reproduce the derived data
|
||||
|
||||
```sh
|
||||
make fetch-dict # downloads the current Wiktionary tiếng Việt dump (~61 MB) into data/
|
||||
make refresh-dict # derives data/dictionary.txt from it, recording the file's SHA-256 in its header
|
||||
make dict # builds data/noitu.db from data/dictionary.txt
|
||||
```
|
||||
|
||||
The corpus is committed and the dump and database are not. Because `latest/` is repointed
|
||||
monthly, a refresh in a later month may not be byte-identical to an earlier one; the corpus
|
||||
as committed is what every build of a given revision uses.
|
||||
@@ -0,0 +1,428 @@
|
||||
Attribution-ShareAlike 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license. Our
|
||||
licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has authority to grant. Use of
|
||||
the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations
|
||||
for the public:
|
||||
wiki.creativecommons.org/Considerations_for_licensees
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution-ShareAlike 4.0 International Public
|
||||
License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-ShareAlike 4.0 International Public License ("Public
|
||||
License"). To the extent this Public License may be interpreted as a
|
||||
contract, You are granted the Licensed Rights in consideration of Your
|
||||
acceptance of these terms and conditions, and the Licensor grants You
|
||||
such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and
|
||||
conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-SA Compatible License means a license listed at
|
||||
creativecommons.org/compatiblelicenses, approved by Creative
|
||||
Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name
|
||||
of a Creative Commons Public License. The License Elements of this
|
||||
Public License are Attribution and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
k. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
l. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
m. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. Additional offer from the Licensor -- Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You apply.
|
||||
|
||||
c. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the
|
||||
Adapter's License You apply. You may satisfy this condition
|
||||
in any reasonable manner based on the medium, means, and
|
||||
context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material,
|
||||
including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the “Licensor.” The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,142 @@
|
||||
package dict
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustDefault(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Default()
|
||||
if err != nil {
|
||||
t.Fatalf("Default: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
// "ữ" as u + combining horn + combining tilde, uppercase, odd spacing.
|
||||
got, syl, err := Normalize(" Nữ ĐẸP\t")
|
||||
if err != nil || got != "nữ đẹp" || len(syl) != 2 {
|
||||
t.Fatalf("Normalize = %q %v %v", got, syl, err)
|
||||
}
|
||||
if _, _, err := Normalize(" \t "); err != ErrEmpty {
|
||||
t.Fatalf("blank input err = %v, want ErrEmpty", err)
|
||||
}
|
||||
if HasEnoughSyllables([]string{"một"}) {
|
||||
t.Fatal("one syllable counted as enough")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse already rejects a headword that is not normalized or is too short, so
|
||||
// loading the embedded corpus at all proves every entry is well formed.
|
||||
func TestDefaultCorpusLoads(t *testing.T) {
|
||||
s := mustDefault(t)
|
||||
if s.Len() < 30000 {
|
||||
t.Fatalf("corpus has %d words, want at least 30000", s.Len())
|
||||
}
|
||||
if s.SourceSHA256 == "" {
|
||||
t.Error("corpus header #@source_sha256 missing")
|
||||
}
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
opening := s.RandomOpening(rng, 20)
|
||||
last, _ := s.LastSyllable(opening)
|
||||
if s.OutDegree(last) < 20 {
|
||||
t.Fatalf("opening %q ends on %q with out-degree %d, want >= 20", opening, last, s.OutDegree(last))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSpellingVariants(t *testing.T) {
|
||||
s := mustDefault(t)
|
||||
cases := map[string]string{
|
||||
"hoà giải": "hòa giải", // tone moved to the second vowel
|
||||
"hòa man": "hoà man", // and back
|
||||
"lí luận": "lý luận", // i/y swap
|
||||
"sỹ quan": "sĩ quan", // listed sĩ/sỹ pair
|
||||
"lí do": "lí do", // both spellings are headwords: exact wins
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got, ok := s.Resolve(in); !ok || got != want {
|
||||
t.Errorf("Resolve(%q) = %q, %v; want %q", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
if got, ok := s.Resolve("xyz abc"); ok {
|
||||
t.Errorf("Resolve(non-word) = %q, want miss", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOnSmallCorpus(t *testing.T) {
|
||||
s, err := Parse("#@source_sha256 abc\n\nsĩ hai\tdanh từ|nghĩa một\nlý do\t|lý lẽ\nlí lẽ\t\nlý lẽ\t\n")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got, ok := s.Resolve("sỹ hai"); !ok || got != "sĩ hai" {
|
||||
t.Errorf("Resolve(sỹ hai) = %q %v, want sĩ hai", got, ok)
|
||||
}
|
||||
if got, ok := s.Resolve("lí do"); !ok || got != "lý do" {
|
||||
t.Errorf("Resolve(lí do) = %q %v, want lý do", got, ok)
|
||||
}
|
||||
// Both "lí lẽ" and "lý lẽ" are headwords: an exact match wins.
|
||||
if got, _ := s.Resolve("lí lẽ"); got != "lí lẽ" {
|
||||
t.Errorf("Resolve(lí lẽ) = %q, want exact match", got)
|
||||
}
|
||||
if first, _ := s.FirstSyllable("sĩ hai"); first != "sĩ" {
|
||||
t.Errorf("FirstSyllable = %q", first)
|
||||
}
|
||||
if m := s.Meanings("sĩ hai"); len(m) != 1 || m[0].POS != "danh từ" || m[0].Gloss != "nghĩa một" {
|
||||
t.Errorf("Meanings = %+v", m)
|
||||
}
|
||||
if m := s.Meanings("lý do"); len(m) != 1 || m[0].POS != "" {
|
||||
t.Errorf("Meanings with empty pos = %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsAmbiguousVariant(t *testing.T) {
|
||||
// "hoá lý" has variants "hóa lý", "hoá lí" and "hóa lí"; with two of them
|
||||
// real words, the lookup must not guess.
|
||||
s, err := Parse("hóa lý\t\nhoá lí\t\n")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got, ok := s.Resolve("hóa lí"); ok {
|
||||
t.Fatalf("ambiguous Resolve = %q, want miss", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsBadHeadwords(t *testing.T) {
|
||||
for _, bad := range []string{"một\t\n", "Hoa Hồng\t\n", "a b\t\n", "a b\t\na b\t\n", "# only header\n"} {
|
||||
if _, err := Parse(bad); err == nil {
|
||||
t.Errorf("Parse(%q) accepted", strings.TrimSpace(bad))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeaningsCapped(t *testing.T) {
|
||||
line := "ab cd" + strings.Repeat("\tpos|gloss", 8) + "\n"
|
||||
s, err := Parse(line)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := len(s.Meanings("ab cd")); got != maxSenses {
|
||||
t.Fatalf("Meanings len = %d, want %d", got, maxSenses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRules(t *testing.T) {
|
||||
if v, ok := toneShiftVariant("hòa"); !ok || v != "hoà" {
|
||||
t.Errorf("toneShiftVariant(hòa) = %q %v", v, ok)
|
||||
}
|
||||
if _, ok := toneShiftVariant("hoàn"); ok {
|
||||
t.Error("closed syllable shifted")
|
||||
}
|
||||
if _, ok := toneShiftVariant("quý"); ok {
|
||||
t.Error("qu onset shifted")
|
||||
}
|
||||
if v, ok := iyVariant("lý"); !ok || v != "lí" {
|
||||
t.Errorf("iyVariant(lý) = %q %v", v, ok)
|
||||
}
|
||||
if _, ok := iyVariant("gì"); ok {
|
||||
t.Error("gi digraph swapped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package dict is the nối từ word list: the embedded Wiktionary-derived
|
||||
// corpus, the normalization player input goes through, the spelling variants
|
||||
// a lookup accepts, and the word graph the engine and the bot opponent walk.
|
||||
//
|
||||
// Ported from tiennm99/noitu: Normalize from server/internal/vietnamese,
|
||||
// the variant generator from server/cmd/build-dictionary/aliases.go, and the
|
||||
// store from server/internal/dictionary (minus SQLite — the corpus is parsed
|
||||
// into maps once, in memory).
|
||||
package dict
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// MinSyllables is the shortest word the game accepts. Nối từ is played with
|
||||
// compound words; a single syllable is not a valid move.
|
||||
const MinSyllables = 2
|
||||
|
||||
// ErrEmpty is returned when input contains no usable characters.
|
||||
var ErrEmpty = errors.New("dict: empty input")
|
||||
|
||||
// Normalize canonicalizes raw player or corpus input and splits it into
|
||||
// syllables: NFC composition (so a base letter plus combining marks equals the
|
||||
// precomposed form), lowercase, and whitespace collapsed to single spaces.
|
||||
// Tone marks are kept exactly as typed; alternative tone placements are
|
||||
// handled by Store.Resolve, not here.
|
||||
func Normalize(raw string) (string, []string, error) {
|
||||
composed := norm.NFC.String(raw)
|
||||
// Composed again after lowering: case-folding can enable a composition that
|
||||
// only exists for the lowercase form, so the lowered string may no longer
|
||||
// be NFC.
|
||||
lowered := norm.NFC.String(strings.ToLower(composed))
|
||||
syllables := strings.Fields(lowered)
|
||||
if len(syllables) == 0 {
|
||||
return "", nil, ErrEmpty
|
||||
}
|
||||
return strings.Join(syllables, " "), syllables, nil
|
||||
}
|
||||
|
||||
// HasEnoughSyllables reports whether a word is long enough to be a legal move.
|
||||
func HasEnoughSyllables(syllables []string) bool {
|
||||
return len(syllables) >= MinSyllables
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package dict
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxSenses caps the meanings shown per word. The corpus builder already
|
||||
// enforces it; repeating the cap here keeps a hand-edited corpus from sending
|
||||
// an unbounded list to the page.
|
||||
const maxSenses = 5
|
||||
|
||||
// Sense is one meaning of a word: an optional part of speech and a gloss.
|
||||
// Both are plain text and must be rendered as text, never as markup.
|
||||
type Sense struct {
|
||||
POS string `json:"pos"`
|
||||
Gloss string `json:"gloss"`
|
||||
}
|
||||
|
||||
// entry is one headword. senses holds the raw "pos|gloss" cells, still
|
||||
// tab-separated, so meanings are only parsed for words a game actually shows.
|
||||
type entry struct {
|
||||
first, last string
|
||||
syllables int
|
||||
senses string
|
||||
}
|
||||
|
||||
// Store is the read-only word graph. Safe for concurrent use once built.
|
||||
type Store struct {
|
||||
words map[string]entry
|
||||
byFirst map[string][]string // first syllable → words, in corpus (sorted) order
|
||||
// openers lists every word sorted by how many words start with its last
|
||||
// syllable, most first, so RandomOpening can cut the list at a threshold.
|
||||
openers []string
|
||||
openerOut []int // parallel to openers: that out-degree
|
||||
// SourceSHA256 is the corpus header's #@source_sha256 value, for logs.
|
||||
SourceSHA256 string
|
||||
}
|
||||
|
||||
// Parse builds a Store from corpus text: '#' header lines, then one line per
|
||||
// word, `word<TAB>pos|gloss<TAB>…`. Every headword must already be in
|
||||
// normalized form with at least MinSyllables syllables; anything else is an
|
||||
// error, so a bad corpus fails at load rather than as an unplayable word.
|
||||
func Parse(raw string) (*Store, error) {
|
||||
s := &Store{words: map[string]entry{}, byFirst: map[string][]string{}}
|
||||
for line := range strings.Lines(raw) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") {
|
||||
if v, ok := strings.CutPrefix(line, "#@source_sha256 "); ok {
|
||||
s.SourceSHA256 = strings.TrimSpace(v)
|
||||
}
|
||||
continue
|
||||
}
|
||||
word, senses, _ := strings.Cut(line, "\t")
|
||||
normalized, syllables, err := Normalize(word)
|
||||
if err != nil || normalized != word || !HasEnoughSyllables(syllables) {
|
||||
return nil, fmt.Errorf("dict: corpus headword %q is not a normalized word of %d+ syllables", word, MinSyllables)
|
||||
}
|
||||
if _, dup := s.words[word]; dup {
|
||||
return nil, fmt.Errorf("dict: duplicate corpus headword %q", word)
|
||||
}
|
||||
e := entry{first: syllables[0], last: syllables[len(syllables)-1], syllables: len(syllables), senses: senses}
|
||||
s.words[word] = e
|
||||
s.byFirst[e.first] = append(s.byFirst[e.first], word)
|
||||
}
|
||||
if len(s.words) == 0 {
|
||||
return nil, fmt.Errorf("dict: corpus has no words")
|
||||
}
|
||||
s.buildOpeners()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) buildOpeners() {
|
||||
s.openers = make([]string, 0, len(s.words))
|
||||
for w := range s.words {
|
||||
s.openers = append(s.openers, w)
|
||||
}
|
||||
// Sorting by word first makes the order independent of map iteration, so
|
||||
// a seeded RandomOpening is reproducible.
|
||||
slices.Sort(s.openers)
|
||||
sort.SliceStable(s.openers, func(i, j int) bool {
|
||||
return s.OutDegree(s.words[s.openers[i]].last) > s.OutDegree(s.words[s.openers[j]].last)
|
||||
})
|
||||
s.openerOut = make([]int, len(s.openers))
|
||||
for i, w := range s.openers {
|
||||
s.openerOut[i] = s.OutDegree(s.words[w].last)
|
||||
}
|
||||
}
|
||||
|
||||
// Len reports how many words the store holds.
|
||||
func (s *Store) Len() int { return len(s.words) }
|
||||
|
||||
// Contains reports whether word is a canonical headword.
|
||||
func (s *Store) Contains(word string) bool {
|
||||
_, ok := s.words[word]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Resolve maps a normalized word to its canonical headword. An exact match
|
||||
// wins; otherwise the word's spelling variants (tone placement in oa/oe/uy,
|
||||
// i/y after certain onsets) are tried, and the lookup succeeds only when
|
||||
// exactly one of them is a word, so an ambiguous spelling is never guessed.
|
||||
func (s *Store) Resolve(word string) (string, bool) {
|
||||
if _, ok := s.words[word]; ok {
|
||||
return word, true
|
||||
}
|
||||
match := ""
|
||||
for _, v := range variantsFor(word) {
|
||||
if _, ok := s.words[v]; !ok {
|
||||
continue
|
||||
}
|
||||
if match != "" {
|
||||
return "", false
|
||||
}
|
||||
match = v
|
||||
}
|
||||
return match, match != ""
|
||||
}
|
||||
|
||||
// FirstSyllable reports the first syllable of a canonical word.
|
||||
func (s *Store) FirstSyllable(word string) (string, bool) {
|
||||
e, ok := s.words[word]
|
||||
return e.first, ok
|
||||
}
|
||||
|
||||
// LastSyllable reports the last syllable of a canonical word.
|
||||
func (s *Store) LastSyllable(word string) (string, bool) {
|
||||
e, ok := s.words[word]
|
||||
return e.last, ok
|
||||
}
|
||||
|
||||
// Syllables reports how many syllables a canonical word has.
|
||||
func (s *Store) Syllables(word string) (int, bool) {
|
||||
e, ok := s.words[word]
|
||||
return e.syllables, ok
|
||||
}
|
||||
|
||||
// WordsStartingWith iterates the words whose first syllable is syllable, in
|
||||
// corpus order.
|
||||
func (s *Store) WordsStartingWith(syllable string) iter.Seq[string] {
|
||||
words := s.byFirst[syllable]
|
||||
return func(yield func(string) bool) {
|
||||
for _, w := range words {
|
||||
if !yield(w) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OutDegree reports how many words start with syllable.
|
||||
func (s *Store) OutDegree(syllable string) int { return len(s.byFirst[syllable]) }
|
||||
|
||||
// RandomOpening picks a random word whose last syllable starts at least
|
||||
// minOut words, so the first reply is never forced. Falls back to the best
|
||||
// connected word when nothing reaches minOut.
|
||||
func (s *Store) RandomOpening(rng *rand.Rand, minOut int) string {
|
||||
// openerOut is sorted descending, so the qualifying words are a prefix.
|
||||
n := sort.Search(len(s.openerOut), func(i int) bool { return s.openerOut[i] < minOut })
|
||||
if n == 0 {
|
||||
return s.openers[0]
|
||||
}
|
||||
return s.openers[rng.IntN(n)]
|
||||
}
|
||||
|
||||
// Meanings returns up to maxSenses senses of a canonical word, in corpus
|
||||
// order. The slice is freshly built, so callers may keep or modify it.
|
||||
func (s *Store) Meanings(word string) []Sense {
|
||||
e, ok := s.words[word]
|
||||
if !ok || e.senses == "" {
|
||||
return nil
|
||||
}
|
||||
var out []Sense
|
||||
for cell := range strings.SplitSeq(e.senses, "\t") {
|
||||
pos, gloss, _ := strings.Cut(cell, "|")
|
||||
gloss = strings.TrimSpace(gloss)
|
||||
if gloss == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Sense{POS: strings.TrimSpace(pos), Gloss: gloss})
|
||||
if len(out) == maxSenses {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package dict
|
||||
|
||||
// Spelling variants, ported from tiennm99/noitu
|
||||
// server/cmd/build-dictionary/aliases.go. The builder there turns them into an
|
||||
// alias table; here Store.Resolve generates them at lookup time instead, which
|
||||
// yields the same mappings because both swaps undo themselves.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// Vietnamese combining tone marks. These are the five tones; they are distinct
|
||||
// from the combining marks that form letters (circumflex, breve, horn), which
|
||||
// belong to their base letter and must never be moved.
|
||||
var toneMarks = map[rune]bool{
|
||||
'̀': true, // huyền (grave)
|
||||
'́': true, // sắc (acute)
|
||||
'̃': true, // ngã (tilde)
|
||||
'̉': true, // hỏi (hook above)
|
||||
'̣': true, // nặng (dot below)
|
||||
}
|
||||
|
||||
// Marks that turn a base letter into a different Vietnamese letter (ă â ê ô ơ ư).
|
||||
// A vowel carrying one of these is not a plain vowel and is excluded from the
|
||||
// tone-shifting rule below.
|
||||
var letterMarks = map[rune]bool{
|
||||
'̂': true, // circumflex: â ê ô
|
||||
'̆': true, // breve: ă
|
||||
'̛': true, // horn: ơ ư
|
||||
}
|
||||
|
||||
// cluster is one base rune plus the combining marks attached to it.
|
||||
type cluster struct {
|
||||
base rune
|
||||
mods []rune
|
||||
}
|
||||
|
||||
func (c cluster) tone() (rune, bool) {
|
||||
for _, m := range c.mods {
|
||||
if toneMarks[m] {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (c cluster) hasLetterMark() bool {
|
||||
for _, m := range c.mods {
|
||||
if letterMarks[m] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c cluster) without(target rune) cluster {
|
||||
out := cluster{base: c.base}
|
||||
for _, m := range c.mods {
|
||||
if m != target {
|
||||
out.mods = append(out.mods, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c cluster) with(extra rune) cluster {
|
||||
out := cluster{base: c.base, mods: append([]rune{}, c.mods...)}
|
||||
out.mods = append(out.mods, extra)
|
||||
return out
|
||||
}
|
||||
|
||||
// decompose splits a syllable into base-plus-marks clusters, working on the NFD
|
||||
// form so that every diacritic is visible as its own rune.
|
||||
func decompose(syllable string) []cluster {
|
||||
var clusters []cluster
|
||||
for _, r := range norm.NFD.String(syllable) {
|
||||
if (toneMarks[r] || letterMarks[r]) && len(clusters) > 0 {
|
||||
last := len(clusters) - 1
|
||||
clusters[last].mods = append(clusters[last].mods, r)
|
||||
continue
|
||||
}
|
||||
clusters = append(clusters, cluster{base: r})
|
||||
}
|
||||
return clusters
|
||||
}
|
||||
|
||||
func recompose(clusters []cluster) string {
|
||||
var b strings.Builder
|
||||
for _, c := range clusters {
|
||||
b.WriteRune(c.base)
|
||||
for _, m := range c.mods {
|
||||
b.WriteRune(m)
|
||||
}
|
||||
}
|
||||
return norm.NFC.String(b.String())
|
||||
}
|
||||
|
||||
// shiftablePairs are the vowel clusters where Vietnamese has two competing tone
|
||||
// placements in real use: the older style marks the first vowel ("hòa", "thúy"),
|
||||
// the newer style marks the second ("hoà", "thuý"). Both spellings appear in
|
||||
// dictionaries and both are typed by players, but they are different codepoint
|
||||
// sequences, so one of them fails a lookup unless we record it as an alias.
|
||||
var shiftablePairs = [][2]rune{
|
||||
{'o', 'a'},
|
||||
{'o', 'e'},
|
||||
{'u', 'y'},
|
||||
}
|
||||
|
||||
// toneShiftVariant moves the tone mark to the other vowel of an oa/oe/uy cluster.
|
||||
// Returns the variant and whether one was produced.
|
||||
func toneShiftVariant(syllable string) (string, bool) {
|
||||
clusters := decompose(syllable)
|
||||
|
||||
for i := 0; i+1 < len(clusters); i++ {
|
||||
first, second := clusters[i], clusters[i+1]
|
||||
|
||||
// Only plain vowels take part; ơ/ư/ô/ê/ă follow different rules.
|
||||
if first.hasLetterMark() || second.hasLetterMark() {
|
||||
continue
|
||||
}
|
||||
if !isShiftablePair(first.base, second.base) {
|
||||
continue
|
||||
}
|
||||
// The two placements only compete in an OPEN syllable that ends on the
|
||||
// cluster. Add anything after it -- a final consonant ("hoàn") or a
|
||||
// third vowel ("hoài") -- and only one spelling is correct. Without this
|
||||
// check the builder invents words like "hòan" and "hòai".
|
||||
if i+1 != len(clusters)-1 {
|
||||
continue
|
||||
}
|
||||
// In "qu" the u is part of the consonant onset, not the vowel nucleus,
|
||||
// so "quý" must not become "qúy".
|
||||
if first.base == 'u' && i > 0 && clusters[i-1].base == 'q' {
|
||||
continue
|
||||
}
|
||||
|
||||
if tone, ok := first.tone(); ok {
|
||||
shifted := append([]cluster{}, clusters...)
|
||||
shifted[i] = first.without(tone)
|
||||
shifted[i+1] = second.with(tone)
|
||||
return recompose(shifted), true
|
||||
}
|
||||
if tone, ok := second.tone(); ok {
|
||||
shifted := append([]cluster{}, clusters...)
|
||||
shifted[i] = first.with(tone)
|
||||
shifted[i+1] = second.without(tone)
|
||||
return recompose(shifted), true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isShiftablePair(a, b rune) bool {
|
||||
for _, p := range shiftablePairs {
|
||||
if p[0] == a && p[1] == b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// iyOnsets are the consonant onsets after which Vietnamese genuinely writes
|
||||
// both i and y across the tone range: hi/hy, ki/ky, li/ly, mi/my, ti/ty, qui/quy.
|
||||
//
|
||||
// This is an allowlist rather than a general rule because most onsets take only
|
||||
// one spelling. "gi" is a digraph, so "gì" has no "gỳ"; "chì" has no "chỳ".
|
||||
//
|
||||
// "v" and "s" were deliberately removed after inspecting real output: they
|
||||
// produced non-words at scale ("chức vị" -> "chức vỵ", plus "vỳ", "vý", "sỵ",
|
||||
// "sỳ"). Their genuine alternations are lexical rather than productive, so the
|
||||
// few real ones are listed in iyPairs instead.
|
||||
var iyOnsets = map[string]bool{
|
||||
"h": true, "k": true, "l": true, "m": true, "t": true, "qu": true,
|
||||
}
|
||||
|
||||
// iyPairs are individual syllables that alternate even though their onset does
|
||||
// not do so productively. Listed explicitly, both directions.
|
||||
var iyPairs = map[string]string{
|
||||
"sĩ": "sỹ", "sỹ": "sĩ",
|
||||
"vĩ": "vỹ", "vỹ": "vĩ",
|
||||
}
|
||||
|
||||
// iyVariant swaps a lone i/y nucleus: quý/quí, lý/lí, mỹ/mĩ.
|
||||
//
|
||||
// The nucleus must be the final cluster, so closed syllables are excluded
|
||||
// ("tính" has no "týnh"), and the onset must be one that takes both spellings.
|
||||
func iyVariant(syllable string) (string, bool) {
|
||||
if pair, ok := iyPairs[syllable]; ok {
|
||||
return pair, true
|
||||
}
|
||||
|
||||
clusters := decompose(syllable)
|
||||
if len(clusters) < 2 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
last := len(clusters) - 1
|
||||
nucleus := clusters[last]
|
||||
if nucleus.hasLetterMark() {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var swapped rune
|
||||
switch nucleus.base {
|
||||
case 'i':
|
||||
swapped = 'y'
|
||||
case 'y':
|
||||
swapped = 'i'
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
var onset strings.Builder
|
||||
for _, c := range clusters[:last] {
|
||||
if c.hasLetterMark() {
|
||||
return "", false
|
||||
}
|
||||
onset.WriteRune(c.base)
|
||||
}
|
||||
if !iyOnsets[onset.String()] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
variant := append([]cluster{}, clusters...)
|
||||
variant[last] = cluster{base: swapped, mods: nucleus.mods}
|
||||
return recompose(variant), true
|
||||
}
|
||||
|
||||
// maxVariantsPerWord caps the combination count for a word whose syllables are
|
||||
// all variable. Real words reach at most a handful; the cap only guards against
|
||||
// a pathological entry blowing up the alias table.
|
||||
const maxVariantsPerWord = 32
|
||||
|
||||
// spellingsOf returns every accepted spelling of a single syllable, the
|
||||
// original first.
|
||||
func spellingsOf(syllable string) []string {
|
||||
out := []string{syllable}
|
||||
seen := map[string]bool{syllable: true}
|
||||
|
||||
for _, gen := range []func(string) (string, bool){toneShiftVariant, iyVariant} {
|
||||
alt, ok := gen(syllable)
|
||||
if !ok || seen[alt] {
|
||||
continue
|
||||
}
|
||||
seen[alt] = true
|
||||
out = append(out, alt)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// variantsFor returns every accepted alternative spelling of a whole word.
|
||||
//
|
||||
// Syllables must be varied together, not one at a time. "hoá lí" has two
|
||||
// variable syllables, and a player is far more likely to type the fully modern
|
||||
// "hóa lý" than either half-and-half form. Varying independently produced only
|
||||
// the halves and rejected the spelling most people actually use.
|
||||
func variantsFor(word string) []string {
|
||||
syllables := strings.Split(word, " ")
|
||||
|
||||
// Cartesian product over each syllable's accepted spellings.
|
||||
combos := [][]string{{}}
|
||||
for _, syl := range syllables {
|
||||
spellings := spellingsOf(syl)
|
||||
next := make([][]string, 0, len(combos)*len(spellings))
|
||||
for _, prefix := range combos {
|
||||
for _, s := range spellings {
|
||||
if len(next) >= maxVariantsPerWord {
|
||||
break
|
||||
}
|
||||
next = append(next, append(append([]string{}, prefix...), s))
|
||||
}
|
||||
}
|
||||
combos = next
|
||||
}
|
||||
|
||||
seen := map[string]bool{word: true}
|
||||
var out []string
|
||||
for _, combo := range combos {
|
||||
joined := strings.Join(combo, " ")
|
||||
if seen[joined] {
|
||||
continue
|
||||
}
|
||||
seen[joined] = true
|
||||
out = append(out, joined)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Package engine holds the rules of one nối từ game between a human and the
|
||||
// bot: what counts as a legal move, whose turn it is, when the turn expires
|
||||
// and how a word is scored.
|
||||
//
|
||||
// It is a two-player cut of tiennm99/noitu server/internal/game/engine.go.
|
||||
// The engine never reads the clock: every call that depends on time takes a
|
||||
// now argument, so the rules are testable without a timer.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
)
|
||||
|
||||
// Side is one of the two players.
|
||||
type Side int
|
||||
|
||||
const (
|
||||
// Human is the Telegram player.
|
||||
Human Side = iota + 1
|
||||
// Bot is the computer opponent. It plays the opening word.
|
||||
Bot
|
||||
)
|
||||
|
||||
func (s Side) other() Side {
|
||||
if s == Human {
|
||||
return Bot
|
||||
}
|
||||
return Human
|
||||
}
|
||||
|
||||
// Reason says why a submission was rejected, or why it did not count.
|
||||
type Reason string
|
||||
|
||||
// Reject reasons. ReasonNone means the word was accepted.
|
||||
const (
|
||||
ReasonNone Reason = ""
|
||||
ReasonGameOver Reason = "game_over"
|
||||
ReasonNotYourTurn Reason = "not_your_turn"
|
||||
ReasonTimeout Reason = "timeout"
|
||||
ReasonTooFewSyllables Reason = "too_few_syllables"
|
||||
ReasonNotInDictionary Reason = "not_in_dictionary"
|
||||
ReasonWrongLink Reason = "wrong_link"
|
||||
ReasonAlreadyUsed Reason = "already_used"
|
||||
)
|
||||
|
||||
// EndReason says how a finished game ended.
|
||||
type EndReason string
|
||||
|
||||
// End reasons. EndNone means the game is still in play.
|
||||
const (
|
||||
EndNone EndReason = ""
|
||||
EndTimeout EndReason = "timeout" // the human ran out of time
|
||||
EndNoLegalMove EndReason = "no_legal_move" // the human was left a dead end
|
||||
EndGaveUp EndReason = "gave_up" // the human resigned
|
||||
EndBotStuck EndReason = "bot_stuck" // the bot had no legal move
|
||||
EndMaxMoves EndReason = "max_moves" // the chain reached the per-game cap
|
||||
)
|
||||
|
||||
// Dictionary is the slice of the word store the engine needs.
|
||||
type Dictionary interface {
|
||||
Resolve(word string) (string, bool)
|
||||
FirstSyllable(word string) (string, bool)
|
||||
LastSyllable(word string) (string, bool)
|
||||
WordsStartingWith(syllable string) iter.Seq[string]
|
||||
OutDegree(syllable string) int
|
||||
}
|
||||
|
||||
// Move is one played word. Word is the canonical spelling, which may differ
|
||||
// from what the player typed.
|
||||
type Move struct {
|
||||
By Side
|
||||
Word string
|
||||
Syllables int
|
||||
Points int
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Engine is one game. Not safe for concurrent use; the caller serializes.
|
||||
type Engine struct {
|
||||
dict Dictionary
|
||||
opening string
|
||||
used map[string]struct{}
|
||||
current string
|
||||
turn Side
|
||||
turnLimit time.Duration
|
||||
grace time.Duration
|
||||
deadline time.Time
|
||||
history []Move
|
||||
score int
|
||||
|
||||
over bool
|
||||
winner Side
|
||||
endReason EndReason
|
||||
}
|
||||
|
||||
// New starts a game from an opening word played by the bot. The human moves
|
||||
// first, with a full turn from now.
|
||||
//
|
||||
// grace is extra time after the deadline during which a submission still
|
||||
// counts, to absorb network latency. It never earns speed points.
|
||||
func New(d Dictionary, opening string, turnLimit, grace time.Duration, now time.Time) (*Engine, error) {
|
||||
if d == nil {
|
||||
return nil, errors.New("engine: nil dictionary")
|
||||
}
|
||||
if turnLimit <= 0 || grace < 0 {
|
||||
return nil, fmt.Errorf("engine: invalid turn limit %v / grace %v", turnLimit, grace)
|
||||
}
|
||||
canonical, ok := d.Resolve(opening)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("engine: opening word %q is not in the dictionary", opening)
|
||||
}
|
||||
last, _ := d.LastSyllable(canonical)
|
||||
e := &Engine{
|
||||
dict: d,
|
||||
opening: canonical,
|
||||
used: map[string]struct{}{canonical: {}},
|
||||
current: last,
|
||||
turn: Human,
|
||||
turnLimit: turnLimit,
|
||||
grace: grace,
|
||||
deadline: now.Add(turnLimit),
|
||||
}
|
||||
if !e.HasLegalMove() {
|
||||
return nil, fmt.Errorf("engine: opening word %q ends on %q, which starts no other word", canonical, last)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Opening is the bot's opening word.
|
||||
func (e *Engine) Opening() string { return e.opening }
|
||||
|
||||
// Current is the syllable the next word must start with.
|
||||
func (e *Engine) Current() string { return e.current }
|
||||
|
||||
// Turn reports whose move it is.
|
||||
func (e *Engine) Turn() Side { return e.turn }
|
||||
|
||||
// Deadline is when the current turn ends, before the grace period.
|
||||
func (e *Engine) Deadline() time.Time { return e.deadline }
|
||||
|
||||
// TurnLimit is the length of one turn.
|
||||
func (e *Engine) TurnLimit() time.Duration { return e.turnLimit }
|
||||
|
||||
// Over reports whether the game has finished.
|
||||
func (e *Engine) Over() bool { return e.over }
|
||||
|
||||
// Winner is the winning side once the game is over.
|
||||
func (e *Engine) Winner() Side { return e.winner }
|
||||
|
||||
// EndReason reports how the game ended.
|
||||
func (e *Engine) EndReason() EndReason { return e.endReason }
|
||||
|
||||
// Score is the human's total points.
|
||||
func (e *Engine) Score() int { return e.score }
|
||||
|
||||
// History returns the played moves after the opening word, oldest first.
|
||||
func (e *Engine) History() []Move { return slices.Clone(e.history) }
|
||||
|
||||
// ChainLength counts the words played, opening word included.
|
||||
func (e *Engine) ChainLength() int { return len(e.history) + 1 }
|
||||
|
||||
// Expired reports whether the current turn ran out, grace period included.
|
||||
func (e *Engine) Expired(now time.Time) bool {
|
||||
return now.After(e.deadline.Add(e.grace))
|
||||
}
|
||||
|
||||
// Submit validates a word from side and, when legal, plays it.
|
||||
//
|
||||
// Checks run in this order: game over, turn, expiry, syllable count,
|
||||
// dictionary, link, reuse. Resolving before the link check matters: the
|
||||
// canonical spelling can change the first syllable ("sỹ hai" → "sĩ hai").
|
||||
// An expired turn ends the game against the human (the bot never waits).
|
||||
func (e *Engine) Submit(side Side, raw string, now time.Time) (Move, Reason) {
|
||||
if e.over {
|
||||
return Move{}, ReasonGameOver
|
||||
}
|
||||
if side != e.turn {
|
||||
return Move{}, ReasonNotYourTurn
|
||||
}
|
||||
if side == Human && e.Expired(now) {
|
||||
e.expire()
|
||||
return Move{}, ReasonTimeout
|
||||
}
|
||||
normalized, syllables, err := dict.Normalize(raw)
|
||||
if err != nil || !dict.HasEnoughSyllables(syllables) {
|
||||
return Move{}, ReasonTooFewSyllables
|
||||
}
|
||||
canonical, ok := e.dict.Resolve(normalized)
|
||||
if !ok {
|
||||
return Move{}, ReasonNotInDictionary
|
||||
}
|
||||
first, _ := e.dict.FirstSyllable(canonical)
|
||||
if first != e.current {
|
||||
return Move{}, ReasonWrongLink
|
||||
}
|
||||
if _, played := e.used[canonical]; played {
|
||||
return Move{}, ReasonAlreadyUsed
|
||||
}
|
||||
last, _ := e.dict.LastSyllable(canonical)
|
||||
// Count the canonical word's syllables: a variant spelling never changes
|
||||
// the count, but the canonical form is what was played.
|
||||
n := len(syllablesOf(canonical))
|
||||
move := Move{By: side, Word: canonical, Syllables: n, Points: e.pointsFor(n, first, now), At: now}
|
||||
|
||||
e.used[canonical] = struct{}{}
|
||||
e.history = append(e.history, move)
|
||||
if side == Human {
|
||||
e.score += move.Points
|
||||
}
|
||||
e.current = last
|
||||
e.turn = side.other()
|
||||
e.deadline = now.Add(e.turnLimit)
|
||||
// A dead end does not end the game here. The human keeps the turn and
|
||||
// loses it on the clock or claims it with NoMove; the caller settles the
|
||||
// bot's with BotStuck.
|
||||
return move, ReasonNone
|
||||
}
|
||||
|
||||
func syllablesOf(word string) []string {
|
||||
_, s, _ := dict.Normalize(word)
|
||||
return s
|
||||
}
|
||||
|
||||
// Timeout ends the game against the human when their turn has expired.
|
||||
// Reports whether it did.
|
||||
func (e *Engine) Timeout(now time.Time) bool {
|
||||
if e.over || e.turn != Human || !e.Expired(now) {
|
||||
return false
|
||||
}
|
||||
e.expire()
|
||||
return true
|
||||
}
|
||||
|
||||
// expire ends the human's expired turn: as no legal move when the position
|
||||
// was a dead end, so the result does not blame the clock, otherwise as a
|
||||
// timeout.
|
||||
func (e *Engine) expire() {
|
||||
if e.HasLegalMove() {
|
||||
e.end(Bot, EndTimeout)
|
||||
return
|
||||
}
|
||||
e.end(Bot, EndNoLegalMove)
|
||||
}
|
||||
|
||||
// NoMove ends the game at once when it is the human's turn and the position
|
||||
// leaves nothing to play, so a dead end need not wait out the clock. It is
|
||||
// not a resignation: a playable position is left alone. Reports whether it
|
||||
// ended the game.
|
||||
func (e *Engine) NoMove() bool {
|
||||
if e.over || e.turn != Human || e.HasLegalMove() {
|
||||
return false
|
||||
}
|
||||
e.end(Bot, EndNoLegalMove)
|
||||
return true
|
||||
}
|
||||
|
||||
// GiveUp ends the game as the human's resignation.
|
||||
func (e *Engine) GiveUp() bool {
|
||||
if e.over {
|
||||
return false
|
||||
}
|
||||
e.end(Bot, EndGaveUp)
|
||||
return true
|
||||
}
|
||||
|
||||
// BotStuck ends the game as a human win when it is the bot's turn and it has
|
||||
// nothing to play. Reports whether it did.
|
||||
func (e *Engine) BotStuck() bool {
|
||||
if e.over || e.turn != Bot || e.HasLegalMove() {
|
||||
return false
|
||||
}
|
||||
e.end(Human, EndBotStuck)
|
||||
return true
|
||||
}
|
||||
|
||||
// EndMaxMoves ends the game as a human win once the chain reached the cap the
|
||||
// caller enforces. Surviving that long beats the bot.
|
||||
func (e *Engine) EndMaxMoves() bool {
|
||||
if e.over {
|
||||
return false
|
||||
}
|
||||
e.end(Human, EndMaxMoves)
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *Engine) end(winner Side, reason EndReason) {
|
||||
e.over = true
|
||||
e.winner = winner
|
||||
e.endReason = reason
|
||||
}
|
||||
|
||||
// Used reports whether a canonical word has been played.
|
||||
func (e *Engine) Used(word string) bool {
|
||||
_, ok := e.used[word]
|
||||
return ok
|
||||
}
|
||||
|
||||
// WordsStartingWith iterates the dictionary words starting with syllable,
|
||||
// played ones included.
|
||||
func (e *Engine) WordsStartingWith(syllable string) iter.Seq[string] {
|
||||
return e.dict.WordsStartingWith(syllable)
|
||||
}
|
||||
|
||||
// LastSyllable reports the last syllable of a canonical word.
|
||||
func (e *Engine) LastSyllable(word string) (string, bool) {
|
||||
return e.dict.LastSyllable(word)
|
||||
}
|
||||
|
||||
// LegalMoves lists every unused word that answers the current syllable.
|
||||
func (e *Engine) LegalMoves() []string {
|
||||
var out []string
|
||||
for w := range e.dict.WordsStartingWith(e.current) {
|
||||
if !e.Used(w) {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HasLegalMove reports whether the side to act has anything to play.
|
||||
func (e *Engine) HasLegalMove() bool {
|
||||
for w := range e.dict.WordsStartingWith(e.current) {
|
||||
if !e.Used(w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Suggestions lists up to n words the side to act could still play, sorted.
|
||||
// Shown to a player who lost; empty means the position was a dead end.
|
||||
func (e *Engine) Suggestions(n int) []string {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
moves := e.LegalMoves()
|
||||
slices.Sort(moves)
|
||||
return moves[:min(n, len(moves))]
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
)
|
||||
|
||||
const (
|
||||
limit = 30 * time.Second
|
||||
grace = 2 * time.Second
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// testDict is a tiny graph:
|
||||
//
|
||||
// hoa hồng → hồng hào → hào hoa (back to hoa)
|
||||
// hồng tâm → tâm sự → sự cố → (cố is a dead end)
|
||||
// sĩ quan → quan tâm
|
||||
func testDict(t *testing.T) *dict.Store {
|
||||
t.Helper()
|
||||
s, err := dict.Parse("hoa hồng\nhồng hào\nhào hoa\nhồng tâm\ntâm sự\nsự cố\nsĩ quan\nquan tâm\nhào quang lấp lánh\n")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func newGame(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
e, err := New(testDict(t), "hoa hồng", limit, grace, t0)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestNewRejectsDeadOpening(t *testing.T) {
|
||||
if _, err := New(testDict(t), "sự cố", limit, grace, t0); err == nil {
|
||||
t.Fatal("dead-end opening accepted")
|
||||
}
|
||||
if _, err := New(testDict(t), "không có", limit, grace, t0); err == nil {
|
||||
t.Fatal("unknown opening accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitReasons(t *testing.T) {
|
||||
e := newGame(t)
|
||||
if e.Current() != "hồng" || e.Turn() != Human {
|
||||
t.Fatalf("start: current %q turn %v", e.Current(), e.Turn())
|
||||
}
|
||||
now := t0.Add(time.Second)
|
||||
cases := []struct {
|
||||
side Side
|
||||
word string
|
||||
want Reason
|
||||
}{
|
||||
{Bot, "hồng hào", ReasonNotYourTurn},
|
||||
{Human, "hồng", ReasonTooFewSyllables},
|
||||
{Human, " ", ReasonTooFewSyllables},
|
||||
{Human, "hồng xyz", ReasonNotInDictionary},
|
||||
{Human, "tâm sự", ReasonWrongLink},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, got := e.Submit(c.side, c.word, now); got != c.want {
|
||||
t.Errorf("Submit(%v, %q) = %q, want %q", c.side, c.word, got, c.want)
|
||||
}
|
||||
}
|
||||
if m, r := e.Submit(Human, "Hồng HÀO", now); r != ReasonNone || m.Word != "hồng hào" {
|
||||
t.Fatalf("valid move = %+v %q", m, r)
|
||||
}
|
||||
if _, r := e.Submit(Bot, "hào hoa", now); r != ReasonNone {
|
||||
t.Fatalf("bot move rejected: %q", r)
|
||||
}
|
||||
if _, r := e.Submit(Human, "hoa hồng", now); r != ReasonAlreadyUsed {
|
||||
t.Fatalf("reused opening = %q, want already_used", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSpellingLinksOnCanonical(t *testing.T) {
|
||||
e, err := New(testDict(t), "hào hoa", limit, grace, t0) // ends on "hoa"
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.current = "sĩ" // force a position answered only by "sĩ quan"
|
||||
if m, r := e.Submit(Human, "sỹ quan", t0); r != ReasonNone || m.Word != "sĩ quan" {
|
||||
t.Fatalf("variant = %+v %q, want canonical sĩ quan", m, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutAfterGrace(t *testing.T) {
|
||||
e := newGame(t)
|
||||
// Inside the grace window the move still counts, with no speed points.
|
||||
if m, r := e.Submit(Human, "hồng hào", t0.Add(limit+grace)); r != ReasonNone {
|
||||
t.Fatalf("move in grace = %q", r)
|
||||
} else if m.Points != 10+2*1+0+0+rarity(e, "hồng") {
|
||||
t.Fatalf("points in grace = %d", m.Points)
|
||||
}
|
||||
e.Submit(Bot, "hào hoa", t0.Add(limit+grace))
|
||||
late := t0.Add(limit + grace).Add(limit + grace + time.Millisecond)
|
||||
if e.Timeout(late.Add(-2 * time.Millisecond)) {
|
||||
t.Fatal("timeout fired inside the grace window")
|
||||
}
|
||||
if _, r := e.Submit(Human, "hoa hồng", late); r != ReasonTimeout {
|
||||
t.Fatalf("late move = %q, want timeout", r)
|
||||
}
|
||||
// "hoa" only starts the used opening, so the expired turn was a dead end.
|
||||
if !e.Over() || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
|
||||
t.Fatalf("after timeout: over %v winner %v reason %q", e.Over(), e.Winner(), e.EndReason())
|
||||
}
|
||||
if _, r := e.Submit(Human, "hoa hồng", late); r != ReasonGameOver {
|
||||
t.Fatalf("move after end = %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
func rarity(e *Engine, link string) int { return e.rarityPoints(link) }
|
||||
|
||||
func TestTimeoutSettlesWithoutMove(t *testing.T) {
|
||||
e := newGame(t)
|
||||
if e.Timeout(t0.Add(limit)) {
|
||||
t.Fatal("timeout at the deadline itself")
|
||||
}
|
||||
if !e.Timeout(t0.Add(limit + grace + time.Nanosecond)) {
|
||||
t.Fatal("timeout not settled after grace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotStuckAndGiveUp(t *testing.T) {
|
||||
e := newGame(t)
|
||||
e.Submit(Human, "hồng tâm", t0)
|
||||
if e.BotStuck() {
|
||||
t.Fatal("bot stuck while it can answer tâm")
|
||||
}
|
||||
e.Submit(Bot, "tâm sự", t0)
|
||||
e.Submit(Human, "sự cố", t0)
|
||||
if !e.BotStuck() || e.Winner() != Human || e.EndReason() != EndBotStuck {
|
||||
t.Fatalf("bot stuck: over %v winner %v reason %q", e.Over(), e.Winner(), e.EndReason())
|
||||
}
|
||||
|
||||
g := newGame(t)
|
||||
if !g.GiveUp() || g.Winner() != Bot || g.EndReason() != EndGaveUp {
|
||||
t.Fatal("give up did not end the game for the bot")
|
||||
}
|
||||
if g.GiveUp() {
|
||||
t.Fatal("second give up reported a change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreSumsHumanPointsOnly(t *testing.T) {
|
||||
e := newGame(t)
|
||||
m1, _ := e.Submit(Human, "hồng tâm", t0) // instant: full speed bonus
|
||||
e.Submit(Bot, "tâm sự", t0.Add(time.Second)) // bot points do not count
|
||||
m2, _ := e.Submit(Human, "sự cố", t0.Add(16*time.Second)) // half the turn left
|
||||
if e.Score() != m1.Points+m2.Points {
|
||||
t.Fatalf("score %d, want %d + %d", e.Score(), m1.Points, m2.Points)
|
||||
}
|
||||
// hồng tâm: base 10 + chain 2*1 + syllables 0 + speed 10 + rarity for "hồng" (2 words → 12).
|
||||
if m1.Points != 10+2+0+10+12 {
|
||||
t.Fatalf("m1 points = %d", m1.Points)
|
||||
}
|
||||
// sự cố: chain 2*3, speed 10*15/30 = 5, rarity for "sự" (1 word → 15).
|
||||
if m2.Points != 10+6+0+5+15 {
|
||||
t.Fatalf("m2 points = %d", m2.Points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointsCapped(t *testing.T) {
|
||||
e := newGame(t)
|
||||
e.history = make([]Move, 40) // chain bonus at its cap
|
||||
if got := e.pointsFor(30, "sự", t0); got != MaxPointsPerWord {
|
||||
t.Fatalf("points = %d, want cap %d", got, MaxPointsPerWord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestions(t *testing.T) {
|
||||
e := newGame(t)
|
||||
got := e.Suggestions(3)
|
||||
if len(got) != 2 || got[0] != "hồng hào" || got[1] != "hồng tâm" {
|
||||
t.Fatalf("Suggestions = %v", got)
|
||||
}
|
||||
if e.Suggestions(0) != nil {
|
||||
t.Fatal("Suggestions(0) not nil")
|
||||
}
|
||||
}
|
||||
|
||||
// deadEndGame leaves the human to answer "cố", which starts no word.
|
||||
func deadEndGame(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
e := newGame(t)
|
||||
e.current = "cố"
|
||||
return e
|
||||
}
|
||||
|
||||
func TestDeadEndExpiresAsNoLegalMove(t *testing.T) {
|
||||
late := t0.Add(limit + grace + time.Millisecond)
|
||||
|
||||
e := deadEndGame(t)
|
||||
if !e.Timeout(late) || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
|
||||
t.Fatalf("Timeout in a dead end: winner %v reason %q", e.Winner(), e.EndReason())
|
||||
}
|
||||
|
||||
e = deadEndGame(t)
|
||||
if _, r := e.Submit(Human, "cố gắng", late); r != ReasonTimeout || e.EndReason() != EndNoLegalMove {
|
||||
t.Fatalf("late Submit in a dead end: %q / %q", r, e.EndReason())
|
||||
}
|
||||
|
||||
// A playable position still ends as an ordinary timeout.
|
||||
e = newGame(t)
|
||||
if !e.Timeout(late) || e.EndReason() != EndTimeout {
|
||||
t.Fatalf("Timeout with moves left: reason %q", e.EndReason())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMoveClaimsOnlyADeadEnd(t *testing.T) {
|
||||
e := newGame(t)
|
||||
if e.NoMove() || e.Over() {
|
||||
t.Fatal("NoMove ended a playable position")
|
||||
}
|
||||
e = deadEndGame(t)
|
||||
if !e.NoMove() || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
|
||||
t.Fatalf("NoMove in a dead end: over %v reason %q", e.Over(), e.EndReason())
|
||||
}
|
||||
if e.NoMove() || e.GiveUp() {
|
||||
t.Fatal("a finished game ended a second time")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math/bits"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
)
|
||||
|
||||
// Scoring, ported from tiennm99/noitu server/internal/game/engine.go. A word
|
||||
// is worth more the longer the chain it extends, the longer the word, the
|
||||
// faster it was played, and the fewer words the corpus offers for the
|
||||
// syllable it answered.
|
||||
const (
|
||||
basePoints = 10
|
||||
chainBonus = 2 // per word already played
|
||||
chainBonusWords = 15 // the chain term stops growing here
|
||||
syllableBonus = 5 // per syllable beyond the minimum
|
||||
speedBonus = 10 // full for an instant answer, nothing on the buzzer
|
||||
rarityBonus = 15 // full for a syllable with a single answer
|
||||
rarityHalvingPenalty = 3 // lost per doubling of the answers available
|
||||
// MaxPointsPerWord caps one word's score. Overflow is trimmed from rarity
|
||||
// first, then speed, then syllables.
|
||||
MaxPointsPerWord = 100
|
||||
)
|
||||
|
||||
// pointsFor scores a word about to be played: syllables is its length, link
|
||||
// the syllable it answers, now when it was played.
|
||||
func (e *Engine) pointsFor(syllables int, link string, now time.Time) int {
|
||||
parts := []int{
|
||||
basePoints,
|
||||
chainBonus * min(e.ChainLength(), chainBonusWords),
|
||||
syllableBonus * (syllables - dict.MinSyllables),
|
||||
e.speedPoints(now),
|
||||
e.rarityPoints(link),
|
||||
}
|
||||
total := 0
|
||||
for _, p := range parts {
|
||||
total += p
|
||||
}
|
||||
return min(total, MaxPointsPerWord)
|
||||
}
|
||||
|
||||
// speedPoints pays for the share of the turn left on the clock. A move in the
|
||||
// grace period is past the deadline and earns nothing.
|
||||
func (e *Engine) speedPoints(now time.Time) int {
|
||||
remaining := e.deadline.Sub(now)
|
||||
if remaining <= 0 {
|
||||
return 0
|
||||
}
|
||||
remaining = min(remaining, e.turnLimit)
|
||||
return int(int64(speedBonus) * int64(remaining) / int64(e.turnLimit))
|
||||
}
|
||||
|
||||
// rarityPoints pays for how few corpus words answer link, spent ones
|
||||
// included: the reward is a property of the dictionary, not of this game.
|
||||
func (e *Engine) rarityPoints(link string) int {
|
||||
options := e.dict.OutDegree(link)
|
||||
if options < 1 {
|
||||
return 0
|
||||
}
|
||||
halvings := bits.Len(uint(options)) - 1
|
||||
return max(rarityBonus-rarityHalvingPenalty*halvings, 0)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
)
|
||||
|
||||
var testKey = []byte("0123456789abcdef0123456789abcdef")
|
||||
|
||||
const testBase = "https://game.example"
|
||||
|
||||
// testCorpus is a tiny word graph. The opening falls back to the best
|
||||
// connected word, "hoa hồng":
|
||||
//
|
||||
// hồng → hồng hào → hào hoa → hoa lá (dead) | hoa hồng (opening)
|
||||
// hồng → hồng tâm → tâm sự → sự cố (dead)
|
||||
const testCorpus = "hoa hồng\tdanh từ|loài hoa\nhồng hào\t\nhồng tâm\t\ntâm sự\t\nsự cố\t\nhào hoa\t\nhoa lá\t\n"
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
type reportCall struct {
|
||||
claims claims
|
||||
score int
|
||||
}
|
||||
|
||||
type fakeReporter struct {
|
||||
mu sync.Mutex
|
||||
calls []reportCall
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeReporter) Report(_ context.Context, c claims, score int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, reportCall{c, score})
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *fakeReporter) snapshot() []reportCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]reportCall(nil), r.calls...)
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
svc *service
|
||||
mod modules.Module
|
||||
clock *fakeClock
|
||||
reporter *fakeReporter
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, corpus string) *harness {
|
||||
t.Helper()
|
||||
store, err := dict.Parse(corpus)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
return newHarnessWithStore(t, store)
|
||||
}
|
||||
|
||||
func newHarnessWithStore(t *testing.T, store *dict.Store) *harness {
|
||||
t.Helper()
|
||||
h := &harness{t: t, clock: &fakeClock{t: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)}, reporter: &fakeReporter{}}
|
||||
seed := uint64(0)
|
||||
cfg := config{
|
||||
baseURL: testBase,
|
||||
key: testKey,
|
||||
now: h.clock.now,
|
||||
newRNG: func() *rand.Rand { seed++; return rand.New(rand.NewPCG(seed, 42)) },
|
||||
reporter: h.reporter,
|
||||
loadDict: func() (*dict.Store, error) { return store, nil },
|
||||
}
|
||||
h.svc = newService(cfg)
|
||||
h.mod = h.svc.module()
|
||||
if len(h.mod.HTTP) != 1 {
|
||||
t.Fatalf("enabled module has %d routes, want 1", len(h.mod.HTTP))
|
||||
}
|
||||
h.handler = h.mod.HTTP[0].Handler
|
||||
return h
|
||||
}
|
||||
|
||||
// token signs a chat-message token for user valid from the harness clock.
|
||||
func (h *harness) token(user int64) string {
|
||||
h.t.Helper()
|
||||
return h.tokenFor(user, 5)
|
||||
}
|
||||
|
||||
// tokenFor signs a token for user on game message msg in the user's chat.
|
||||
func (h *harness) tokenFor(user int64, msg int) string {
|
||||
h.t.Helper()
|
||||
tok, err := signToken(testKey, claims{UserID: user, Name: "Tí", ChatID: user, MessageID: msg, Expiry: h.clock.now().Add(tokenTTL).Unix()})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// post sends a raw body and returns the recorder.
|
||||
func (h *harness) post(path, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, routePrefix+"api/"+path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// call posts v and decodes the answer into out, failing on an unexpected status.
|
||||
func (h *harness) call(path string, v any, wantStatus int, out any) {
|
||||
h.t.Helper()
|
||||
body, _ := json.Marshal(v)
|
||||
rec := h.post(path, string(body))
|
||||
if rec.Code != wantStatus {
|
||||
h.t.Fatalf("%s: status %d, want %d; body %s", path, rec.Code, wantStatus, rec.Body.String())
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
|
||||
h.t.Fatalf("%s: decode %q: %v", path, rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *harness) start(user int64, difficulty string) sessionView {
|
||||
h.t.Helper()
|
||||
var v sessionView
|
||||
h.call("start", map[string]string{"token": h.token(user), "difficulty": difficulty}, http.StatusOK, &v)
|
||||
return v
|
||||
}
|
||||
|
||||
// move advances the clock past the per-move rate limit, then plays word.
|
||||
func (h *harness) move(session, word string) moveResponse {
|
||||
h.t.Helper()
|
||||
h.clock.advance(time.Second)
|
||||
var r moveResponse
|
||||
h.call("move", map[string]string{"session": session, "word": word}, http.StatusOK, &r)
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *harness) state(session string) sessionView {
|
||||
h.t.Helper()
|
||||
var v sessionView
|
||||
h.call("state", map[string]string{"session": session}, http.StatusOK, &v)
|
||||
return v
|
||||
}
|
||||
|
||||
// waitReport polls the state until the asynchronous score report settles.
|
||||
func (h *harness) waitReport(session string) sessionView {
|
||||
h.t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
v := h.state(session)
|
||||
if v.ScoreReported != reportPending || time.Now().After(deadline) {
|
||||
return v
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *harness) apiError(rec *httptest.ResponseRecorder) string {
|
||||
h.t.Helper()
|
||||
var e struct {
|
||||
Code string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||
h.t.Fatalf("decode error body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
if e.Message == "" {
|
||||
h.t.Errorf("error %q has no message", e.Code)
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/opponent"
|
||||
)
|
||||
|
||||
const (
|
||||
maxBodyBytes = 4 << 10
|
||||
maxWordRunes = 64
|
||||
maxWordTokens = 8
|
||||
)
|
||||
|
||||
// apiError is the JSON error body every API failure returns.
|
||||
type apiError struct {
|
||||
status int
|
||||
Code string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var (
|
||||
errBadRequest = apiError{http.StatusBadRequest, "bad_request", "Yêu cầu không hợp lệ."}
|
||||
errWordTooLong = apiError{http.StatusBadRequest, "bad_request", "Từ quá dài."}
|
||||
errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "Liên kết trò chơi không hợp lệ. Hãy mở lại trò chơi từ Telegram."}
|
||||
errTokenExpAPI = apiError{http.StatusUnauthorized, "token_expired", "Liên kết trò chơi đã hết hạn. Hãy bấm Chơi trong Telegram để mở lại."}
|
||||
errNoSession = apiError{http.StatusNotFound, "no_session", "Ván chơi không còn nữa. Hãy bắt đầu ván mới."}
|
||||
errGameOver = apiError{http.StatusConflict, "game_over", "Ván chơi đã kết thúc."}
|
||||
errTooFast = apiError{http.StatusTooManyRequests, "too_fast", "Bạn thao tác nhanh quá, thử lại sau giây lát."}
|
||||
errBusyAPI = apiError{http.StatusServiceUnavailable, "busy", "Máy chủ đang bận, thử lại sau."}
|
||||
errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Có lỗi xảy ra, thử lại sau."}
|
||||
rejectMessages = map[engine.Reason]string{
|
||||
engine.ReasonTooFewSyllables: "Từ phải có ít nhất 2 tiếng.",
|
||||
engine.ReasonNotInDictionary: "Từ này không có trong từ điển.",
|
||||
engine.ReasonWrongLink: "Từ phải bắt đầu bằng tiếng “%s”.",
|
||||
engine.ReasonAlreadyUsed: "Từ này đã được dùng rồi.",
|
||||
engine.ReasonTimeout: "Hết giờ!",
|
||||
}
|
||||
)
|
||||
|
||||
// sessionView is the game state the page renders. See docs/noitu-game.md.
|
||||
type sessionView struct {
|
||||
Session string `json:"session"`
|
||||
Player string `json:"player"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Status string `json:"status"` // playing | won | lost
|
||||
EndReason string `json:"end_reason"`
|
||||
Current string `json:"current"`
|
||||
Chain []chainEntry `json:"chain"`
|
||||
Score int `json:"score"`
|
||||
TurnLimitMS int64 `json:"turn_limit_ms"`
|
||||
DeadlineMS int64 `json:"deadline_ms"`
|
||||
ServerNowMS int64 `json:"server_now_ms"`
|
||||
Suggestions []string `json:"suggestions"`
|
||||
ScoreReported string `json:"score_reported"`
|
||||
}
|
||||
|
||||
type moveResult struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
PlayerWord string `json:"player_word"`
|
||||
BotWord string `json:"bot_word"`
|
||||
}
|
||||
|
||||
type moveResponse struct {
|
||||
Result moveResult `json:"result"`
|
||||
State sessionView `json:"state"`
|
||||
}
|
||||
|
||||
// handler serves the page, its assets and the JSON API under routePrefix.
|
||||
func (s *service) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET "+routePrefix+"{$}", serveAsset("index.html"))
|
||||
mux.HandleFunc("GET "+routePrefix+"app.js", serveAsset("app.js"))
|
||||
mux.HandleFunc("GET "+routePrefix+"app.css", serveAsset("app.css"))
|
||||
mux.HandleFunc("POST "+routePrefix+"api/start", s.apiStart)
|
||||
mux.HandleFunc("POST "+routePrefix+"api/move", s.apiMove)
|
||||
mux.HandleFunc("POST "+routePrefix+"api/state", s.apiState)
|
||||
mux.HandleFunc("POST "+routePrefix+"api/give-up", s.apiGiveUp)
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
// securityHeaders applies to every response under the game prefix. The token
|
||||
// travels in the page URL, so no referrer may leak it. Telegram Web embeds
|
||||
// games in an iframe, so framing is deliberately not restricted.
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'")
|
||||
if strings.HasPrefix(r.URL.Path, routePrefix+"api/") {
|
||||
h.Set("Cache-Control", "no-store")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, e apiError) { writeJSON(w, e.status, e) }
|
||||
|
||||
// decode reads one JSON object of at most maxBodyBytes with no unknown fields
|
||||
// and nothing after it.
|
||||
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeError(w, errBadRequest)
|
||||
return false
|
||||
}
|
||||
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
|
||||
writeError(w, errBadRequest)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type startRequest struct {
|
||||
Token string `json:"token"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
}
|
||||
|
||||
func (s *service) apiStart(w http.ResponseWriter, r *http.Request) {
|
||||
var req startRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
c, err := verifyToken(s.cfg.key, req.Token, now)
|
||||
switch {
|
||||
case errors.Is(err, errTokenExpired):
|
||||
writeError(w, errTokenExpAPI)
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, errBadTokenAPI)
|
||||
return
|
||||
}
|
||||
difficulty, ok := opponent.ParseDifficulty(req.Difficulty)
|
||||
if !ok {
|
||||
writeError(w, errBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.sessions.allowStart(c.UserID, now) {
|
||||
writeError(w, errTooFast)
|
||||
return
|
||||
}
|
||||
s.sweep(now)
|
||||
|
||||
sess, err := s.newSession(c, difficulty, now)
|
||||
if err != nil {
|
||||
log.Error("noitu start failed", "err", err)
|
||||
writeError(w, errInternalAPI)
|
||||
return
|
||||
}
|
||||
displaced, err := s.sessions.add(sess)
|
||||
if err != nil {
|
||||
writeError(w, errBusyAPI)
|
||||
return
|
||||
}
|
||||
if displaced != nil {
|
||||
s.settle(displaced, now)
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, s.view(sess, now))
|
||||
}
|
||||
|
||||
func (s *service) newSession(c claims, d opponent.Difficulty, now time.Time) (*session, error) {
|
||||
id, err := newSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rng := s.cfg.newRNG()
|
||||
strategy, err := opponent.New(d, rng)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eng, err := engine.New(s.words, s.words.RandomOpening(rng, minOpeningOut), turnLimit, turnGrace, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess := &session{id: id, claims: c, difficulty: d, eng: eng, strategy: strategy, lastSeen: now}
|
||||
sess.chain = append(sess.chain, s.entry(eng.Opening(), "bot", 0))
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *service) entry(word, by string, points int) chainEntry {
|
||||
m := s.words.Meanings(word)
|
||||
if m == nil {
|
||||
m = []dict.Sense{}
|
||||
}
|
||||
return chainEntry{Word: word, By: by, Points: points, Meanings: m}
|
||||
}
|
||||
|
||||
type sessionRequest struct {
|
||||
Session string `json:"session"`
|
||||
}
|
||||
|
||||
type moveRequest struct {
|
||||
Session string `json:"session"`
|
||||
Word string `json:"word"`
|
||||
}
|
||||
|
||||
// lockedSession finds and locks the session named in a request, settling an
|
||||
// expired turn first and answering no_session when the session is gone.
|
||||
// timedOut reports that this call ended the game on the clock. On ok the
|
||||
// caller must unlock.
|
||||
func (s *service) lockedSession(w http.ResponseWriter, id string, now time.Time) (sess *session, timedOut, ok bool) {
|
||||
sess = s.sessions.get(id)
|
||||
if sess == nil {
|
||||
writeError(w, errNoSession)
|
||||
return nil, false, false
|
||||
}
|
||||
sess.mu.Lock()
|
||||
if sess.eng.Timeout(now) {
|
||||
s.finish(sess, now)
|
||||
timedOut = true
|
||||
}
|
||||
if sess.expired(now) {
|
||||
sess.mu.Unlock()
|
||||
s.sessions.remove(sess)
|
||||
writeError(w, errNoSession)
|
||||
return nil, false, false
|
||||
}
|
||||
return sess, timedOut, true
|
||||
}
|
||||
|
||||
func (s *service) apiState(w http.ResponseWriter, r *http.Request) {
|
||||
var req sessionRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
sess, _, ok := s.lockedSession(w, req.Session, now)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer sess.mu.Unlock()
|
||||
if !sess.eng.Over() {
|
||||
sess.lastSeen = now
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.view(sess, now))
|
||||
}
|
||||
|
||||
func (s *service) apiGiveUp(w http.ResponseWriter, r *http.Request) {
|
||||
var req sessionRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
sess, _, ok := s.lockedSession(w, req.Session, now)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer sess.mu.Unlock()
|
||||
if s.resign(sess) {
|
||||
s.finish(sess, now)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.view(sess, now))
|
||||
}
|
||||
|
||||
func (s *service) apiMove(w http.ResponseWriter, r *http.Request) {
|
||||
var req moveRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
sess, timedOut, ok := s.lockedSession(w, req.Session, now)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer sess.mu.Unlock()
|
||||
if timedOut {
|
||||
// The move arrived after the deadline plus grace: it ended the game.
|
||||
writeJSON(w, http.StatusOK, moveResponse{Result: s.rejected(sess, engine.ReasonTimeout), State: s.view(sess, now)})
|
||||
return
|
||||
}
|
||||
if sess.eng.Over() {
|
||||
writeError(w, errGameOver)
|
||||
return
|
||||
}
|
||||
if now.Sub(sess.lastMove) < moveInterval {
|
||||
writeError(w, errTooFast)
|
||||
return
|
||||
}
|
||||
sess.lastMove, sess.lastSeen = now, now
|
||||
if wordTooLong(req.Word) {
|
||||
writeError(w, errWordTooLong)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.play(sess, req.Word, now)
|
||||
if err != nil {
|
||||
log.Error("noitu bot move failed", "err", err)
|
||||
writeError(w, errInternalAPI)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, moveResponse{Result: result, State: s.view(sess, now)})
|
||||
}
|
||||
|
||||
func wordTooLong(word string) bool {
|
||||
return utf8.RuneCountInString(word) > maxWordRunes || len(strings.Fields(word)) > maxWordTokens
|
||||
}
|
||||
|
||||
// play submits the human's word and, when it is accepted, the bot's reply.
|
||||
// Caller holds sess.mu.
|
||||
func (s *service) play(sess *session, word string, now time.Time) (moveResult, error) {
|
||||
mv, reason := sess.eng.Submit(engine.Human, word, now)
|
||||
if reason != engine.ReasonNone {
|
||||
if sess.eng.Over() {
|
||||
s.finish(sess, now)
|
||||
}
|
||||
return s.rejected(sess, reason), nil
|
||||
}
|
||||
sess.chain = append(sess.chain, s.entry(mv.Word, "player", mv.Points))
|
||||
result := moveResult{Accepted: true, PlayerWord: mv.Word}
|
||||
|
||||
switch {
|
||||
case len(sess.chain) >= maxChain:
|
||||
sess.eng.EndMaxMoves()
|
||||
case sess.eng.BotStuck():
|
||||
// The bot has no reply: BotStuck already ended the game as a win.
|
||||
default:
|
||||
botWord, err := s.botMove(sess, now)
|
||||
if err != nil {
|
||||
return moveResult{}, err
|
||||
}
|
||||
result.BotWord = botWord
|
||||
if len(sess.chain) >= maxChain {
|
||||
sess.eng.EndMaxMoves()
|
||||
}
|
||||
}
|
||||
if sess.eng.Over() {
|
||||
s.finish(sess, now)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// botMove plays the strategy's choice. Every strategy picks from the legal
|
||||
// moves, so a rejection here is a bug; fall back to the first legal move
|
||||
// rather than leaving the game stuck on the bot's turn.
|
||||
func (s *service) botMove(sess *session, now time.Time) (string, error) {
|
||||
word, err := sess.strategy.Choose(sess.eng)
|
||||
if err != nil {
|
||||
word = ""
|
||||
}
|
||||
mv, reason := sess.eng.Submit(engine.Bot, word, now)
|
||||
if reason != engine.ReasonNone {
|
||||
legal := sess.eng.LegalMoves()
|
||||
if len(legal) == 0 {
|
||||
return "", errors.New("noitu: bot has no move but BotStuck did not end the game")
|
||||
}
|
||||
if mv, reason = sess.eng.Submit(engine.Bot, legal[0], now); reason != engine.ReasonNone {
|
||||
return "", errors.New("noitu: engine rejected a legal bot move: " + string(reason))
|
||||
}
|
||||
}
|
||||
sess.chain = append(sess.chain, s.entry(mv.Word, "bot", mv.Points))
|
||||
return mv.Word, nil
|
||||
}
|
||||
|
||||
func (s *service) rejected(sess *session, reason engine.Reason) moveResult {
|
||||
msg := rejectMessages[reason]
|
||||
if reason == engine.ReasonWrongLink {
|
||||
msg = strings.Replace(msg, "%s", sess.eng.Current(), 1)
|
||||
}
|
||||
return moveResult{Reason: string(reason), Message: msg}
|
||||
}
|
||||
|
||||
// view renders the session for the page. Caller holds sess.mu.
|
||||
func (s *service) view(sess *session, now time.Time) sessionView {
|
||||
v := sessionView{
|
||||
Session: sess.id,
|
||||
Player: sess.claims.Name,
|
||||
Difficulty: string(sess.difficulty),
|
||||
Status: "playing",
|
||||
EndReason: string(sess.eng.EndReason()),
|
||||
Current: sess.eng.Current(),
|
||||
Chain: sess.chain,
|
||||
Score: sess.eng.Score(),
|
||||
TurnLimitMS: turnLimit.Milliseconds(),
|
||||
ServerNowMS: now.UnixMilli(),
|
||||
Suggestions: []string{},
|
||||
ScoreReported: sess.report,
|
||||
}
|
||||
if sess.eng.Over() {
|
||||
v.Status = "lost"
|
||||
if sess.eng.Winner() == engine.Human {
|
||||
v.Status = "won"
|
||||
}
|
||||
if sess.suggestions != nil {
|
||||
v.Suggestions = sess.suggestions
|
||||
}
|
||||
return v
|
||||
}
|
||||
v.DeadlineMS = sess.eng.Deadline().UnixMilli()
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
)
|
||||
|
||||
func TestAPI_PlayToBotStuckWinsAndReportsOnce(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
v := h.start(42, "")
|
||||
if v.Status != "playing" || v.Difficulty != "medium" || v.Current != "hồng" || v.Player != "Tí" {
|
||||
t.Fatalf("start view = %+v", v)
|
||||
}
|
||||
if len(v.Chain) != 1 || v.Chain[0].Word != "hoa hồng" || v.Chain[0].By != "bot" || len(v.Chain[0].Meanings) != 1 {
|
||||
t.Fatalf("opening chain = %+v", v.Chain)
|
||||
}
|
||||
if v.DeadlineMS-v.ServerNowMS != turnLimit.Milliseconds() || v.TurnLimitMS != 30000 {
|
||||
t.Fatalf("deadline %d now %d limit %d", v.DeadlineMS, v.ServerNowMS, v.TurnLimitMS)
|
||||
}
|
||||
|
||||
r := h.move(v.Session, "Hồng Tâm")
|
||||
if !r.Result.Accepted || r.Result.PlayerWord != "hồng tâm" || r.Result.BotWord != "tâm sự" {
|
||||
t.Fatalf("move 1 = %+v", r.Result)
|
||||
}
|
||||
if r.State.Current != "sự" || r.State.Status != "playing" {
|
||||
t.Fatalf("after move 1: %+v", r.State)
|
||||
}
|
||||
r = h.move(v.Session, "sự cố")
|
||||
if !r.Result.Accepted || r.Result.BotWord != "" || r.State.Status != "won" || r.State.EndReason != "bot_stuck" {
|
||||
t.Fatalf("move 2 = %+v / %+v", r.Result, r.State)
|
||||
}
|
||||
if r.State.DeadlineMS != 0 {
|
||||
t.Errorf("finished game still has a deadline: %d", r.State.DeadlineMS)
|
||||
}
|
||||
|
||||
sum := 0
|
||||
for _, e := range r.State.Chain {
|
||||
if e.By == "player" {
|
||||
sum += e.Points
|
||||
}
|
||||
}
|
||||
if sum == 0 || r.State.Score != sum {
|
||||
t.Fatalf("score %d, want the sum of player points %d", r.State.Score, sum)
|
||||
}
|
||||
|
||||
final := h.waitReport(v.Session)
|
||||
if final.ScoreReported != reportOK {
|
||||
t.Fatalf("score_reported = %q, want ok", final.ScoreReported)
|
||||
}
|
||||
calls := h.reporter.snapshot()
|
||||
if len(calls) != 1 || calls[0].score != sum || calls[0].claims.ChatID != 42 || calls[0].claims.MessageID != 5 {
|
||||
t.Fatalf("reporter calls = %+v", calls)
|
||||
}
|
||||
// Further calls never report again.
|
||||
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, nil)
|
||||
h.state(v.Session)
|
||||
if n := len(h.reporter.snapshot()); n != 1 {
|
||||
t.Fatalf("reporter called %d times, want once", n)
|
||||
}
|
||||
rec := h.post("move", fmt.Sprintf(`{"session":%q,"word":"cố gắng"}`, v.Session))
|
||||
if rec.Code != http.StatusConflict || h.apiError(rec) != "game_over" {
|
||||
t.Fatalf("move after end: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_RejectReasonsKeepTheTurn(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
v := h.start(42, "easy")
|
||||
cases := []struct{ word, reason string }{
|
||||
{"hồng", "too_few_syllables"},
|
||||
{"hồng xyz", "not_in_dictionary"},
|
||||
{"tâm sự", "wrong_link"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := h.move(v.Session, c.word)
|
||||
if r.Result.Accepted || r.Result.Reason != c.reason || r.Result.Message == "" {
|
||||
t.Errorf("%q: result %+v, want %s", c.word, r.Result, c.reason)
|
||||
}
|
||||
if r.State.DeadlineMS != v.DeadlineMS || r.State.Current != "hồng" || len(r.State.Chain) != 1 {
|
||||
t.Errorf("%q: a rejected move changed the turn: %+v", c.word, r.State)
|
||||
}
|
||||
}
|
||||
if r := h.move(v.Session, "tâm sự"); !strings.Contains(r.Result.Message, "“hồng”") {
|
||||
t.Errorf("wrong_link message %q does not name the syllable", r.Result.Message)
|
||||
}
|
||||
r := h.move(v.Session, "hồng hào") // bot answers "hào hoa"
|
||||
if r.Result.BotWord != "hào hoa" {
|
||||
t.Fatalf("bot reply = %q", r.Result.BotWord)
|
||||
}
|
||||
r = h.move(v.Session, "hoa hồng")
|
||||
if r.Result.Accepted || r.Result.Reason != "already_used" {
|
||||
t.Fatalf("reused opening: %+v", r.Result)
|
||||
}
|
||||
h.clock.advance(time.Second)
|
||||
rec := h.post("move", fmt.Sprintf(`{"session":%q,"word":%q}`, v.Session, strings.Repeat("a ", 9)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("over-long word: status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_TimeoutAfterGraceEndsTheGame(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
v := h.start(42, "")
|
||||
// Still inside the grace window: accepted, but without speed points.
|
||||
h.clock.advance(turnLimit + turnGrace - time.Second)
|
||||
var r moveResponse
|
||||
h.call("move", map[string]string{"session": v.Session, "word": "hồng hào"}, http.StatusOK, &r)
|
||||
if !r.Result.Accepted {
|
||||
t.Fatalf("move in grace rejected: %+v", r.Result)
|
||||
}
|
||||
h.clock.advance(turnLimit + turnGrace + time.Millisecond)
|
||||
h.call("move", map[string]string{"session": v.Session, "word": "hoa lá"}, http.StatusOK, &r)
|
||||
if r.Result.Accepted || r.Result.Reason != "timeout" || r.State.Status != "lost" || r.State.EndReason != "timeout" {
|
||||
t.Fatalf("late move = %+v / %+v", r.Result, r.State)
|
||||
}
|
||||
if fmt.Sprint(r.State.Suggestions) != "[hoa lá]" {
|
||||
t.Fatalf("suggestions = %v", r.State.Suggestions)
|
||||
}
|
||||
if got := h.waitReport(v.Session); got.ScoreReported != reportOK {
|
||||
t.Fatalf("score_reported = %q", got.ScoreReported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_StateSettlesTimeoutAndSweepReports(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
v := h.start(42, "")
|
||||
h.clock.advance(turnLimit + turnGrace + time.Second)
|
||||
got := h.state(v.Session)
|
||||
if got.Status != "lost" || got.EndReason != "timeout" || got.ScoreReported != reportSkipped {
|
||||
t.Fatalf("state after deadline = %+v", got)
|
||||
}
|
||||
|
||||
// A player who scored and then closed the page is settled by the sweep.
|
||||
v2 := h.start(43, "")
|
||||
h.move(v2.Session, "hồng hào")
|
||||
h.clock.advance(time.Minute)
|
||||
if err := h.svc.sweepCron(t.Context(), modules.Deps{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.waitReport(v2.Session).ScoreReported != reportOK {
|
||||
t.Fatal("sweep did not report the abandoned game")
|
||||
}
|
||||
h.clock.advance(finishedTTL + time.Second)
|
||||
h.svc.sweep(h.clock.now())
|
||||
rec := h.post("state", fmt.Sprintf(`{"session":%q}`, v2.Session))
|
||||
if rec.Code != http.StatusNotFound || h.apiError(rec) != "no_session" {
|
||||
t.Fatalf("expired session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_GiveUpWithoutPointsSkipsReport(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
v := h.start(42, "hard")
|
||||
var got sessionView
|
||||
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, &got)
|
||||
if got.Status != "lost" || got.EndReason != "gave_up" || got.ScoreReported != reportSkipped {
|
||||
t.Fatalf("give up = %+v", got)
|
||||
}
|
||||
if len(got.Suggestions) != 2 {
|
||||
t.Fatalf("suggestions = %v", got.Suggestions)
|
||||
}
|
||||
if len(h.reporter.snapshot()) != 0 {
|
||||
t.Fatal("zero score was reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_ReportOutcomes(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{fmt.Errorf("Bad Request: BOT_SCORE_NOT_MODIFIED"), reportOK},
|
||||
{fmt.Errorf("Forbidden"), reportFailed},
|
||||
} {
|
||||
h := newHarness(t, testCorpus)
|
||||
h.reporter.err = c.err
|
||||
v := h.start(42, "")
|
||||
h.move(v.Session, "hồng hào")
|
||||
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, nil)
|
||||
if got := h.waitReport(v.Session).ScoreReported; got != c.want {
|
||||
t.Errorf("reporter err %v: score_reported %q, want %q", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_InlineTokenReportsInlineAddress(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
tok, _ := signToken(testKey, claims{UserID: 9, InlineID: "BAAAAInline", Expiry: h.clock.now().Add(time.Hour).Unix()})
|
||||
var v sessionView
|
||||
h.call("start", map[string]string{"token": tok}, http.StatusOK, &v)
|
||||
h.move(v.Session, "hồng hào")
|
||||
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, nil)
|
||||
h.waitReport(v.Session)
|
||||
calls := h.reporter.snapshot()
|
||||
if len(calls) != 1 || calls[0].claims.InlineID != "BAAAAInline" || calls[0].claims.ChatID != 0 {
|
||||
t.Fatalf("reporter calls = %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_TokenErrors(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
tok := h.token(42)
|
||||
rec := h.post("start", fmt.Sprintf(`{"token":%q}`, tok[:len(tok)-2]+"xx"))
|
||||
if rec.Code != http.StatusUnauthorized || h.apiError(rec) != "bad_token" {
|
||||
t.Fatalf("tampered token: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
h.clock.advance(tokenTTL)
|
||||
rec = h.post("start", fmt.Sprintf(`{"token":%q}`, tok))
|
||||
if rec.Code != http.StatusUnauthorized || h.apiError(rec) != "token_expired" {
|
||||
t.Fatalf("expired token: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_RequestLimits(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
big := `{"token":"` + strings.Repeat("a", maxBodyBytes) + `"}`
|
||||
if rec := h.post("start", big); rec.Code != http.StatusBadRequest || h.apiError(rec) != "bad_request" {
|
||||
t.Fatalf("oversized body: %d", rec.Code)
|
||||
}
|
||||
if rec := h.post("start", `{"token":"x","score":1000}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown field: %d", rec.Code)
|
||||
}
|
||||
if rec := h.post("start", `{"token":"x"}{}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("trailing data: %d", rec.Code)
|
||||
}
|
||||
tok := h.token(42)
|
||||
if rec := h.post("start", fmt.Sprintf(`{"token":%q,"difficulty":"insane"}`, tok)); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown difficulty: %d", rec.Code)
|
||||
}
|
||||
if rec := h.post("state", `{"session":"nope"}`); rec.Code != http.StatusNotFound || h.apiError(rec) != "no_session" {
|
||||
t.Fatalf("unknown session: %d", rec.Code)
|
||||
}
|
||||
|
||||
v := h.start(42, "")
|
||||
h.move(v.Session, "hồng hào")
|
||||
rec := h.post("move", fmt.Sprintf(`{"session":%q,"word":"hoa lá"}`, v.Session))
|
||||
if rec.Code != http.StatusTooManyRequests || h.apiError(rec) != "too_fast" {
|
||||
t.Fatalf("fast move: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Ten starts a minute per user, counting the one above.
|
||||
for i := 0; i < startsPerMinute-1; i++ {
|
||||
h.start(42, "")
|
||||
}
|
||||
rec = h.post("start", fmt.Sprintf(`{"token":%q}`, h.token(42)))
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("start flood: %d", rec.Code)
|
||||
}
|
||||
h.clock.advance(time.Minute)
|
||||
h.start(42, "")
|
||||
}
|
||||
|
||||
func TestAPI_SessionCap(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
for user := int64(1); user <= maxSessions; user++ {
|
||||
h.start(user, "easy")
|
||||
}
|
||||
rec := h.post("start", fmt.Sprintf(`{"token":%q}`, h.token(maxSessions+1)))
|
||||
if rec.Code != http.StatusServiceUnavailable || h.apiError(rec) != "busy" {
|
||||
t.Fatalf("over cap: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Restarting replaces the player's own session instead of adding one.
|
||||
h.start(1, "easy")
|
||||
}
|
||||
|
||||
func TestAPI_NewStartReplacesOwnSession(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
first := h.start(42, "")
|
||||
h.start(42, "")
|
||||
if rec := h.post("state", fmt.Sprintf(`{"session":%q}`, first.Session)); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("replaced session still answers: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A player who closes the page mid-game and starts again on the same message
|
||||
// still gets the abandoned game's score reported.
|
||||
func TestAPI_RestartMidGameReportsTheReplacedScore(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
first := h.start(42, "")
|
||||
r := h.move(first.Session, "hồng hào")
|
||||
if !r.Result.Accepted || r.State.Score <= 0 {
|
||||
t.Fatalf("scoring move = %+v / score %d", r.Result, r.State.Score)
|
||||
}
|
||||
h.start(42, "")
|
||||
calls := waitCalls(t, h.reporter, 1)
|
||||
if len(calls) != 1 || calls[0].score != r.State.Score || calls[0].claims.MessageID != 5 {
|
||||
t.Fatalf("reporter calls = %+v, want one with score %d", calls, r.State.Score)
|
||||
}
|
||||
}
|
||||
|
||||
// Each user holds at most maxUserSessions live sessions; a further start on
|
||||
// another message settles and drops the oldest.
|
||||
func TestAPI_PerUserSessionCap(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
var ids []string
|
||||
for msg := 1; msg <= maxUserSessions; msg++ {
|
||||
var v sessionView
|
||||
h.call("start", map[string]string{"token": h.tokenFor(42, msg)}, http.StatusOK, &v)
|
||||
ids = append(ids, v.Session)
|
||||
}
|
||||
scored := h.move(ids[0], "hồng hào").State.Score
|
||||
var v sessionView
|
||||
h.call("start", map[string]string{"token": h.tokenFor(42, maxUserSessions+1)}, http.StatusOK, &v)
|
||||
|
||||
if rec := h.post("state", fmt.Sprintf(`{"session":%q}`, ids[0])); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("oldest session still answers: %d", rec.Code)
|
||||
}
|
||||
for _, id := range append(ids[1:], v.Session) {
|
||||
h.state(id)
|
||||
}
|
||||
calls := waitCalls(t, h.reporter, 1)
|
||||
if len(calls) != 1 || calls[0].score != scored || calls[0].claims.MessageID != 1 {
|
||||
t.Fatalf("reporter calls = %+v, want the evicted game's %d", calls, scored)
|
||||
}
|
||||
// Another user is unaffected.
|
||||
h.start(43, "")
|
||||
}
|
||||
|
||||
func waitCalls(t *testing.T, r *fakeReporter, n int) []reportCall {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
calls := r.snapshot()
|
||||
if len(calls) >= n || time.Now().After(deadline) {
|
||||
return calls
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// cycleCorpus is a three-word cycle: after one exchange the human must answer
|
||||
// the opening's first syllable, whose only word is the used opening.
|
||||
const cycleCorpus = "hoa hồng\t\nhồng hào\t\nhào hoa\t\n"
|
||||
|
||||
// deadEnd starts a game for user and plays into the dead end.
|
||||
func (h *harness) deadEnd(user int64) sessionView {
|
||||
h.t.Helper()
|
||||
v := h.start(user, "easy")
|
||||
sess := h.svc.sessions.get(v.Session)
|
||||
sess.mu.Lock()
|
||||
reply := sess.eng.LegalMoves()[0]
|
||||
sess.mu.Unlock()
|
||||
r := h.move(v.Session, reply)
|
||||
if !r.Result.Accepted || r.Result.BotWord == "" || r.State.Status != "playing" {
|
||||
h.t.Fatalf("exchange = %+v / %+v", r.Result, r.State)
|
||||
}
|
||||
return r.State
|
||||
}
|
||||
|
||||
func TestAPI_DeadEndEndsAsNoLegalMove(t *testing.T) {
|
||||
h := newHarness(t, cycleCorpus)
|
||||
v := h.deadEnd(42)
|
||||
var got sessionView
|
||||
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, &got)
|
||||
if got.Status != "lost" || got.EndReason != "no_legal_move" || len(got.Suggestions) != 0 {
|
||||
t.Fatalf("give up in a dead end = %+v", got)
|
||||
}
|
||||
|
||||
v = h.deadEnd(43)
|
||||
h.clock.advance(turnLimit + turnGrace + time.Second)
|
||||
if got := h.state(v.Session); got.Status != "lost" || got.EndReason != "no_legal_move" {
|
||||
t.Fatalf("dead end on the clock = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_HeadersAndRoutes(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
rec := h.post("start", `{}`)
|
||||
hdr := rec.Header()
|
||||
if !strings.Contains(hdr.Get("Content-Security-Policy"), "default-src 'self'") ||
|
||||
hdr.Get("Referrer-Policy") != "no-referrer" ||
|
||||
hdr.Get("Cache-Control") != "no-store" ||
|
||||
hdr.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("API headers = %v", hdr)
|
||||
}
|
||||
if hdr.Get("X-Frame-Options") != "" || strings.Contains(hdr.Get("Content-Security-Policy"), "frame-ancestors") {
|
||||
t.Fatal("framing restricted; Telegram Web embeds games in an iframe")
|
||||
}
|
||||
|
||||
for path, ctype := range map[string]string{"": "text/html", "app.js": "text/javascript", "app.css": "text/css"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+path+"?t=abc", nil))
|
||||
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), ctype) || rec.Body.Len() == 0 {
|
||||
t.Errorf("GET %q: %d %q", path, rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "public, max-age=300" || rec.Header().Get("Referrer-Policy") != "no-referrer" {
|
||||
t.Errorf("GET %q headers = %v", path, rec.Header())
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"web.go", "secret", "api/start"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+path, nil))
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("GET %q served", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One game against the real corpus, playing a legal word each turn, proves
|
||||
// the embedded dictionary, the engine and the opponent fit together.
|
||||
func TestAPI_RealCorpusGame(t *testing.T) {
|
||||
store, err := dict.Default()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := newHarnessWithStore(t, store)
|
||||
v := h.start(42, "hard")
|
||||
if len(v.Chain) != 1 || v.Current == "" {
|
||||
t.Fatalf("start view = %+v", v)
|
||||
}
|
||||
accepted, replies := 0, 0
|
||||
for range 5 {
|
||||
sess := h.svc.sessions.get(v.Session)
|
||||
sess.mu.Lock()
|
||||
legal := sess.eng.LegalMoves()
|
||||
sess.mu.Unlock()
|
||||
if len(legal) == 0 {
|
||||
break
|
||||
}
|
||||
r := h.move(v.Session, legal[0])
|
||||
if !r.Result.Accepted {
|
||||
t.Fatalf("legal word %q rejected: %+v", legal[0], r.Result)
|
||||
}
|
||||
accepted++
|
||||
if r.State.Status != "playing" {
|
||||
break
|
||||
}
|
||||
if r.Result.BotWord == "" || r.State.Current == "" {
|
||||
t.Fatalf("game still playing without a bot reply: %+v / %+v", r.Result, r.State)
|
||||
}
|
||||
replies++
|
||||
v = r.State
|
||||
}
|
||||
if accepted == 0 || replies == 0 {
|
||||
t.Fatalf("accepted %d words with %d bot replies; the real corpus must sustain a game", accepted, replies)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/web"
|
||||
)
|
||||
|
||||
var assetTypes = map[string]string{
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
}
|
||||
|
||||
// serveAsset serves one embedded page file. The files are small and fixed
|
||||
// for a binary's lifetime, so a short public cache is safe; the page's token
|
||||
// lives in the query string, which does not change the content.
|
||||
func serveAsset(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := web.FS.ReadFile(name)
|
||||
if err != nil {
|
||||
http.NotFound(w, nil)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", assetTypes[path.Ext(name)])
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package noitu is the "nối từ" Telegram HTML5 game: the /noitu command sends
|
||||
// the BotFather game, the Play button opens a page this bot serves, and the
|
||||
// player chains Vietnamese words against the bot opponent there.
|
||||
//
|
||||
// The server owns the game. The page only sends words; validation, the turn
|
||||
// timer, the bot's replies and the score all happen here, and the score is
|
||||
// reported with setGameScore so Telegram's in-chat high-score table works.
|
||||
// See docs/noitu-game.md.
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
mrand "math/rand/v2"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
)
|
||||
|
||||
const (
|
||||
// ShortName is the game's BotFather short name.
|
||||
ShortName = "noitu"
|
||||
|
||||
baseURLEnv = "GAME_BASE_URL"
|
||||
secretEnv = "NOITU_GAME_SECRET" //nolint:gosec // G101: an env var name, not a credential
|
||||
|
||||
// minSecretBytes is the shortest NOITU_GAME_SECRET accepted.
|
||||
minSecretBytes = 32
|
||||
// tokenKeyLabel derives the token key from the bot token when no
|
||||
// dedicated secret is set.
|
||||
tokenKeyLabel = "tiennm99bot/noitu/token/v1" //nolint:gosec // G101: a public derivation label; the key is the bot token
|
||||
|
||||
routePrefix = "/games/" + ShortName + "/"
|
||||
)
|
||||
|
||||
// config is everything New reads from the environment, injectable in tests.
|
||||
type config struct {
|
||||
baseURL string // public https base without trailing slash; "" disables
|
||||
key []byte // token HMAC key; empty disables
|
||||
now func() time.Time
|
||||
newRNG func() *mrand.Rand
|
||||
reporter scoreReporter // nil disables
|
||||
loadDict func() (*dict.Store, error)
|
||||
}
|
||||
|
||||
// New builds the module from the environment. The game is enabled only when
|
||||
// GAME_BASE_URL is a valid https URL and a token key is available; otherwise
|
||||
// /noitu still sends the game and Play explains it is not configured.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
cfg := config{
|
||||
baseURL: parseBaseURL(os.Getenv(baseURLEnv)),
|
||||
key: tokenKey(os.Getenv(secretEnv), deps),
|
||||
now: time.Now,
|
||||
newRNG: newSessionRNG,
|
||||
loadDict: dict.Default,
|
||||
}
|
||||
if deps.Bot != nil {
|
||||
cfg.reporter = botReporter{b: deps.Bot}
|
||||
}
|
||||
return newWithConfig(cfg)
|
||||
}
|
||||
|
||||
// newWithConfig assembles the module from an explicit config.
|
||||
func newWithConfig(cfg config) modules.Module { return newService(cfg).module() }
|
||||
|
||||
// newService loads the dictionary when the game is configured. A load failure
|
||||
// leaves the game disabled rather than failing startup for every module.
|
||||
func newService(cfg config) *service {
|
||||
svc := &service{cfg: cfg, sessions: newSessionStore()}
|
||||
if cfg.baseURL != "" && len(cfg.key) > 0 && cfg.reporter != nil {
|
||||
words, err := cfg.loadDict()
|
||||
if err != nil {
|
||||
log.Error("noitu dictionary load failed; game disabled", "err", err)
|
||||
} else {
|
||||
svc.words = words
|
||||
log.Info("noitu game enabled", "words", words.Len(), "dictionary_sha256", words.SourceSHA256)
|
||||
}
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// module describes the service to the registry. Routes and the sweep cron
|
||||
// exist only when the game is enabled, so a disabled game exposes nothing
|
||||
// over HTTP.
|
||||
func (svc *service) module() modules.Module {
|
||||
mod := modules.Module{
|
||||
Commands: []modules.Command{{
|
||||
Name: ShortName,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Chơi nối từ với bot",
|
||||
Handler: svc.handleCommand,
|
||||
}},
|
||||
Games: []modules.Game{{
|
||||
ShortName: ShortName,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Handler: svc.handlePlay,
|
||||
}},
|
||||
}
|
||||
if svc.enabled() {
|
||||
mod.HTTP = []modules.Route{{Pattern: routePrefix, Handler: svc.handler()}}
|
||||
mod.Crons = []modules.Cron{{Schedule: "* * * * *", Name: "noitu_sweep", Handler: svc.sweepCron}}
|
||||
}
|
||||
return mod
|
||||
}
|
||||
|
||||
// parseBaseURL accepts https://host[/path] and returns it without a trailing
|
||||
// slash. Anything else is logged and disables the game.
|
||||
func parseBaseURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
log.Warn("GAME_BASE_URL must be https://host[/path]; noitu game disabled")
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
}
|
||||
|
||||
// tokenKey returns NOITU_GAME_SECRET when set, otherwise a key derived from
|
||||
// the bot token. A secret that is set but too short disables the game rather
|
||||
// than silently falling back.
|
||||
func tokenKey(secret string, deps modules.Deps) []byte {
|
||||
if secret != "" {
|
||||
if len(secret) < minSecretBytes {
|
||||
log.Warn("NOITU_GAME_SECRET is shorter than 32 bytes; noitu game disabled")
|
||||
return nil
|
||||
}
|
||||
return []byte(secret)
|
||||
}
|
||||
if deps.Bot == nil {
|
||||
return nil
|
||||
}
|
||||
return deriveKey(deps.Bot.Token())
|
||||
}
|
||||
|
||||
// deriveKey is HMAC-SHA256(key=bot token, tokenKeyLabel). Rotating the bot
|
||||
// token therefore invalidates every open game link.
|
||||
func deriveKey(botToken string) []byte {
|
||||
if botToken == "" {
|
||||
return nil
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(botToken))
|
||||
mac.Write([]byte(tokenKeyLabel))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
// newSessionRNG seeds a per-game generator for the opening word and the bot's
|
||||
// choices. Gameplay randomness, not security; crypto/rand only seeds it.
|
||||
func newSessionRNG() *mrand.Rand {
|
||||
var seed [16]byte
|
||||
_, _ = rand.Read(seed[:])
|
||||
return mrand.New(mrand.NewPCG(binary.LittleEndian.Uint64(seed[:8]), binary.LittleEndian.Uint64(seed[8:])))
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/web"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
"github.com/tiennm99/tiennm99bot/internal/testutil"
|
||||
)
|
||||
|
||||
func TestToken_RoundTripTamperExpiryAndKey(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
c := claims{UserID: 7, Name: "An", ChatID: -100123, MessageID: 9, Expiry: now.Add(tokenTTL).Unix()}
|
||||
tok, err := signToken(testKey, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := verifyToken(testKey, tok, now)
|
||||
if err != nil || got != c {
|
||||
t.Fatalf("round trip = %+v, %v", got, err)
|
||||
}
|
||||
body, sig, _ := strings.Cut(tok, ".")
|
||||
forged, _ := signToken([]byte("another-key-another-key-another!!"), claims{UserID: 8, ChatID: 1, MessageID: 1, Expiry: c.Expiry})
|
||||
_, forgedSig, _ := strings.Cut(forged, ".")
|
||||
for name, bad := range map[string]string{
|
||||
"tampered body": strings.ToUpper(body[:4]) + body[4:] + "." + sig,
|
||||
"swapped sig": body + "." + forgedSig,
|
||||
"no dot": body,
|
||||
"empty": "",
|
||||
} {
|
||||
if _, err := verifyToken(testKey, bad, now); !errors.Is(err, errBadToken) {
|
||||
t.Errorf("%s: err = %v, want errBadToken", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := verifyToken([]byte("wrong-key-wrong-key-wrong-key-xx"), tok, now); !errors.Is(err, errBadToken) {
|
||||
t.Errorf("wrong key: err = %v", err)
|
||||
}
|
||||
if _, err := verifyToken(testKey, tok, now.Add(tokenTTL)); !errors.Is(err, errTokenExpired) {
|
||||
t.Errorf("expired: err = %v", err)
|
||||
}
|
||||
if _, err := verifyToken(nil, tok, now); !errors.Is(err, errBadToken) {
|
||||
t.Errorf("no key: err = %v", err)
|
||||
}
|
||||
// A signed token must still name exactly one game message.
|
||||
both, _ := signToken(testKey, claims{UserID: 7, ChatID: 1, MessageID: 1, InlineID: "x", Expiry: c.Expiry})
|
||||
neither, _ := signToken(testKey, claims{UserID: 7, Expiry: c.Expiry})
|
||||
for _, bad := range []string{both, neither} {
|
||||
if _, err := verifyToken(testKey, bad, now); !errors.Is(err, errBadToken) {
|
||||
t.Errorf("bad address accepted: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenKey(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
derived := tokenKey("", modules.Deps{Bot: rb.Bot})
|
||||
if len(derived) != 32 || string(derived) == "test-token" {
|
||||
t.Fatalf("derived key = %x", derived)
|
||||
}
|
||||
if string(derived) != string(deriveKey("test-token")) {
|
||||
t.Fatal("derivation is not deterministic")
|
||||
}
|
||||
if tokenKey("", modules.Deps{}) != nil {
|
||||
t.Fatal("key without a bot or secret")
|
||||
}
|
||||
secret := strings.Repeat("s", 32)
|
||||
if string(tokenKey(secret, modules.Deps{Bot: rb.Bot})) != secret {
|
||||
t.Fatal("NOITU_GAME_SECRET not preferred")
|
||||
}
|
||||
if tokenKey("short", modules.Deps{Bot: rb.Bot}) != nil {
|
||||
t.Fatal("short secret accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBaseURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"https://game.example": "https://game.example",
|
||||
" https://game.example/ ": "https://game.example",
|
||||
"https://game.example/bot//": "https://game.example/bot",
|
||||
"http://game.example": "",
|
||||
"game.example": "",
|
||||
"https://u:[email protected]": "",
|
||||
"https://game.example/?x=1": "",
|
||||
"https://game.example/#frag": "",
|
||||
"https:///no-host": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := parseBaseURL(in); got != want {
|
||||
t.Errorf("parseBaseURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DisabledWithoutBaseURL(t *testing.T) {
|
||||
t.Setenv(baseURLEnv, "")
|
||||
t.Setenv(secretEnv, "")
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
mod := New(modules.Deps{Bot: rb.Bot})
|
||||
if len(mod.HTTP) != 0 || len(mod.Crons) != 0 {
|
||||
t.Fatalf("disabled game exposes routes %v / crons %v", mod.HTTP, mod.Crons)
|
||||
}
|
||||
if len(mod.Commands) != 1 || mod.Commands[0].Name != "noitu" || mod.Commands[0].Parameters != "" {
|
||||
t.Fatalf("commands = %+v", mod.Commands)
|
||||
}
|
||||
if len(mod.Games) != 1 || mod.Games[0].ShortName != "noitu" {
|
||||
t.Fatalf("games = %+v", mod.Games)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_EnabledFromEnv(t *testing.T) {
|
||||
t.Setenv(baseURLEnv, "https://game.example/")
|
||||
t.Setenv(secretEnv, "")
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
mod := New(modules.Deps{Bot: rb.Bot})
|
||||
if len(mod.HTTP) != 1 || mod.HTTP[0].Pattern != "/games/noitu/" {
|
||||
t.Fatalf("routes = %+v", mod.HTTP)
|
||||
}
|
||||
// Without a bot there is neither a derived key nor a reporter.
|
||||
if mod := New(modules.Deps{}); len(mod.HTTP) != 0 {
|
||||
t.Fatal("enabled without a bot or secret")
|
||||
}
|
||||
}
|
||||
|
||||
// installModule registers mod through the real registry and dispatcher.
|
||||
func installModule(t *testing.T, mod modules.Module) *testutil.RecordingBot {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"noitu"}, map[string]modules.Factory{
|
||||
"noitu": func(modules.Deps) modules.Module { return mod },
|
||||
}, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
return rb
|
||||
}
|
||||
|
||||
func lastAnswer(t *testing.T, rb *testutil.RecordingBot) map[string]string {
|
||||
t.Helper()
|
||||
for _, c := range rb.Sent() {
|
||||
if c.Method == "answerCallbackQuery" {
|
||||
return c.Form
|
||||
}
|
||||
}
|
||||
t.Fatalf("no answerCallbackQuery; sent %+v", rb.Sent())
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPlay_DisabledAnswersAlert(t *testing.T) {
|
||||
rb := installModule(t, newWithConfig(config{now: time.Now}))
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, 42, 7, ShortName))
|
||||
a := lastAnswer(t, rb)
|
||||
if a["text"] != msgDisabled || a["show_alert"] != "true" || a["url"] != "" {
|
||||
t.Fatalf("answer = %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlay_EnabledAnswersSignedURL(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
for name, update := range map[string]*models.Update{
|
||||
"chat": testutil.NewGameCallback(42, -100500, 7, ShortName),
|
||||
"inline": testutil.NewInlineGameCallback(42, "AgAAAInline", ShortName),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rb := installModule(t, h.mod)
|
||||
rb.Bot.ProcessUpdate(context.Background(), update)
|
||||
a := lastAnswer(t, rb)
|
||||
prefix := testBase + "/games/noitu/?t="
|
||||
if !strings.HasPrefix(a["url"], prefix) {
|
||||
t.Fatalf("url = %q", a["url"])
|
||||
}
|
||||
u, _ := url.Parse(a["url"])
|
||||
c, err := verifyToken(testKey, u.Query().Get("t"), h.clock.now())
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
if c.UserID != 42 || c.Name != "Test" || c.Expiry != h.clock.now().Add(tokenTTL).Unix() {
|
||||
t.Fatalf("claims = %+v", c)
|
||||
}
|
||||
if name == "chat" && (c.ChatID != -100500 || c.MessageID != 7 || c.InlineID != "") {
|
||||
t.Fatalf("chat claims = %+v", c)
|
||||
}
|
||||
if name == "inline" && (c.InlineID != "AgAAAInline" || c.ChatID != 0) {
|
||||
t.Fatalf("inline claims = %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlay_InaccessibleMessageAndNoTarget(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
update := testutil.NewGameCallback(42, 0, 0, ShortName)
|
||||
update.CallbackQuery.Message = models.MaybeInaccessibleMessage{
|
||||
Type: models.MaybeInaccessibleMessageTypeInaccessibleMessage,
|
||||
InaccessibleMessage: &models.InaccessibleMessage{Chat: models.Chat{ID: -5}, MessageID: 3},
|
||||
}
|
||||
rb := installModule(t, h.mod)
|
||||
rb.Bot.ProcessUpdate(context.Background(), update)
|
||||
u, _ := url.Parse(lastAnswer(t, rb)["url"])
|
||||
if c, err := verifyToken(testKey, u.Query().Get("t"), h.clock.now()); err != nil || c.ChatID != -5 || c.MessageID != 3 {
|
||||
t.Fatalf("inaccessible claims = %+v, %v", c, err)
|
||||
}
|
||||
|
||||
update.CallbackQuery.Message = models.MaybeInaccessibleMessage{}
|
||||
rb = installModule(t, h.mod)
|
||||
rb.Bot.ProcessUpdate(context.Background(), update)
|
||||
if a := lastAnswer(t, rb); a["text"] != msgNoGameTarget || a["show_alert"] != "true" {
|
||||
t.Fatalf("no target answer = %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_SendsGameKeepingTopic(t *testing.T) {
|
||||
rb := installModule(t, newWithConfig(config{now: time.Now}))
|
||||
update := testutil.NewSupergroupMessage(-100777, 42, "/noitu")
|
||||
update.Message.MessageThreadID = 12
|
||||
rb.Bot.ProcessUpdate(context.Background(), update)
|
||||
last := rb.LastSent()
|
||||
if last.Method != "sendGame" || last.Form["game_short_name"] != "noitu" || last.ChatID() != "-100777" || last.Form["message_thread_id"] != "12" {
|
||||
t.Fatalf("sent %+v", rb.Sent())
|
||||
}
|
||||
if last.Form["reply_markup"] != "" {
|
||||
t.Fatalf("sendGame carries a keyboard: %q", last.Form["reply_markup"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_ChannelAndFailure(t *testing.T) {
|
||||
rb := installModule(t, newWithConfig(config{now: time.Now}))
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-100888, "/noitu"))
|
||||
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgChannel {
|
||||
t.Fatalf("channel: sent %+v", rb.Sent())
|
||||
}
|
||||
|
||||
rb = installModule(t, newWithConfig(config{now: time.Now}))
|
||||
rb.FailMethodCode("sendGame", 400, "Bad Request: GAME_SHORTNAME_INVALID")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/noitu"))
|
||||
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgSendGameFail {
|
||||
t.Fatalf("failure: sent %+v", rb.Sent())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotReporter_ChatMessageAndNotModified(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
r := botReporter{b: rb.Bot}
|
||||
if err := r.Report(context.Background(), claims{UserID: 42, ChatID: -1005, MessageID: 8}, 77); err != nil {
|
||||
t.Fatalf("Report: %v", err)
|
||||
}
|
||||
f := rb.LastSent().Form
|
||||
if rb.LastSent().Method != "setGameScore" || f["user_id"] != "42" || f["score"] != "77" || f["chat_id"] != "-1005" || f["message_id"] != "8" || f["force"] != "" {
|
||||
t.Fatalf("setGameScore form = %v", f)
|
||||
}
|
||||
rb.FailMethodCode("setGameScore", 400, "Bad Request: BOT_SCORE_NOT_MODIFIED")
|
||||
err := r.Report(context.Background(), claims{UserID: 42, ChatID: -1005, MessageID: 8}, 1)
|
||||
if err == nil || !scoreNotModified(err) {
|
||||
t.Fatalf("not-modified err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var inlineScriptRe = regexp.MustCompile(`(?is)<script\b([^>]*)>(.*?)</script>`)
|
||||
|
||||
func TestWebAssets(t *testing.T) {
|
||||
for _, name := range []string{"index.html", "app.css", "app.js"} {
|
||||
if b, err := web.FS.ReadFile(name); err != nil || len(b) == 0 {
|
||||
t.Fatalf("asset %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
index, _ := web.FS.ReadFile("index.html")
|
||||
for _, m := range inlineScriptRe.FindAllStringSubmatch(string(index), -1) {
|
||||
if strings.TrimSpace(m[2]) != "" || !strings.Contains(m[1], "src=") {
|
||||
t.Errorf("inline script breaks the CSP: %q", m[0])
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(index), " style=") || strings.Contains(string(index), "<style") {
|
||||
t.Error("inline style breaks the CSP")
|
||||
}
|
||||
if !strings.Contains(string(index), "CC BY-SA 4.0") || !strings.Contains(string(index), "Wiktionary") {
|
||||
t.Error("dictionary attribution missing from the page footer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package opponent
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// easy plays a uniformly random legal move, without looking at what it hands
|
||||
// the opponent.
|
||||
type easy struct {
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
func (s *easy) Choose(b Board) (string, error) {
|
||||
moves := b.LegalMoves()
|
||||
if len(moves) == 0 {
|
||||
return "", ErrNoMove
|
||||
}
|
||||
return moves[s.rng.IntN(len(moves))], nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package opponent
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Tuning. These are constants rather than structure precisely so the bot can be
|
||||
// made easier or harder without touching the search.
|
||||
const (
|
||||
// hardKillRate is how often Hard takes an available instant win. A bot that
|
||||
// always wins is not a game, so it sometimes plays on instead.
|
||||
hardKillRate = 0.85
|
||||
// searchDepth is plies of lookahead. Directed Edge Geography is
|
||||
// PSPACE-complete, so this is a heuristic cutoff, not a solver.
|
||||
searchDepth = 4
|
||||
// nodeCap bounds the search on hub syllables with a large branching
|
||||
// factor, keeping Choose inside its latency budget.
|
||||
nodeCap = 20000
|
||||
// branchCap limits how many candidates are explored per node. Moves are
|
||||
// ordered tightest-first, so the pruned tail is the least interesting.
|
||||
branchCap = 12
|
||||
|
||||
// A win is the negation of a child's loss, so only the losing terminal
|
||||
// needs a constant. A loss found with more depth still to search is
|
||||
// scored lower than one found later, so a lost position prefers the
|
||||
// slower loss and a won one the faster win.
|
||||
loseScore = -1000.0
|
||||
)
|
||||
|
||||
// hard looks ahead.
|
||||
//
|
||||
// First it checks for an immediate kill: a move that leaves the opponent with
|
||||
// nothing. Failing that it runs a depth-limited negamax over the remaining
|
||||
// edges, valuing a position by how few replies the opponent has. If the search
|
||||
// finds nothing useful it falls back to Medium's heuristic.
|
||||
type hard struct {
|
||||
rng *rand.Rand
|
||||
// lastNodes is how many search nodes the latest Choose visited, so a test
|
||||
// can pin the node cap that bounds a move request's CPU time.
|
||||
lastNodes int
|
||||
}
|
||||
|
||||
func (s *hard) Choose(b Board) (string, error) {
|
||||
s.lastNodes = 0
|
||||
moves := b.LegalMoves()
|
||||
if len(moves) == 0 {
|
||||
return "", ErrNoMove
|
||||
}
|
||||
|
||||
scored := scoreMoves(b, moves)
|
||||
if len(scored) == 0 {
|
||||
return moves[s.rng.IntN(len(moves))], nil
|
||||
}
|
||||
|
||||
// An instant win: the opponent has no reply at all.
|
||||
var kills []string
|
||||
for _, m := range scored {
|
||||
if m.handsOver == 0 {
|
||||
kills = append(kills, m.word)
|
||||
}
|
||||
}
|
||||
if len(kills) > 0 {
|
||||
if s.rng.Float64() < hardKillRate {
|
||||
return kills[s.rng.IntN(len(kills))], nil
|
||||
}
|
||||
// Declined. The killing moves must be taken off the table, not just
|
||||
// skipped here: the search below would otherwise rediscover the same
|
||||
// win and play it anyway, making hardKillRate do nothing at all.
|
||||
var spared []scoredMove
|
||||
for _, m := range scored {
|
||||
if m.handsOver > 0 {
|
||||
spared = append(spared, m)
|
||||
}
|
||||
}
|
||||
if len(spared) > 0 {
|
||||
scored = spared
|
||||
}
|
||||
}
|
||||
|
||||
// Order tightest-first so alpha-beta prunes early.
|
||||
slices.SortStableFunc(scored, func(a, b scoredMove) int { return a.handsOver - b.handsOver })
|
||||
|
||||
st := &search{board: b, used: map[string]bool{}, budget: nodeCap}
|
||||
|
||||
best := ""
|
||||
alpha := math.Inf(-1)
|
||||
for i, m := range scored {
|
||||
if i >= branchCap || st.budget <= 0 {
|
||||
// Out of budget: keep the best move found so far rather than
|
||||
// scoring the rest against an exhausted search.
|
||||
break
|
||||
}
|
||||
last, ok := b.LastSyllable(m.word)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
st.used[m.word] = true
|
||||
// Negamax: the opponent's best outcome, negated, is ours. Alpha carries
|
||||
// across children so later subtrees can be pruned.
|
||||
score := -st.negamax(last, searchDepth-1, math.Inf(-1), -alpha)
|
||||
delete(st.used, m.word)
|
||||
|
||||
if score > alpha {
|
||||
alpha, best = score, m.word
|
||||
}
|
||||
}
|
||||
|
||||
s.lastNodes = nodeCap - st.budget
|
||||
|
||||
if best == "" {
|
||||
// Nothing scored; fall back to the tightest-first heuristic, which is
|
||||
// Medium's judgement.
|
||||
return scored[0].word, nil
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// search carries the mutable state of one lookahead. Moves are made and unmade
|
||||
// on `used` rather than copying the position at every node.
|
||||
type search struct {
|
||||
board Board
|
||||
used map[string]bool
|
||||
budget int
|
||||
}
|
||||
|
||||
// spent reports whether a word is unavailable: already played in the real game,
|
||||
// or played earlier in this search line.
|
||||
func (s *search) spent(word string) bool {
|
||||
return s.used[word] || s.board.Used(word)
|
||||
}
|
||||
|
||||
// negamax scores the position for the player to move at `current`.
|
||||
//
|
||||
// Returns a value from that player's point of view, so the caller negates it.
|
||||
func (s *search) negamax(current string, depth int, alpha, beta float64) float64 {
|
||||
if s.budget <= 0 {
|
||||
// Fail soft: return the bound already established rather than a neutral
|
||||
// 0, which reads as "this line is fine" and can push the root toward a
|
||||
// losing move when the budget bites.
|
||||
return alpha
|
||||
}
|
||||
s.budget--
|
||||
|
||||
var candidates []string
|
||||
for word := range s.board.WordsStartingWith(current) {
|
||||
if !s.spent(word) {
|
||||
candidates = append(candidates, word)
|
||||
}
|
||||
}
|
||||
|
||||
// No reply: the player to move has lost. The remaining depth breaks the
|
||||
// tie between losses, so when every line is lost the search still avoids
|
||||
// the ones that end at once — a human may not find the slower refutation.
|
||||
if len(candidates) == 0 {
|
||||
return loseScore - float64(depth)
|
||||
}
|
||||
if depth <= 0 {
|
||||
// Negamax evaluates from the perspective of the player to move, so
|
||||
// mobility is a positive: more replies available is better for them.
|
||||
// The caller negates this, which is what turns it into "leave the
|
||||
// opponent with as little as possible". Getting this sign backwards
|
||||
// makes the search prefer positions where it is about to be trapped —
|
||||
// measured at a 29% win rate against the one-ply bot.
|
||||
//
|
||||
// Log rather than the raw count so one hub syllable cannot dominate.
|
||||
return math.Log(1 + float64(len(candidates)))
|
||||
}
|
||||
|
||||
// Order tightest-first: explore the moves most likely to be strong.
|
||||
type cand struct {
|
||||
word string
|
||||
last string
|
||||
handsOver int
|
||||
}
|
||||
ordered := make([]cand, 0, len(candidates))
|
||||
for _, w := range candidates {
|
||||
last, ok := s.board.LastSyllable(w)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for reply := range s.board.WordsStartingWith(last) {
|
||||
if reply != w && !s.spent(reply) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
ordered = append(ordered, cand{word: w, last: last, handsOver: n})
|
||||
}
|
||||
slices.SortStableFunc(ordered, func(a, b cand) int { return a.handsOver - b.handsOver })
|
||||
|
||||
// Starts at the losing score: if every candidate is pruned or unresolvable,
|
||||
// this position is no better than lost, which is the honest floor.
|
||||
best := loseScore
|
||||
for i, c := range ordered {
|
||||
if i >= branchCap {
|
||||
break
|
||||
}
|
||||
|
||||
s.used[c.word] = true
|
||||
score := -s.negamax(c.last, depth-1, -beta, -alpha)
|
||||
delete(s.used, c.word)
|
||||
|
||||
if score > best {
|
||||
best = score
|
||||
}
|
||||
if best > alpha {
|
||||
alpha = best
|
||||
}
|
||||
if alpha >= beta {
|
||||
break // the opponent would avoid this line
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package opponent
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// medium plays greedily one move deep.
|
||||
//
|
||||
// It scores each legal move by how many continuations remain from the syllable
|
||||
// it hands over, takes an immediate win when it sees one, and otherwise picks
|
||||
// at random from the tightest quartile. Random rather than strictly best, so it
|
||||
// does not play the same game every time.
|
||||
//
|
||||
// What separates it from hard is lookahead, not ruthlessness: medium sees only
|
||||
// the move in front of it, while hard searches several plies. An earlier
|
||||
// version withheld the winning move to feel gentler, and simulation showed it
|
||||
// losing to the random bot 97% of the time — a strategy that declines to win
|
||||
// loses to a coin flip. Difficulty has to come from how well a bot plays, not
|
||||
// from it refusing to.
|
||||
type medium struct {
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
type scoredMove struct {
|
||||
word string
|
||||
handsOver int // continuations left for the opponent
|
||||
}
|
||||
|
||||
func (s *medium) Choose(b Board) (string, error) {
|
||||
moves := b.LegalMoves()
|
||||
if len(moves) == 0 {
|
||||
return "", ErrNoMove
|
||||
}
|
||||
|
||||
scored := scoreMoves(b, moves)
|
||||
if len(scored) == 0 {
|
||||
return moves[s.rng.IntN(len(moves))], nil
|
||||
}
|
||||
|
||||
slices.SortStableFunc(scored, func(a, b scoredMove) int {
|
||||
return a.handsOver - b.handsOver
|
||||
})
|
||||
|
||||
// A move that leaves the opponent nothing wins immediately. Sorting puts
|
||||
// those first, so this is simply taking the best available move.
|
||||
if scored[0].handsOver == 0 {
|
||||
return scored[0].word, nil
|
||||
}
|
||||
|
||||
// Otherwise pick at random from the tightest quartile.
|
||||
cut := max(len(scored)/4, 1)
|
||||
return scored[s.rng.IntN(cut)].word, nil
|
||||
}
|
||||
|
||||
// scoreMoves annotates each candidate with how many replies it leaves. The
|
||||
// move itself is counted as spent, since the opponent cannot replay it.
|
||||
func scoreMoves(b Board, moves []string) []scoredMove {
|
||||
scored := make([]scoredMove, 0, len(moves))
|
||||
for _, word := range moves {
|
||||
last, ok := b.LastSyllable(word)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
scored = append(scored, scoredMove{
|
||||
word: word,
|
||||
handsOver: remainingOutDegree(b, last, word),
|
||||
})
|
||||
}
|
||||
return scored
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package opponent chooses the bot's moves.
|
||||
//
|
||||
// Ported from tiennm99/noitu server/internal/bot. The strategies only read the
|
||||
// position through Board; the chosen word still goes through the engine's
|
||||
// validation like a human's. The source's "thinking delay" is dropped: the
|
||||
// bot answers inside the move request and the page animates the pause.
|
||||
package opponent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"iter"
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
// Difficulty selects a strategy.
|
||||
type Difficulty string
|
||||
|
||||
// Difficulties, in the wire form the game page sends.
|
||||
const (
|
||||
Easy Difficulty = "easy"
|
||||
Medium Difficulty = "medium"
|
||||
Hard Difficulty = "hard"
|
||||
)
|
||||
|
||||
// ParseDifficulty maps the wire value to a Difficulty. Empty means Medium.
|
||||
func ParseDifficulty(s string) (Difficulty, bool) {
|
||||
switch Difficulty(s) {
|
||||
case "", Medium:
|
||||
return Medium, true
|
||||
case Easy:
|
||||
return Easy, true
|
||||
case Hard:
|
||||
return Hard, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ErrNoMove means the bot has nothing legal to play, so it has lost.
|
||||
var ErrNoMove = errors.New("opponent: no legal move")
|
||||
|
||||
// Board is what a strategy may look at.
|
||||
type Board interface {
|
||||
LegalMoves() []string
|
||||
Used(word string) bool
|
||||
WordsStartingWith(syllable string) iter.Seq[string]
|
||||
LastSyllable(word string) (string, bool)
|
||||
}
|
||||
|
||||
// Strategy picks a move for the position.
|
||||
type Strategy interface {
|
||||
Choose(b Board) (string, error)
|
||||
}
|
||||
|
||||
// New returns the strategy for a difficulty, drawing randomness from rng.
|
||||
// The rng is per game, never shared, and not safe for concurrent use.
|
||||
func New(d Difficulty, rng *rand.Rand) (Strategy, error) {
|
||||
if rng == nil {
|
||||
return nil, errors.New("opponent: nil rng")
|
||||
}
|
||||
switch d {
|
||||
case Easy:
|
||||
return &easy{rng: rng}, nil
|
||||
case Medium:
|
||||
return &medium{rng: rng}, nil
|
||||
case Hard:
|
||||
return &hard{rng: rng}, nil
|
||||
}
|
||||
return nil, errors.New("opponent: unknown difficulty")
|
||||
}
|
||||
|
||||
// remainingOutDegree counts the unplayed continuations from syllable,
|
||||
// ignoring excluding (the move being considered).
|
||||
func remainingOutDegree(b Board, syllable, excluding string) int {
|
||||
n := 0
|
||||
for word := range b.WordsStartingWith(syllable) {
|
||||
if word != excluding && !b.Used(word) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package opponent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"iter"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func realGame(t *testing.T, seed uint64) *engine.Engine {
|
||||
t.Helper()
|
||||
store, err := dict.Default()
|
||||
if err != nil {
|
||||
t.Fatalf("dict: %v", err)
|
||||
}
|
||||
opening := store.RandomOpening(rand.New(rand.NewPCG(seed, seed)), 20)
|
||||
e, err := engine.New(store, opening, 30*time.Second, 2*time.Second, t0)
|
||||
if err != nil {
|
||||
t.Fatalf("engine: %v", err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestParseDifficulty(t *testing.T) {
|
||||
for in, want := range map[string]Difficulty{"": Medium, "easy": Easy, "medium": Medium, "hard": Hard} {
|
||||
if got, ok := ParseDifficulty(in); !ok || got != want {
|
||||
t.Errorf("ParseDifficulty(%q) = %q %v", in, got, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := ParseDifficulty("insane"); ok {
|
||||
t.Error("unknown difficulty accepted")
|
||||
}
|
||||
if _, err := New(Easy, nil); err == nil {
|
||||
t.Error("nil rng accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Every strategy plays legal moves for both sides until the game ends, and
|
||||
// each choice passes the engine's own validation.
|
||||
func TestStrategiesPlayLegalMoves(t *testing.T) {
|
||||
for _, d := range []Difficulty{Easy, Medium, Hard} {
|
||||
t.Run(string(d), func(t *testing.T) {
|
||||
e := realGame(t, 7)
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
strategy, _ := New(d, rng)
|
||||
side := engine.Human
|
||||
for range 40 {
|
||||
word, err := strategy.Choose(e)
|
||||
if errors.Is(err, ErrNoMove) {
|
||||
if e.HasLegalMove() {
|
||||
t.Fatal("ErrNoMove while a legal move exists")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !slices.Contains(e.LegalMoves(), word) {
|
||||
t.Fatalf("%q is not a legal move", word)
|
||||
}
|
||||
if _, r := e.Submit(side, word, t0); r != engine.ReasonNone {
|
||||
t.Fatalf("engine rejected %q: %q", word, r)
|
||||
}
|
||||
if side == engine.Human {
|
||||
side = engine.Bot
|
||||
} else {
|
||||
side = engine.Human
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// deadBoard has no legal move at all.
|
||||
type deadBoard struct{}
|
||||
|
||||
func (deadBoard) LegalMoves() []string { return nil }
|
||||
func (deadBoard) Used(string) bool { return false }
|
||||
func (deadBoard) WordsStartingWith(string) iter.Seq[string] {
|
||||
return func(func(string) bool) {}
|
||||
}
|
||||
func (deadBoard) LastSyllable(string) (string, bool) { return "", false }
|
||||
|
||||
func TestStrategiesReportNoMoveOnDeadEnd(t *testing.T) {
|
||||
for _, d := range []Difficulty{Easy, Medium, Hard} {
|
||||
strategy, _ := New(d, rand.New(rand.NewPCG(1, 1)))
|
||||
if _, err := strategy.Choose(deadBoard{}); !errors.Is(err, ErrNoMove) {
|
||||
t.Errorf("%s: err = %v, want ErrNoMove", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediumTakesImmediateWin(t *testing.T) {
|
||||
store, err := dict.Parse("hoa hồng\nhồng hào\nhồng tâm\ntâm sự\ntâm hồn\nhồn nhiên\nhồn ma\nhào hoa\nhoa lá\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// After "hồng tâm" the bot picks between "tâm hồn" (two replies) and "tâm sự"
|
||||
// (none on "sự"): the winning move must be taken.
|
||||
e, err := engine.New(store, "hoa hồng", time.Minute, 0, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.Submit(engine.Human, "hồng tâm", t0)
|
||||
strategy, _ := New(Medium, rand.New(rand.NewPCG(3, 3)))
|
||||
if w, err := strategy.Choose(e); err != nil || w != "tâm sự" {
|
||||
t.Fatalf("medium chose %q %v, want the winning tâm sự", w, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardRespectsNodeCap(t *testing.T) {
|
||||
maxSeen := 0
|
||||
for seed := range uint64(12) {
|
||||
e := realGame(t, seed)
|
||||
h := &hard{rng: rand.New(rand.NewPCG(seed, 99))}
|
||||
if _, err := h.Choose(e); err != nil {
|
||||
t.Fatalf("seed %d: %v", seed, err)
|
||||
}
|
||||
if h.lastNodes > nodeCap {
|
||||
t.Fatalf("seed %d: searched %d nodes, cap %d", seed, h.lastNodes, nodeCap)
|
||||
}
|
||||
maxSeen = max(maxSeen, h.lastNodes)
|
||||
}
|
||||
if maxSeen == 0 {
|
||||
t.Fatal("hard never searched; the node counter is not wired")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/telegram"
|
||||
)
|
||||
|
||||
// scoreReporter records a finished game's score on the game message.
|
||||
type scoreReporter interface {
|
||||
Report(ctx context.Context, c claims, score int) error
|
||||
}
|
||||
|
||||
// botReporter reports through the Bot API. A chat message goes through the
|
||||
// library; an inline message needs the raw call, because the library types
|
||||
// inline_message_id as an int and cannot decode Telegram's `true` result.
|
||||
type botReporter struct {
|
||||
b *bot.Bot
|
||||
}
|
||||
|
||||
func (r botReporter) Report(ctx context.Context, c claims, score int) error {
|
||||
if c.InlineID != "" {
|
||||
return telegram.SetInlineGameScore(ctx, r.b.Token(), c.InlineID, c.UserID, score)
|
||||
}
|
||||
_, err := r.b.SetGameScore(ctx, &bot.SetGameScoreParams{
|
||||
UserID: c.UserID,
|
||||
Score: score,
|
||||
ChatID: c.ChatID,
|
||||
MessageID: c.MessageID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// scoreNotModified reports Telegram's answer to a score that does not beat the
|
||||
// player's best. With force=false that is the expected outcome, not a failure.
|
||||
func scoreNotModified(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "BOT_SCORE_NOT_MODIFIED")
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/opponent"
|
||||
)
|
||||
|
||||
const (
|
||||
turnLimit = 30 * time.Second
|
||||
turnGrace = 2 * time.Second
|
||||
minOpeningOut = 20
|
||||
maxSessions = 2000
|
||||
maxUserSessions = 3
|
||||
idleTTL = 15 * time.Minute
|
||||
finishedTTL = 2 * time.Minute
|
||||
startsPerMinute = 10
|
||||
moveInterval = 300 * time.Millisecond
|
||||
maxChain = 300
|
||||
maxSuggestions = 3
|
||||
reportTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Score report states shown on the page.
|
||||
const (
|
||||
reportNone = ""
|
||||
reportPending = "pending"
|
||||
reportOK = "ok"
|
||||
reportFailed = "failed"
|
||||
reportSkipped = "skipped"
|
||||
)
|
||||
|
||||
var errBusy = errors.New("noitu: too many sessions")
|
||||
|
||||
// chainEntry is one word of the chain as the page shows it.
|
||||
type chainEntry struct {
|
||||
Word string `json:"word"`
|
||||
By string `json:"by"` // "bot" or "player"
|
||||
Points int `json:"points"`
|
||||
Meanings []dict.Sense `json:"meanings"`
|
||||
}
|
||||
|
||||
// session is one live game. Its mutex guards every field below it; the
|
||||
// store's mutex is never taken while holding it.
|
||||
type session struct {
|
||||
id string
|
||||
claims claims
|
||||
difficulty opponent.Difficulty
|
||||
|
||||
mu sync.Mutex
|
||||
eng *engine.Engine
|
||||
strategy opponent.Strategy
|
||||
chain []chainEntry
|
||||
suggestions []string
|
||||
lastMove time.Time
|
||||
lastSeen time.Time
|
||||
endedAt time.Time
|
||||
report string
|
||||
}
|
||||
|
||||
// expired reports whether the session can be dropped: idle too long, or
|
||||
// finished long enough ago that its result screen has been read.
|
||||
func (s *session) expired(now time.Time) bool {
|
||||
if s.eng.Over() {
|
||||
return now.Sub(s.endedAt) > finishedTTL
|
||||
}
|
||||
return now.Sub(s.lastSeen) > idleTTL
|
||||
}
|
||||
|
||||
// sessionStore indexes live sessions by id, by owner (player + game
|
||||
// message) and by user, and counts recent starts per user.
|
||||
type sessionStore struct {
|
||||
mu sync.Mutex
|
||||
byID map[string]*session
|
||||
byOwner map[string]*session
|
||||
byUser map[int64][]*session
|
||||
starts map[int64][]time.Time
|
||||
}
|
||||
|
||||
func newSessionStore() *sessionStore {
|
||||
return &sessionStore{
|
||||
byID: map[string]*session{},
|
||||
byOwner: map[string]*session{},
|
||||
byUser: map[int64][]*session{},
|
||||
starts: map[int64][]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
func (st *sessionStore) get(id string) *session {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
return st.byID[id]
|
||||
}
|
||||
|
||||
// allowStart records a start for user and reports whether it is within
|
||||
// startsPerMinute.
|
||||
func (st *sessionStore) allowStart(user int64, now time.Time) bool {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
recent := st.starts[user][:0]
|
||||
for _, t := range st.starts[user] {
|
||||
if now.Sub(t) < time.Minute {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if len(recent) >= startsPerMinute {
|
||||
st.starts[user] = recent
|
||||
return false
|
||||
}
|
||||
st.starts[user] = append(recent, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// add registers s and returns the session it displaced, if any: the owner's
|
||||
// previous session on the same game message, or else the user's oldest
|
||||
// session once they hold maxUserSessions. The caller must settle the
|
||||
// displaced session (see service.settle) so a game left mid-play still gets
|
||||
// its score reported. Nothing changes when add fails.
|
||||
func (st *sessionStore) add(s *session) (*session, error) {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
user := s.claims.UserID
|
||||
displaced := st.byOwner[s.claims.ownerKey()]
|
||||
if displaced == nil && len(st.byUser[user]) >= maxUserSessions {
|
||||
displaced = st.byUser[user][0] // kept oldest first
|
||||
}
|
||||
if displaced == nil && len(st.byID) >= maxSessions {
|
||||
return nil, errBusy
|
||||
}
|
||||
if displaced != nil {
|
||||
st.removeLocked(displaced)
|
||||
}
|
||||
st.byID[s.id] = s
|
||||
st.byOwner[s.claims.ownerKey()] = s
|
||||
st.byUser[user] = append(st.byUser[user], s)
|
||||
return displaced, nil
|
||||
}
|
||||
|
||||
func (st *sessionStore) remove(s *session) {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
st.removeLocked(s)
|
||||
}
|
||||
|
||||
func (st *sessionStore) removeLocked(s *session) {
|
||||
if st.byID[s.id] == s {
|
||||
delete(st.byID, s.id)
|
||||
}
|
||||
if key := s.claims.ownerKey(); st.byOwner[key] == s {
|
||||
delete(st.byOwner, key)
|
||||
}
|
||||
user := s.claims.UserID
|
||||
if live := slices.DeleteFunc(st.byUser[user], func(o *session) bool { return o == s }); len(live) > 0 {
|
||||
st.byUser[user] = live
|
||||
} else {
|
||||
delete(st.byUser, user)
|
||||
}
|
||||
}
|
||||
|
||||
// all snapshots the live sessions and drops start records older than a
|
||||
// minute.
|
||||
func (st *sessionStore) all(now time.Time) []*session {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
for user, ts := range st.starts {
|
||||
if len(ts) == 0 || now.Sub(ts[len(ts)-1]) >= time.Minute {
|
||||
delete(st.starts, user)
|
||||
}
|
||||
}
|
||||
out := make([]*session, 0, len(st.byID))
|
||||
for _, s := range st.byID {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newSessionID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// service is the module's runtime: config, the word store and the sessions.
|
||||
type service struct {
|
||||
cfg config
|
||||
words *dict.Store // nil when the game is disabled
|
||||
sessions *sessionStore
|
||||
}
|
||||
|
||||
func (s *service) enabled() bool { return s.words != nil }
|
||||
|
||||
// sweep settles turns that ran out with nobody calling the API (so their
|
||||
// scores are still reported) and drops expired sessions.
|
||||
func (s *service) sweep(now time.Time) {
|
||||
for _, sess := range s.sessions.all(now) {
|
||||
sess.mu.Lock()
|
||||
if sess.eng.Timeout(now) {
|
||||
s.finish(sess, now)
|
||||
}
|
||||
drop := sess.expired(now)
|
||||
sess.mu.Unlock()
|
||||
if drop {
|
||||
s.sessions.remove(sess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) sweepCron(context.Context, modules.Deps) error {
|
||||
s.sweep(s.cfg.now())
|
||||
return nil
|
||||
}
|
||||
|
||||
// settle ends a game the player walked away from and reports its score, as
|
||||
// the sweep would on timeout. A dead-end position ends as no legal move
|
||||
// rather than a resignation. Caller must not hold sess.mu.
|
||||
func (s *service) settle(sess *session, now time.Time) {
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
if s.resign(sess) {
|
||||
s.finish(sess, now)
|
||||
}
|
||||
}
|
||||
|
||||
// resign ends the human's game: as no legal move when the position is a dead
|
||||
// end, otherwise as giving up. Reports whether it ended the game. Caller
|
||||
// holds sess.mu.
|
||||
func (s *service) resign(sess *session) bool {
|
||||
return sess.eng.NoMove() || sess.eng.GiveUp()
|
||||
}
|
||||
|
||||
// finish records the end of a game and reports a positive score once.
|
||||
// Caller holds sess.mu.
|
||||
func (s *service) finish(sess *session, now time.Time) {
|
||||
sess.endedAt = now
|
||||
if sess.eng.Winner() == engine.Bot {
|
||||
sess.suggestions = sess.eng.Suggestions(maxSuggestions)
|
||||
}
|
||||
score := sess.eng.Score()
|
||||
if score <= 0 || s.cfg.reporter == nil {
|
||||
sess.report = reportSkipped
|
||||
return
|
||||
}
|
||||
sess.report = reportPending
|
||||
go s.report(sess, score) //nolint:gosec // G118: the report must outlive the request that ended the game
|
||||
}
|
||||
|
||||
func (s *service) report(sess *session, score int) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
|
||||
defer cancel()
|
||||
err := s.cfg.reporter.Report(ctx, sess.claims, score)
|
||||
status := reportOK
|
||||
if err != nil && !scoreNotModified(err) {
|
||||
status = reportFailed
|
||||
log.Warn("noitu setGameScore failed", "err", err)
|
||||
}
|
||||
sess.mu.Lock()
|
||||
sess.report = status
|
||||
sess.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// tokenTTL bounds how long a Play link stays usable.
|
||||
const tokenTTL = 6 * time.Hour
|
||||
|
||||
// maxNameRunes caps the first name carried in a token.
|
||||
const maxNameRunes = 64
|
||||
|
||||
var (
|
||||
errBadToken = errors.New("noitu: bad token")
|
||||
errTokenExpired = errors.New("noitu: token expired")
|
||||
)
|
||||
|
||||
// claims is what a game token asserts: who pressed Play, on which game
|
||||
// message, until when. Exactly one address is set: ChatID+MessageID for a
|
||||
// message the bot sent, or InlineID for one sent via the bot.
|
||||
type claims struct {
|
||||
UserID int64 `json:"u"`
|
||||
Name string `json:"n,omitempty"`
|
||||
ChatID int64 `json:"c,omitempty"`
|
||||
MessageID int `json:"m,omitempty"`
|
||||
InlineID string `json:"i,omitempty"`
|
||||
Expiry int64 `json:"e"`
|
||||
}
|
||||
|
||||
func (c claims) valid() bool {
|
||||
if c.UserID <= 0 || c.Expiry <= 0 {
|
||||
return false
|
||||
}
|
||||
chat := c.ChatID != 0 && c.MessageID != 0
|
||||
inline := c.InlineID != ""
|
||||
return chat != inline
|
||||
}
|
||||
|
||||
// ownerKey identifies one player on one game message; a new start replaces
|
||||
// the live session with the same key.
|
||||
func (c claims) ownerKey() string {
|
||||
if c.InlineID != "" {
|
||||
return strconv.FormatInt(c.UserID, 10) + "|i|" + c.InlineID
|
||||
}
|
||||
return strconv.FormatInt(c.UserID, 10) + "|c|" + strconv.FormatInt(c.ChatID, 10) + "|" + strconv.Itoa(c.MessageID)
|
||||
}
|
||||
|
||||
// truncateName keeps a first name within maxNameRunes.
|
||||
func truncateName(name string) string {
|
||||
if utf8.RuneCountInString(name) <= maxNameRunes {
|
||||
return name
|
||||
}
|
||||
return string([]rune(name)[:maxNameRunes])
|
||||
}
|
||||
|
||||
// signToken encodes claims as base64url(json) "." base64url(HMAC-SHA256).
|
||||
func signToken(key []byte, c claims) (string, error) {
|
||||
payload, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return body + "." + base64.RawURLEncoding.EncodeToString(tokenMAC(key, body)), nil
|
||||
}
|
||||
|
||||
func tokenMAC(key []byte, body string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(body))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
// verifyToken checks the signature first, then the claims and the expiry.
|
||||
func verifyToken(key []byte, token string, now time.Time) (claims, error) {
|
||||
if len(key) == 0 || len(token) > 1024 {
|
||||
return claims{}, errBadToken
|
||||
}
|
||||
body, sig, ok := strings.Cut(token, ".")
|
||||
if !ok {
|
||||
return claims{}, errBadToken
|
||||
}
|
||||
gotMAC, err := base64.RawURLEncoding.DecodeString(sig)
|
||||
if err != nil || !hmac.Equal(gotMAC, tokenMAC(key, body)) {
|
||||
return claims{}, errBadToken
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(body)
|
||||
if err != nil {
|
||||
return claims{}, errBadToken
|
||||
}
|
||||
var c claims
|
||||
if err := json.Unmarshal(payload, &c); err != nil || !c.valid() {
|
||||
return claims{}, errBadToken
|
||||
}
|
||||
if now.Unix() >= c.Expiry {
|
||||
return claims{}, errTokenExpired
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--text: #1b1f24;
|
||||
--muted: #5f6b7a;
|
||||
--border: #d9dee5;
|
||||
--accent: #2481cc;
|
||||
--accent-text: #ffffff;
|
||||
--bot: #eef1f5;
|
||||
--player: #e3f0fb;
|
||||
--error: #c0392b;
|
||||
--ok: #1e8e3e;
|
||||
--warn: #d68910;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #17212b;
|
||||
--surface: #1f2b38;
|
||||
--text: #e9eef3;
|
||||
--muted: #93a1b0;
|
||||
--border: #2f3d4c;
|
||||
--accent: #5aa7e6;
|
||||
--accent-text: #0e1621;
|
||||
--bot: #263342;
|
||||
--player: #1d3b55;
|
||||
--error: #ff7b6b;
|
||||
--ok: #6fcf87;
|
||||
--warn: #f5b041;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px calc(16px + env(safe-area-inset-bottom));
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
h1 { font-size: 1.5rem; margin: 0; }
|
||||
h2 { font-size: 1.2rem; margin: 0 0 8px; }
|
||||
.player-name { margin: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.lead { margin-top: 0; font-size: 1.05rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.rules { padding-left: 20px; color: var(--muted); }
|
||||
.error { color: var(--error); min-height: 1.4em; margin: 8px 0 0; }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 10px 16px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
|
||||
button.ghost { background: transparent; color: var(--muted); align-self: center; }
|
||||
|
||||
.difficulty { border: 1px solid var(--border); border-radius: 10px; margin: 0 0 16px; padding: 8px 12px 12px; }
|
||||
.difficulty legend { padding: 0 4px; color: var(--muted); }
|
||||
.difficulty label { display: inline-flex; align-items: center; gap: 6px; margin-right: 16px; min-height: 36px; }
|
||||
|
||||
.scoreline { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; }
|
||||
.timer-text { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.timer-text.low { color: var(--error); }
|
||||
|
||||
.timer-track { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
|
||||
.timer-bar { height: 100%; width: 100%; background: var(--accent); transform-origin: left center; }
|
||||
.timer-bar.low { background: var(--error); }
|
||||
|
||||
.move-form { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
|
||||
.move-form label { color: var(--muted); }
|
||||
.move-form label strong { color: var(--text); font-size: 1.15rem; }
|
||||
.move-row { display: flex; gap: 8px; }
|
||||
.move-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 1.1rem;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.move-row input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.message { min-height: 1.4em; margin: 0; }
|
||||
.message.error { color: var(--error); }
|
||||
.message.thinking { color: var(--muted); }
|
||||
.message.thinking::after { content: "…"; animation: blink 1s steps(2) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.chain-box { display: flex; flex-direction: column; }
|
||||
.chain-title { font-size: 1rem; color: var(--muted); font-weight: 600; }
|
||||
.chain { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column-reverse; gap: 6px; }
|
||||
.chain > li { border-radius: 10px; padding: 8px 12px; background: var(--bot); }
|
||||
.chain > li.player { background: var(--player); }
|
||||
.chain .row { display: flex; align-items: center; gap: 8px; }
|
||||
.chain .who { font-size: .8rem; color: var(--muted); min-width: 34px; }
|
||||
.chain .word { font-weight: 600; flex: 1; overflow-wrap: anywhere; }
|
||||
.chain .points { font-size: .85rem; color: var(--ok); }
|
||||
.chain .toggle { padding: 2px 8px; min-height: 30px; font-size: .8rem; }
|
||||
.chain .no-meaning { font-size: .8rem; color: var(--muted); }
|
||||
.chain ol.senses { margin: 6px 0 0; padding-left: 22px; font-size: .9rem; color: var(--muted); }
|
||||
.chain > li.fresh { animation: pop .35s ease-out; }
|
||||
@keyframes pop { from { transform: scale(.97); opacity: .4; } to { transform: none; opacity: 1; } }
|
||||
|
||||
.final-score { font-size: 1.2rem; }
|
||||
.suggestions { padding-left: 20px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.report-ok { color: var(--ok); }
|
||||
.report-failed { color: var(--warn); }
|
||||
|
||||
.footer { margin-top: auto; padding-top: 8px; font-size: .8rem; color: var(--muted); text-align: center; }
|
||||
.footer a { color: inherit; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chain > li.fresh, .message.thinking::after { animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Nối từ game page. Plain JavaScript, no build step. The server owns the game:
|
||||
// this page only sends words and renders the state it gets back.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @typedef {{pos: string, gloss: string}} Sense
|
||||
* @typedef {{word: string, by: 'bot'|'player', points: number, meanings: Sense[]}} ChainEntry
|
||||
* @typedef {{
|
||||
* session: string, player: string, difficulty: string,
|
||||
* status: 'playing'|'won'|'lost', end_reason: string, current: string,
|
||||
* chain: ChainEntry[], score: number, turn_limit_ms: number,
|
||||
* deadline_ms: number, server_now_ms: number, suggestions: string[],
|
||||
* score_reported: string
|
||||
* }} SessionView
|
||||
* @typedef {{accepted: boolean, reason: string, message: string, player_word: string, bot_word: string}} MoveResult
|
||||
*/
|
||||
|
||||
/** Grace the server allows after the deadline, plus a little slack. */
|
||||
var GRACE_MS = 2300;
|
||||
/** Shortest "bot đang nghĩ" pause, so the reply does not feel instant. */
|
||||
var THINK_MS = 350;
|
||||
var REPORT_POLL_MS = 1500;
|
||||
var REPORT_POLLS = 10;
|
||||
|
||||
/** @param {string} id @returns {HTMLElement} */
|
||||
function $(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) throw new Error('missing #' + id);
|
||||
return el;
|
||||
}
|
||||
|
||||
var screens = {
|
||||
missing: $('screen-missing'),
|
||||
start: $('screen-start'),
|
||||
game: $('screen-game'),
|
||||
end: $('screen-end')
|
||||
};
|
||||
var chainBox = $('chain-box');
|
||||
var chainList = $('chain');
|
||||
var wordInput = /** @type {HTMLInputElement} */ ($('word'));
|
||||
var sendBtn = /** @type {HTMLButtonElement} */ ($('send-btn'));
|
||||
var startBtn = /** @type {HTMLButtonElement} */ ($('start-btn'));
|
||||
var giveUpBtn = /** @type {HTMLButtonElement} */ ($('give-up'));
|
||||
var message = $('message');
|
||||
var timerBar = $('timer-bar');
|
||||
var timerText = $('timer-text');
|
||||
|
||||
var TOKEN_KEY = 'noitu-token';
|
||||
var token = readToken();
|
||||
|
||||
/**
|
||||
* Takes the signed token from the URL, then strips it from the address bar
|
||||
* so copying, sharing or opening the page elsewhere does not leak it. The
|
||||
* hash stays: Telegram passes its own parameters there. sessionStorage keeps
|
||||
* the token for a reload; the page still works when storage is blocked.
|
||||
* @returns {string}
|
||||
*/
|
||||
function readToken() {
|
||||
var fromURL = new URLSearchParams(window.location.search).get('t') || '';
|
||||
if (fromURL) {
|
||||
try { window.sessionStorage.setItem(TOKEN_KEY, fromURL); } catch (e) { /* storage blocked */ }
|
||||
try {
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
|
||||
} catch (e) { /* history API unavailable */ }
|
||||
return fromURL;
|
||||
}
|
||||
try { return window.sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/** @type {SessionView|null} */
|
||||
var view = null;
|
||||
/** Local wall-clock time at which the current turn ends. */
|
||||
var deadlineLocal = 0;
|
||||
var timerHandle = 0;
|
||||
var expiryHandle = 0;
|
||||
var reportPolls = 0;
|
||||
var busy = false;
|
||||
var giveUpArmed = 0;
|
||||
/** Words whose meanings are expanded, so a re-render keeps them open. */
|
||||
var expanded = Object.create(null);
|
||||
|
||||
/** @param {keyof typeof screens} name */
|
||||
function show(name) {
|
||||
Object.keys(screens).forEach(function (key) {
|
||||
screens[/** @type {keyof typeof screens} */ (key)].hidden = key !== name;
|
||||
});
|
||||
chainBox.hidden = !(name === 'game' || name === 'end');
|
||||
}
|
||||
|
||||
/** @param {number} ms @returns {Promise<void>} */
|
||||
function delay(ms) {
|
||||
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
/**
|
||||
* POSTs a JSON body to the game API. Rejects with {code, message} on a
|
||||
* non-2xx answer or a network failure.
|
||||
* @param {string} path @param {object} body @returns {Promise<any>}
|
||||
*/
|
||||
function api(path, body) {
|
||||
return fetch('api/' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'omit',
|
||||
cache: 'no-store'
|
||||
}).then(function (res) {
|
||||
return res.json().catch(function () { return {}; }).then(function (data) {
|
||||
if (!res.ok) {
|
||||
throw { code: data.error || 'http_' + res.status, message: data.message || 'Có lỗi xảy ra, thử lại sau.' };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}, function () {
|
||||
throw { code: 'network', message: 'Mất kết nối tới máy chủ. Kiểm tra mạng rồi thử lại.' };
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {string} text @param {string} [kind] */
|
||||
function say(text, kind) {
|
||||
message.textContent = text;
|
||||
message.className = 'message' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
/** @param {ChainEntry} entry @param {boolean} fresh @returns {HTMLLIElement} */
|
||||
function chainItem(entry, fresh) {
|
||||
var li = document.createElement('li');
|
||||
li.className = entry.by + (fresh ? ' fresh' : '');
|
||||
|
||||
var row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
var who = document.createElement('span');
|
||||
who.className = 'who';
|
||||
who.textContent = entry.by === 'bot' ? 'Bot' : 'Bạn';
|
||||
var word = document.createElement('span');
|
||||
word.className = 'word';
|
||||
word.textContent = entry.word;
|
||||
row.appendChild(who);
|
||||
row.appendChild(word);
|
||||
if (entry.by === 'player' && entry.points > 0) {
|
||||
var pts = document.createElement('span');
|
||||
pts.className = 'points';
|
||||
pts.textContent = '+' + entry.points;
|
||||
row.appendChild(pts);
|
||||
}
|
||||
li.appendChild(row);
|
||||
|
||||
if (!entry.meanings || entry.meanings.length === 0) {
|
||||
var none = document.createElement('span');
|
||||
none.className = 'no-meaning';
|
||||
none.textContent = 'Chưa có nghĩa';
|
||||
row.appendChild(none);
|
||||
return li;
|
||||
}
|
||||
|
||||
var senses = document.createElement('ol');
|
||||
senses.className = 'senses';
|
||||
entry.meanings.forEach(function (sense) {
|
||||
var item = document.createElement('li');
|
||||
item.textContent = (sense.pos ? '(' + sense.pos + ') ' : '') + sense.gloss;
|
||||
senses.appendChild(item);
|
||||
});
|
||||
var toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'toggle';
|
||||
function sync() {
|
||||
var open = !!expanded[entry.word];
|
||||
senses.hidden = !open;
|
||||
toggle.textContent = open ? 'Ẩn nghĩa' : 'Xem nghĩa';
|
||||
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
toggle.setAttribute('aria-label', (open ? 'Ẩn nghĩa của ' : 'Xem nghĩa của ') + entry.word);
|
||||
}
|
||||
toggle.addEventListener('click', function () {
|
||||
expanded[entry.word] = !expanded[entry.word];
|
||||
sync();
|
||||
});
|
||||
sync();
|
||||
row.appendChild(toggle);
|
||||
li.appendChild(senses);
|
||||
return li;
|
||||
}
|
||||
|
||||
/** @param {ChainEntry[]} chain */
|
||||
function renderChain(chain) {
|
||||
var before = chainList.children.length;
|
||||
if (chain.length < before) {
|
||||
chainList.textContent = '';
|
||||
before = 0;
|
||||
}
|
||||
for (var i = before; i < chain.length; i++) {
|
||||
chainList.appendChild(chainItem(chain[i], before > 0));
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
clearInterval(timerHandle);
|
||||
clearTimeout(expiryHandle);
|
||||
timerHandle = 0;
|
||||
expiryHandle = 0;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!view) return;
|
||||
var left = Math.max(0, deadlineLocal - Date.now());
|
||||
var ratio = view.turn_limit_ms > 0 ? left / view.turn_limit_ms : 0;
|
||||
timerBar.style.transform = 'scaleX(' + Math.min(1, ratio).toFixed(3) + ')';
|
||||
var low = left <= 5000;
|
||||
timerBar.classList.toggle('low', low);
|
||||
timerText.classList.toggle('low', low);
|
||||
timerText.textContent = String(Math.ceil(left / 1000));
|
||||
if (left <= 0 && timerHandle) {
|
||||
clearInterval(timerHandle);
|
||||
timerHandle = 0;
|
||||
say('Hết giờ…', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the countdown from the server's deadline, not the local clock. */
|
||||
function startCountdown() {
|
||||
stopTimers();
|
||||
if (!view) return;
|
||||
var left = view.deadline_ms - view.server_now_ms;
|
||||
deadlineLocal = Date.now() + left;
|
||||
tick();
|
||||
timerHandle = window.setInterval(tick, 100);
|
||||
// Once the grace period has passed, ask the server to settle the timeout.
|
||||
expiryHandle = window.setTimeout(refresh, Math.max(0, left) + GRACE_MS);
|
||||
}
|
||||
|
||||
/** @param {SessionView} next */
|
||||
function render(next) {
|
||||
view = next;
|
||||
$('player').textContent = next.player || '';
|
||||
$('score').textContent = String(next.score);
|
||||
$('current').textContent = next.current;
|
||||
renderChain(next.chain);
|
||||
if (next.status === 'playing') {
|
||||
show('game');
|
||||
startCountdown();
|
||||
return;
|
||||
}
|
||||
stopTimers();
|
||||
renderEnd(next);
|
||||
}
|
||||
|
||||
var endText = {
|
||||
bot_stuck: ['Bạn thắng!', 'Bot hết từ để nối.'],
|
||||
max_moves: ['Bạn thắng!', 'Chuỗi đã đủ dài, bot chịu thua.'],
|
||||
timeout: ['Bot thắng', 'Bạn đã hết giờ.'],
|
||||
no_legal_move: ['Bot thắng', 'Không còn từ nào nối được — đây là ngõ cụt.'],
|
||||
gave_up: ['Bot thắng', 'Bạn đã chịu thua.']
|
||||
};
|
||||
|
||||
var reportText = {
|
||||
pending: 'Đang lưu điểm lên bảng xếp hạng Telegram…',
|
||||
ok: 'Đã lưu điểm vào bảng xếp hạng Telegram.',
|
||||
failed: 'Không lưu được điểm lên Telegram.',
|
||||
skipped: 'Không có điểm để lưu.'
|
||||
};
|
||||
|
||||
/** @param {SessionView} v */
|
||||
function renderEnd(v) {
|
||||
show('end');
|
||||
var text = endText[/** @type {keyof typeof endText} */ (v.end_reason)] ||
|
||||
(v.status === 'won' ? ['Bạn thắng!', ''] : ['Bot thắng', '']);
|
||||
$('result-title').textContent = text[0];
|
||||
$('result-detail').textContent = text[1];
|
||||
$('final-score').textContent = String(v.score);
|
||||
|
||||
var report = $('report-status');
|
||||
report.textContent = reportText[/** @type {keyof typeof reportText} */ (v.score_reported)] || '';
|
||||
report.className = v.score_reported === 'ok' ? 'report-ok' : v.score_reported === 'failed' ? 'report-failed' : 'muted';
|
||||
|
||||
var box = $('suggestions-box');
|
||||
var list = $('suggestions');
|
||||
list.textContent = '';
|
||||
if (v.status === 'lost' && v.suggestions.length > 0) {
|
||||
$('suggestions-label').textContent = 'Bạn có thể đã nối:';
|
||||
v.suggestions.forEach(function (w) {
|
||||
var li = document.createElement('li');
|
||||
li.textContent = w;
|
||||
list.appendChild(li);
|
||||
});
|
||||
box.hidden = false;
|
||||
} else {
|
||||
box.hidden = true;
|
||||
}
|
||||
|
||||
$('share').hidden = !(window.TelegramGameProxy && typeof window.TelegramGameProxy.shareScore === 'function');
|
||||
|
||||
if (v.score_reported === 'pending' && reportPolls < REPORT_POLLS) {
|
||||
reportPolls++;
|
||||
expiryHandle = window.setTimeout(refresh, REPORT_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {{code: string, message: string}} err */
|
||||
function handleError(err) {
|
||||
if (err.code === 'no_session') {
|
||||
stopTimers();
|
||||
view = null;
|
||||
chainList.textContent = '';
|
||||
expanded = Object.create(null);
|
||||
show('start');
|
||||
$('start-error').textContent = err.message;
|
||||
return;
|
||||
}
|
||||
if (err.code === 'game_over') {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
say(err.message, 'error');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!view) return;
|
||||
api('state', { session: view.session }).then(render, handleError);
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
startBtn.disabled = true;
|
||||
$('start-error').textContent = '';
|
||||
var picked = /** @type {HTMLInputElement|null} */ (document.querySelector('input[name="difficulty"]:checked'));
|
||||
api('start', { token: token, difficulty: picked ? picked.value : 'medium' }).then(function (v) {
|
||||
chainList.textContent = '';
|
||||
expanded = Object.create(null);
|
||||
reportPolls = 0;
|
||||
say('');
|
||||
wordInput.value = '';
|
||||
render(v);
|
||||
wordInput.focus();
|
||||
}, function (err) {
|
||||
$('start-error').textContent = err.message;
|
||||
}).then(function () {
|
||||
busy = false;
|
||||
startBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {Event} event */
|
||||
function submitWord(event) {
|
||||
event.preventDefault();
|
||||
var word = wordInput.value.trim();
|
||||
if (!view || busy || word === '') return;
|
||||
busy = true;
|
||||
sendBtn.disabled = true;
|
||||
say('Bot đang nghĩ', 'thinking');
|
||||
Promise.all([api('move', { session: view.session, word: word }), delay(THINK_MS)]).then(function (out) {
|
||||
/** @type {{result: MoveResult, state: SessionView}} */
|
||||
var res = out[0];
|
||||
if (res.result.accepted) {
|
||||
wordInput.value = '';
|
||||
say(res.result.bot_word ? 'Bot nối: ' + res.result.bot_word : '');
|
||||
} else {
|
||||
say(res.result.message || 'Từ không hợp lệ.', 'error');
|
||||
wordInput.select();
|
||||
}
|
||||
render(res.state);
|
||||
}, handleError).then(function () {
|
||||
busy = false;
|
||||
sendBtn.disabled = false;
|
||||
if (view && view.status === 'playing') wordInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function giveUp() {
|
||||
if (!view || busy) return;
|
||||
if (!giveUpArmed) {
|
||||
giveUpBtn.textContent = 'Bấm lần nữa để chịu thua';
|
||||
giveUpArmed = window.setTimeout(function () {
|
||||
giveUpArmed = 0;
|
||||
giveUpBtn.textContent = 'Chịu thua';
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
clearTimeout(giveUpArmed);
|
||||
giveUpArmed = 0;
|
||||
giveUpBtn.textContent = 'Chịu thua';
|
||||
api('give-up', { session: view.session }).then(render, handleError);
|
||||
}
|
||||
|
||||
function share() {
|
||||
try {
|
||||
if (window.TelegramGameProxy) window.TelegramGameProxy.shareScore();
|
||||
} catch (e) {
|
||||
$('share').hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', startGame);
|
||||
$('move-form').addEventListener('submit', submitWord);
|
||||
giveUpBtn.addEventListener('click', giveUp);
|
||||
$('replay').addEventListener('click', function () {
|
||||
stopTimers();
|
||||
show('start');
|
||||
});
|
||||
$('share').addEventListener('click', share);
|
||||
// A tab that slept (phone locked) has a stale countdown; resync on return.
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (!document.hidden && view && view.status === 'playing') refresh();
|
||||
});
|
||||
|
||||
show(token ? 'start' : 'missing');
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Nối từ</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="top">
|
||||
<h1>Nối từ</h1>
|
||||
<p class="player-name" id="player"></p>
|
||||
</header>
|
||||
|
||||
<section id="screen-missing" class="card" hidden>
|
||||
<p class="lead">Hãy mở trò chơi từ Telegram.</p>
|
||||
<p class="muted">Gửi <code>/noitu</code> cho bot rồi bấm nút Chơi.</p>
|
||||
</section>
|
||||
|
||||
<section id="screen-start" class="card" hidden>
|
||||
<p class="lead">Nối tiếp chuỗi từ với bot: từ của bạn phải bắt đầu bằng tiếng cuối của từ trước.</p>
|
||||
<ul class="rules">
|
||||
<li>Mỗi từ có ít nhất 2 tiếng và phải có trong từ điển.</li>
|
||||
<li>Không dùng lại từ đã chơi.</li>
|
||||
<li>Mỗi lượt có 30 giây. Trả lời nhanh, dùng từ dài hoặc từ hiếm để được nhiều điểm.</li>
|
||||
</ul>
|
||||
<fieldset class="difficulty">
|
||||
<legend>Độ khó</legend>
|
||||
<label><input type="radio" name="difficulty" value="easy"> Dễ</label>
|
||||
<label><input type="radio" name="difficulty" value="medium" checked> Vừa</label>
|
||||
<label><input type="radio" name="difficulty" value="hard"> Khó</label>
|
||||
</fieldset>
|
||||
<button type="button" id="start-btn" class="primary">Bắt đầu</button>
|
||||
<p id="start-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<section id="screen-game" hidden>
|
||||
<div class="scoreline">
|
||||
<span>Điểm: <strong id="score">0</strong></span>
|
||||
<span id="timer-text" class="timer-text">30</span>
|
||||
</div>
|
||||
<div class="timer-track" aria-hidden="true"><div id="timer-bar" class="timer-bar"></div></div>
|
||||
<form id="move-form" class="move-form" autocomplete="off">
|
||||
<label for="word">Từ bắt đầu bằng “<strong id="current"></strong>”</label>
|
||||
<div class="move-row">
|
||||
<input id="word" name="word" type="text" maxlength="64" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="send" required>
|
||||
<button type="submit" id="send-btn" class="primary">Gửi</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="message" class="message" role="status" aria-live="polite"></p>
|
||||
<button type="button" id="give-up" class="ghost">Chịu thua</button>
|
||||
</section>
|
||||
|
||||
<section id="screen-end" class="card" hidden>
|
||||
<h2 id="result-title"></h2>
|
||||
<p id="result-detail"></p>
|
||||
<p class="final-score">Điểm: <strong id="final-score">0</strong></p>
|
||||
<p id="report-status" class="muted"></p>
|
||||
<div id="suggestions-box" hidden>
|
||||
<p id="suggestions-label">Bạn có thể đã nối:</p>
|
||||
<ul id="suggestions" class="suggestions"></ul>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="replay" class="primary">Chơi lại</button>
|
||||
<button type="button" id="share" hidden>Chia sẻ điểm</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="chain-box" class="chain-box" hidden>
|
||||
<h2 class="chain-title">Chuỗi từ</h2>
|
||||
<ol id="chain" class="chain"></ol>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
Từ điển: <a href="https://vi.wiktionary.org/" target="_blank" rel="noopener noreferrer">Wiktionary tiếng Việt</a>
|
||||
(<a href="https://creativecommons.org/licenses/by-sa/4.0/deed.vi" target="_blank" rel="noopener noreferrer">CC BY-SA 4.0</a>)
|
||||
</footer>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
<script src="https://telegram.org/js/games.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package web holds the noitu game page: plain HTML, CSS and JavaScript with
|
||||
// no build step, embedded into the binary.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS holds index.html, app.css and app.js.
|
||||
//
|
||||
//go:embed index.html app.css app.js
|
||||
var FS embed.FS
|
||||
@@ -38,6 +38,8 @@ type Registry struct {
|
||||
crons map[string]Cron // name → Cron, unique across modules
|
||||
cronDeps map[string]Deps // cron name → owning module's Deps
|
||||
callbacks map[string]Callback
|
||||
games map[string]Game // short name → Game, unique across modules
|
||||
routes map[string]Route // pattern → Route, unique across modules
|
||||
commandHooks []func(ctx context.Context, name string, update *models.Update)
|
||||
fallback *CommandFallback // at most one; owner tracked in Build
|
||||
inline *InlineQuery // at most one; owner tracked in Build
|
||||
@@ -197,12 +199,15 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
|
||||
crons: map[string]Cron{},
|
||||
cronDeps: map[string]Deps{},
|
||||
callbacks: map[string]Callback{},
|
||||
games: map[string]Game{},
|
||||
routes: map[string]Route{},
|
||||
botUsername: opts.BotUsername,
|
||||
}
|
||||
|
||||
owners := map[string]string{} // command name → module that registered it
|
||||
cronOwners := map[string]string{}
|
||||
callbackOwners := map[string]string{}
|
||||
gameOwners := map[string]string{}
|
||||
var fallbackOwner, inlineOwner string
|
||||
seenModule := map[string]bool{}
|
||||
var unknown []string
|
||||
@@ -261,6 +266,14 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := reg.addGames(name, mod.Games, gameOwners); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := reg.addRoutes(name, mod.HTTP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := reg.addSingletons(name, mod, &fallbackOwner, &inlineOwner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -291,6 +304,48 @@ func (r *Registry) addCallbacks(module string, callbacks []Callback, owners map[
|
||||
return nil
|
||||
}
|
||||
|
||||
// addGames validates and indexes one module's BotFather games. A short name
|
||||
// is matched exactly, so two modules claiming the same one is a conflict.
|
||||
func (r *Registry) addGames(module string, games []Game, owners map[string]string) error {
|
||||
for _, g := range games {
|
||||
if err := validateGame(g); err != nil {
|
||||
return fmt.Errorf("module %q: %w", module, err)
|
||||
}
|
||||
if prev, dup := owners[g.ShortName]; dup {
|
||||
return fmt.Errorf("game conflict: %q defined in %q and %q", g.ShortName, prev, module)
|
||||
}
|
||||
owners[g.ShortName] = module
|
||||
r.games[g.ShortName] = g
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addRoutes validates and indexes one module's HTTP routes. validateRoute
|
||||
// confines each module to its own /games/<module>/ subtree, so a duplicate can
|
||||
// only come from the same module declaring a pattern twice.
|
||||
func (r *Registry) addRoutes(module string, routes []Route) error {
|
||||
for _, rt := range routes {
|
||||
if err := validateRoute(module, rt); err != nil {
|
||||
return fmt.Errorf("module %q: %w", module, err)
|
||||
}
|
||||
if _, dup := r.routes[rt.Pattern]; dup {
|
||||
return fmt.Errorf("route conflict: %q declared twice in %q", rt.Pattern, module)
|
||||
}
|
||||
r.routes[rt.Pattern] = rt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTTPRoutes returns every module-contributed HTTP route, sorted by pattern.
|
||||
func (r *Registry) HTTPRoutes() []Route {
|
||||
out := make([]Route, 0, len(r.routes))
|
||||
for _, rt := range r.routes {
|
||||
out = append(out, rt)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Pattern < out[j].Pattern })
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedCommands(m map[string]Command) []Command {
|
||||
out := make([]Command, 0, len(m))
|
||||
for _, c := range m {
|
||||
|
||||
@@ -3,6 +3,7 @@ package modules
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -422,3 +423,68 @@ func TestBuild_RejectsHandlerlessFallbackAndInline(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_DetectsGameConflict(t *testing.T) {
|
||||
game := Game{ShortName: "noitu", Visibility: VisibilityPublic, Handler: okHandler}
|
||||
factories := map[string]Factory{
|
||||
"alpha": func(_ Deps) Module { return Module{Games: []Game{game}} },
|
||||
"beta": func(_ Deps) Module { return Module{Games: []Game{game}} },
|
||||
}
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "game conflict") {
|
||||
t.Fatalf("expected game conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_RejectsInvalidGame(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"alpha": func(_ Deps) Module {
|
||||
return Module{Games: []Game{{ShortName: "no-itu", Visibility: VisibilityPublic, Handler: okHandler}}}
|
||||
},
|
||||
}
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "alpha"`) {
|
||||
t.Fatalf("expected game validation error naming the module, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_IndexesHTTPRoutesSorted(t *testing.T) {
|
||||
h := http.NotFoundHandler()
|
||||
factories := map[string]Factory{
|
||||
"zeta": func(_ Deps) Module { return Module{HTTP: []Route{{Pattern: "/games/zeta/", Handler: h}}} },
|
||||
"alpha": func(_ Deps) Module { return Module{HTTP: []Route{{Pattern: "/games/alpha/", Handler: h}}} },
|
||||
}
|
||||
reg, err := Build([]string{"zeta", "alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
routes := reg.HTTPRoutes()
|
||||
if len(routes) != 2 || routes[0].Pattern != "/games/alpha/" || routes[1].Pattern != "/games/zeta/" {
|
||||
t.Fatalf("HTTPRoutes = %+v, want alpha then zeta", routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_RejectsRouteOutsideModuleSubtree(t *testing.T) {
|
||||
h := http.NotFoundHandler()
|
||||
for _, pattern := range []string{"/", "/games/other/", "/games/alpha", "GET /games/alpha/", "example.com/games/alpha/", ""} {
|
||||
factories := map[string]Factory{
|
||||
"alpha": func(_ Deps) Module { return Module{HTTP: []Route{{Pattern: pattern, Handler: h}}} },
|
||||
}
|
||||
if _, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{}); err == nil {
|
||||
t.Errorf("pattern %q: expected validation error", pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_RejectsDuplicateRoute(t *testing.T) {
|
||||
h := http.NotFoundHandler()
|
||||
factories := map[string]Factory{
|
||||
"alpha": func(_ Deps) Module {
|
||||
return Module{HTTP: []Route{{Pattern: "/games/alpha/", Handler: h}, {Pattern: "/games/alpha/", Handler: h}}}
|
||||
},
|
||||
}
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "route conflict") {
|
||||
t.Fatalf("expected route conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
|
||||
var commandNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`)
|
||||
|
||||
// gameShortNameRe mirrors BotFather's rule for a game's short name.
|
||||
var gameShortNameRe = regexp.MustCompile(`^[A-Za-z0-9_]{3,64}$`)
|
||||
|
||||
const telegramCommandDescriptionMaxRunes = 256
|
||||
|
||||
func validateCommand(c Command) error {
|
||||
@@ -65,3 +68,41 @@ func validateCallback(c Callback) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validVisibility(v Visibility) bool {
|
||||
switch v {
|
||||
case VisibilityPublic, VisibilityProtected, VisibilityPrivate, VisibilityUnlisted:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateGame(g Game) error {
|
||||
if !gameShortNameRe.MatchString(g.ShortName) {
|
||||
return fmt.Errorf("game short name %q must match %s", g.ShortName, gameShortNameRe)
|
||||
}
|
||||
if !validVisibility(g.Visibility) {
|
||||
return fmt.Errorf("game %q: unknown visibility %d", g.ShortName, g.Visibility)
|
||||
}
|
||||
if g.Handler == nil {
|
||||
return fmt.Errorf("game %q: handler is nil", g.ShortName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRoute keeps a module's HTTP routes inside its own /games/<module>/
|
||||
// subtree. Method and host patterns are refused so the prefix check below sees
|
||||
// the path itself.
|
||||
func validateRoute(module string, r Route) error {
|
||||
if r.Handler == nil {
|
||||
return fmt.Errorf("route %q: handler is nil", r.Pattern)
|
||||
}
|
||||
if r.Pattern == "" || !strings.HasPrefix(r.Pattern, "/") || strings.ContainsAny(r.Pattern, " \t\r\n") {
|
||||
return fmt.Errorf("route %q: pattern must be a path starting with / (no method or host)", r.Pattern)
|
||||
}
|
||||
prefix := "/games/" + module + "/"
|
||||
if !strings.HasPrefix(r.Pattern, prefix) {
|
||||
return fmt.Errorf("route %q: pattern must start with %s", r.Pattern, prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -100,3 +100,28 @@ func TestValidateCron_RequiresNameAndHandler(t *testing.T) {
|
||||
t.Error("expected error for nil handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGame(t *testing.T) {
|
||||
good := Game{ShortName: "noitu_2", Visibility: VisibilityPublic, Handler: okHandler}
|
||||
if err := validateGame(good); err != nil {
|
||||
t.Fatalf("valid game rejected: %v", err)
|
||||
}
|
||||
bad := []Game{
|
||||
{ShortName: "no", Visibility: VisibilityPublic, Handler: okHandler},
|
||||
{ShortName: "no itu", Visibility: VisibilityPublic, Handler: okHandler},
|
||||
{ShortName: strings.Repeat("a", 65), Visibility: VisibilityPublic, Handler: okHandler},
|
||||
{ShortName: "noitu", Visibility: Visibility(99), Handler: okHandler},
|
||||
{ShortName: "noitu", Visibility: VisibilityPublic},
|
||||
}
|
||||
for _, g := range bad {
|
||||
if err := validateGame(g); err == nil {
|
||||
t.Errorf("validateGame(%q, %d, nil=%v) = nil, want error", g.ShortName, g.Visibility, g.Handler == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoute(t *testing.T) {
|
||||
if err := validateRoute("noitu", Route{Pattern: "/games/noitu/", Handler: nil}); err == nil {
|
||||
t.Error("nil handler accepted")
|
||||
}
|
||||
}
|
||||
+28
-10
@@ -1,22 +1,40 @@
|
||||
// Package server is the bot's HTTP surface: a single health route for the
|
||||
// container monitor, wrapped in structured request logging. Telegram updates
|
||||
// and crons never arrive over HTTP.
|
||||
// Package server is the bot's HTTP surface: a health route for the container
|
||||
// monitor plus any routes modules contribute (the noitu game page and its JSON
|
||||
// API), all wrapped in structured request logging. Telegram updates and crons
|
||||
// never arrive over HTTP.
|
||||
//
|
||||
// The package does not import internal/modules: cmd/server converts the
|
||||
// registry's routes into Route values, which keeps this package a leaf.
|
||||
package server
|
||||
|
||||
import "net/http"
|
||||
|
||||
// New builds the application's HTTP handler. The only route is:
|
||||
// Route is one extra handler mounted on the server's mux. Pattern is a
|
||||
// net/http ServeMux pattern.
|
||||
type Route struct {
|
||||
Pattern string
|
||||
Handler http.Handler
|
||||
}
|
||||
|
||||
// New builds the application's HTTP handler:
|
||||
//
|
||||
// GET / → health (Coolify container monitor; not publicly routed)
|
||||
// GET / → health (Coolify container monitor)
|
||||
// routes → module handlers, e.g. /games/noitu/ when the game is enabled
|
||||
//
|
||||
// There is no /webhook route: Telegram updates arrive via long polling
|
||||
// (cmd/server runs b.Start), so the bot needs no public inbound ingress.
|
||||
// There is no /cron route either: crons fire from the in-process scheduler
|
||||
// (internal/cron), so cron has no HTTP surface to expose or protect.
|
||||
// (cmd/server runs b.Start). There is no /cron route either: crons fire from
|
||||
// the in-process scheduler (internal/cron). Without module routes the bot
|
||||
// needs no public inbound ingress; a module route is reachable from outside
|
||||
// only when the deployment attaches a public domain to this port.
|
||||
//
|
||||
// Anything else is 404. All routes pass through LogRequests so every request
|
||||
// emits a structured `req` log line.
|
||||
func New() http.Handler {
|
||||
// emits a structured `req` log line; that line carries the path only, never
|
||||
// the query string.
|
||||
func New(routes ...Route) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", HealthHandler())
|
||||
for _, r := range routes {
|
||||
mux.Handle(r.Pattern, r.Handler)
|
||||
}
|
||||
return LogRequests(mux)
|
||||
}
|
||||
@@ -17,3 +17,29 @@ func TestHealthHandler_OK(t *testing.T) {
|
||||
t.Errorf("body = %q, want contains 'ok'", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ServesModuleRoutesAndKeepsHealth(t *testing.T) {
|
||||
_, restore := captureLogger(t)
|
||||
defer restore()
|
||||
h := New(Route{Pattern: "/games/demo/", Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("demo"))
|
||||
})})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/games/demo/x", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "demo" {
|
||||
t.Fatalf("module route: status %d body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
|
||||
t.Fatalf("health: status %d body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/games/other/", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown path: status %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// SetInlineGameScore records a game score on a message sent via the bot
|
||||
// (inline mode, or a t.me/<bot>?game= share), addressed by inline_message_id.
|
||||
//
|
||||
// go-telegram/bot v1.20.0 cannot make this call: SetGameScoreParams types
|
||||
// InlineMessageID as an int while Telegram's id is a string, and SetGameScore
|
||||
// always decodes a Message while Telegram answers an inline target with true.
|
||||
// This is a plain JSON POST instead. The request URL carries the bot token, so
|
||||
// errors never include it.
|
||||
//
|
||||
// A score that does not beat the user's current one is rejected by Telegram
|
||||
// with BOT_SCORE_NOT_MODIFIED; the error description is returned as is so the
|
||||
// caller can classify it.
|
||||
func SetInlineGameScore(ctx context.Context, token, inlineMessageID string, userID int64, score int) error {
|
||||
return setInlineGameScoreAt(ctx, telegramAPIBase, token, inlineMessageID, userID, score)
|
||||
}
|
||||
|
||||
// setInlineGameScoreAt is the testable core; base points tests at an httptest
|
||||
// server instead of the live API.
|
||||
func setInlineGameScoreAt(ctx context.Context, base, token, inlineMessageID string, userID int64, score int) error {
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"user_id": userID,
|
||||
"score": score,
|
||||
"inline_message_id": inlineMessageID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/bot"+token+"/setGameScore", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("setGameScore: build request failed")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
// *url.Error embeds the URL, which carries the token.
|
||||
return fmt.Errorf("setGameScore: request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if err != nil {
|
||||
return fmt.Errorf("setGameScore: read response: %w", err)
|
||||
}
|
||||
var r struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return fmt.Errorf("decode setGameScore response (status %d): %w", resp.StatusCode, err)
|
||||
}
|
||||
if !r.OK {
|
||||
return fmt.Errorf("setGameScore rejected (status %d): %s", resp.StatusCode, r.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetInlineGameScore_PostsJSON(t *testing.T) {
|
||||
var gotPath string
|
||||
var got map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := setInlineGameScoreAt(context.Background(), srv.URL, "tok", "AAA-inline", 42, 120); err != nil {
|
||||
t.Fatalf("setInlineGameScoreAt: %v", err)
|
||||
}
|
||||
if gotPath != "/bottok/setGameScore" {
|
||||
t.Errorf("path = %q", gotPath)
|
||||
}
|
||||
if got["inline_message_id"] != "AAA-inline" || got["user_id"].(float64) != 42 || got["score"].(float64) != 120 {
|
||||
t.Errorf("payload = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetInlineGameScore_ReturnsDescriptionWithoutToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: BOT_SCORE_NOT_MODIFIED"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := setInlineGameScoreAt(context.Background(), srv.URL, "secret-token", "x", 1, 1)
|
||||
if err == nil || !strings.Contains(err.Error(), "BOT_SCORE_NOT_MODIFIED") {
|
||||
t.Fatalf("err = %v, want the Telegram description", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error leaks the token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetInlineGameScore_TransportErrorHidesToken(t *testing.T) {
|
||||
err := setInlineGameScoreAt(context.Background(), "http://127.0.0.1:1", "secret-token", "x", 1, 1)
|
||||
if err == nil || strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("err = %v, want a token-free transport error", err)
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,11 @@ func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) {
|
||||
// real Telegram chats, where handlers key follow-up edits on the returned ID.
|
||||
func isMessageProducingMethod(method string) bool {
|
||||
switch method {
|
||||
case "sendMessage", "sendSticker", "sendPhoto", "sendDocument", "sendVideo", "sendAnimation":
|
||||
case "sendMessage", "sendSticker", "sendPhoto", "sendDocument", "sendVideo", "sendAnimation",
|
||||
// setGameScore returns the edited game Message for a chat message (an
|
||||
// inline message gets true instead, which the library cannot decode, so
|
||||
// that path goes through a raw call outside the recording bot).
|
||||
"sendGame", "setGameScore":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -89,3 +89,41 @@ func botCommandEntity(text string) models.MessageEntity {
|
||||
Length: end,
|
||||
}
|
||||
}
|
||||
|
||||
// NewGameCallback builds the callback query Telegram sends when a user presses
|
||||
// Play on a game message the bot sent into a chat: game_short_name set, no
|
||||
// callback data, and the game message attached.
|
||||
func NewGameCallback(userID, chatID int64, messageID int, shortName string) *models.Update {
|
||||
return &models.Update{
|
||||
ID: 1,
|
||||
CallbackQuery: &models.CallbackQuery{
|
||||
ID: "game-cbq",
|
||||
From: models.User{ID: userID, FirstName: "Test"},
|
||||
Message: models.MaybeInaccessibleMessage{
|
||||
Type: models.MaybeInaccessibleMessageTypeMessage,
|
||||
Message: &models.Message{
|
||||
ID: messageID,
|
||||
Chat: models.Chat{ID: chatID, Type: models.ChatTypePrivate},
|
||||
},
|
||||
},
|
||||
ChatInstance: "chat-instance",
|
||||
GameShortName: shortName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewInlineGameCallback builds the Play callback for a game message sent via
|
||||
// the bot (inline mode or a t.me/<bot>?game= link): it carries
|
||||
// inline_message_id instead of the message.
|
||||
func NewInlineGameCallback(userID int64, inlineMessageID, shortName string) *models.Update {
|
||||
return &models.Update{
|
||||
ID: 1,
|
||||
CallbackQuery: &models.CallbackQuery{
|
||||
ID: "game-cbq",
|
||||
From: models.User{ID: userID, FirstName: "Test"},
|
||||
InlineMessageID: inlineMessageID,
|
||||
ChatInstance: "chat-instance",
|
||||
GameShortName: shortName,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user