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:
tiennm99 committed 2026-10-09 14:32:57 +07:00
1 parent fe552f43e4
commit 6f30d13b2d
52 files changed
+42452 -35

No files matched your search

+45
View File
@@ -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})
}
+94
View File
@@ -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())
}
}
+26
View File
@@ -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.
+68
View File
@@ -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
}
+35
View File
@@ -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
}
+28
View File
@@ -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.
+428
View File
@@ -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
+142
View File
@@ -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")
}
}
+46
View File
@@ -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
}
+193
View File
@@ -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
}
+292
View File
@@ -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
}
+345
View File
@@ -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")
}
}
+64
View File
@@ -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)
}
+198
View File
@@ -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
}
+413
View File
@@ -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
}
+444
View File
@@ -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)
}
}
+30
View File
@@ -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)
}
}
+162
View File
@@ -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:])))
}
+288
View File
@@ -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")
}
}
+17
View File
@@ -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
}
+218
View File
@@ -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
}
+71
View File
@@ -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")
}
}
+41
View File
@@ -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")
}
+271
View File
@@ -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()
}
+105
View File
@@ -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
}
+148
View File
@@ -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; }
}
+407
View File
@@ -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');
})();
+85
View File
@@ -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>
+10
View File
@@ -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
+55
View File
@@ -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 {
+66
View File
@@ -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)
}
}
+41
View File
@@ -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
}
+25
View File
@@ -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
View File
@@ -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)
}
+26
View File
@@ -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)
}
}
+66
View File
@@ -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
}
+54
View File
@@ -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)
}
}
+5 -1
View File
@@ -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
+38
View File
@@ -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,
},
}
}