mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
Replace the pinned 2018 undertheseanlp wordlist with kaikki.org's current wiktextract export of the Vietnamese Wiktionary, read with --kaikki. The same authors and website, eight years fresher: 34,813 words instead of 26,845, with every graph metric up and bot game length unchanged. The export is fetched fresh for every build and is not pinned, by the owner's decision: kaikki keeps no dated snapshots, so a checksum would break weekly. The builder therefore hashes the file as it streams it and records source_sha256, source_rows and source_fetched_at in meta; the fetch downloads to a .part name and renames on success; a truncated or non-JSON body fails the build, and the --min-words floor rises to 30,000. DICT_SHA256 and verify-dict are gone; the Makefile, Dockerfile and the builder's URL constant are held in agreement by a test. Current Wiktionary text is CC BY-SA 4.0, so the data licence returns to 4.0: data/LICENSE is restored, and NOTICE, ATTRIBUTION, README, the image docs and the in-game footer credit Wiktionary tiếng Việt's contributors and wiktextract/kaikki.org. builder_version becomes 3 for the changed meta contract.
157 lines
3.9 KiB
Go
157 lines
3.9 KiB
Go
// Command noitu-server runs the nối từ game server: the WebSocket API, the
|
|
// rooms behind it, and the built frontend, in one binary.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/tiennm99dev/noitu/server/internal/dictionary"
|
|
"github.com/tiennm99dev/noitu/server/internal/wsapi"
|
|
)
|
|
|
|
const (
|
|
defaultAddr = ":8080"
|
|
defaultDBPath = "data/noitu.db"
|
|
defaultTurnLimit = 20 * time.Second
|
|
defaultGrace = 30 * time.Second
|
|
|
|
// shutdownGrace bounds how long in-flight requests get once a signal
|
|
// arrives. Rooms are told separately and immediately, so this only covers
|
|
// the HTTP side.
|
|
shutdownGrace = 10 * time.Second
|
|
)
|
|
|
|
type config struct {
|
|
addr string
|
|
dbPath string
|
|
turnLimit time.Duration
|
|
grace time.Duration
|
|
allowedOrigins []string
|
|
webDir string
|
|
}
|
|
|
|
func main() {
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
|
|
|
if err := run(); err != nil {
|
|
slog.Error("server exited", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
cfg := loadConfig()
|
|
|
|
store, err := dictionary.Open(cfg.dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The store deliberately does not log — a library writing to the global
|
|
// logger fights the server's own handler — so the CC BY-SA 4.0 attribution
|
|
// that ships with the data surfaces here or nowhere.
|
|
slog.Info("dictionary loaded",
|
|
"path", cfg.dbPath,
|
|
"words", store.WordCount(),
|
|
"aliases", store.AliasCount(),
|
|
"license", store.License(),
|
|
)
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
api := wsapi.NewServer(ctx, store, wsapi.Config{
|
|
TurnLimit: cfg.turnLimit,
|
|
GraceFor: cfg.grace,
|
|
AllowedOrigins: cfg.allowedOrigins,
|
|
WebDir: cfg.webDir,
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.addr,
|
|
Handler: api,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
slog.Info("listening", "addr", cfg.addr, "turn_limit", cfg.turnLimit, "web_dir", cfg.webDir)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
errc <- err
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case err := <-errc:
|
|
return err
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
// Tell players why before the sockets go, rather than dropping them and
|
|
// leaving the UI to guess.
|
|
slog.Info("shutting down")
|
|
api.Shutdown()
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
|
defer cancel()
|
|
return srv.Shutdown(shutdownCtx)
|
|
}
|
|
|
|
func loadConfig() config {
|
|
return config{
|
|
addr: env("NOITU_ADDR", defaultAddr),
|
|
dbPath: env("NOITU_DB_PATH", defaultDBPath),
|
|
turnLimit: envDuration("NOITU_TURN_LIMIT", defaultTurnLimit),
|
|
grace: envDuration("NOITU_GRACE", defaultGrace),
|
|
allowedOrigins: envList("NOITU_ALLOWED_ORIGINS"),
|
|
webDir: env("NOITU_WEB_DIR", ""),
|
|
}
|
|
}
|
|
|
|
func env(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// envDuration falls back loudly. A typo in a timing variable would otherwise
|
|
// silently change the rules of the game.
|
|
func envDuration(key string, fallback time.Duration) time.Duration {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
d, err := time.ParseDuration(raw)
|
|
if err != nil || d <= 0 {
|
|
slog.Warn("ignoring invalid duration", "key", key, "value", raw, "using", fallback)
|
|
return fallback
|
|
}
|
|
return d
|
|
}
|
|
|
|
// envList returns nil for an unset variable, which coder/websocket reads as
|
|
// same-origin only.
|
|
func envList(key string) []string {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|