feat(game): price a word on speed and how rare its link is

A word was worth its length and the length of the chain it extended.
Two more terms: the share of the turn the player left on the clock, and
how few words the corpus offers for the syllable they answered.

The chain term now stops counting at 15 words. Uncapped it grew without
bound, so a total grew with the square of how long a player survived,
which drowned everything the move itself earned and pinned every late
word to the per-word ceiling.

Rarity is a ladder of 3 points per doubling of the answers available
rather than a straight line. Option counts are heavy tailed — the
syllable a player is handed has a median of 14 answers and a maximum of
195 — so a line spends the bonus across the middle, where a straight
count of options is not what a player feels as rare.
This commit is contained in:
tiennm99 committed 2026-09-09 00:12:53 +07:00
1 parent 3ba99ec107
commit af08df74a9
2 files changed
+184 -16

No files matched your search

+75 -11
View File
@@ -10,20 +10,43 @@ package game
import (
"errors"
"fmt"
"math/bits"
"slices"
"time"
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
)
// Scoring. Longer words are worth more, which gives players a reason to reach
// for three- and four-syllable compounds rather than always playing the
// shortest legal word.
// Scoring. A word is worth more the longer the chain it extends, the longer
// the word itself, the faster it was played, and the fewer words the corpus
// offered for the syllable it answered. Between them the four terms reward
// reaching for three- and four-syllable compounds, answering without stalling
// on the clock, and knowing a word for a syllable almost nothing follows.
const (
basePoints = 10
chainBonus = 2 // per word already played
syllableBonus = 5 // per syllable beyond the minimum
maxPointsPerWord = 100
basePoints = 10
chainBonus = 2 // per word already played
// chainBonusWords caps how much of the chain the chain term counts.
// Uncapped it grows without bound, so a player's total would grow with the
// square of how long they survived: length would drown the three terms
// that reward the move itself, and every word late in a long game would
// land on maxPointsPerWord with nothing to tell two of them apart.
chainBonusWords = 15
syllableBonus = 5 // per syllable beyond the minimum
// speedBonus is paid in full for an answer that arrives instantly and
// falls linearly to nothing for one that arrives on the buzzer.
speedBonus = 10
// rarityBonus is paid in full for a syllable the corpus answers with a
// single word and loses rarityHalvingPenalty for each doubling of the
// answers available, so it is spent by 32.
//
// A ladder rather than a straight line because option counts are heavy
// tailed: in the shipped corpus the syllable a player is handed has a
// median of 14 answers and a maximum of 195, and going from one answer to
// two is the whole of what "rare" means to a player while going from 100
// to 200 is nothing they can feel.
rarityBonus = 15
rarityHalvingPenalty = 3
maxPointsPerWord = 100
)
// Engine holds one game of two or more players.
@@ -215,7 +238,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
First: first,
Last: last,
Syllables: len(syllables),
Points: e.pointsFor(len(syllables)),
Points: e.pointsFor(len(syllables), first, now),
At: now,
}
@@ -236,12 +259,53 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
}
// pointsFor scores a word about to be played. The chain term counts the words
// already down, opening word included, which is what ChainLength reports.
func (e *Engine) pointsFor(syllables int) int {
points := basePoints + chainBonus*e.ChainLength() + syllableBonus*(syllables-vietnamese.MinSyllables)
// already down, opening word included, which is what ChainLength reports; link
// is the syllable the word answers, and now is when it was played, so both the
// speed and the rarity term have to be read before the move is applied.
func (e *Engine) pointsFor(syllables int, link string, now time.Time) int {
points := basePoints +
chainBonus*min(e.ChainLength(), chainBonusWords) +
syllableBonus*(syllables-vietnamese.MinSyllables) +
e.speedPoints(now) +
e.rarityPoints(link)
return min(points, maxPointsPerWord)
}
// speedPoints pays for the share of the turn the player left on the clock.
//
// Called from Submit after the expiry check, so the deadline is still this
// player's and has not passed; the clamps only keep an unexpired-but-late
// answer or a caller's clock skew from turning into negative or excess points.
func (e *Engine) speedPoints(now time.Time) int {
remaining := e.deadline.Sub(now)
if remaining <= 0 {
return 0
}
if remaining > e.turnLimit {
remaining = e.turnLimit
}
return int(int64(speedBonus) * int64(remaining) / int64(e.turnLimit))
}
// rarityPoints pays for how little the corpus offers for the syllable the word
// answers. It counts every word on that link, spent ones included: the reward
// is for knowing a word where the language has few, which is a property of the
// dictionary and not of how far this particular game has run them down.
//
// An unknown syllable scores nothing rather than the maximum. The link was
// just answered, so the corpus does hold a word for it; a dictionary that
// cannot count them is a dictionary that cannot price rarity.
func (e *Engine) rarityPoints(link string) int {
options, err := e.dict.OutDegree(link)
if err != nil || options < 1 {
return 0
}
// bits.Len(1) is 1, so this is how many times the count has doubled past
// the single answer that pays in full.
halvings := bits.Len(uint(options)) - 1
return max(rarityBonus-rarityHalvingPenalty*halvings, 0)
}
// LegalMoves lists every word the player to act may play.
//
// Allocates, so the bot's search uses HasLegalMove and iterates directly rather
+109 -5
View File
@@ -1,6 +1,7 @@
package game
import (
"fmt"
"iter"
"slices"
"strings"
@@ -275,11 +276,14 @@ func TestSubmitScoring(t *testing.T) {
d := newDict("ngôn ngữ", "ngữ pháp", "pháp vô tuyến điện")
e := newGame(t, d, "ngôn ngữ")
// Spec: 10 + 2*chainLength + 5*(syllables-2), where chainLength counts the
// words already down, opening word included. Asserted as literals so the
// test pins the specified formula rather than whatever the code computes.
// Spec: 10 + 2*chainLength + 5*(syllables-2) + speed + rarity, where
// chainLength counts the words already down, opening word included, speed
// is 10 for an answer with the whole turn still on the clock, and rarity
// is 15 for a syllable this dictionary answers with one word. Asserted as
// literals so the test pins the specified formula rather than whatever the
// code computes.
two, _ := e.Submit(alice, "ngữ pháp", t0)
if want := 12; two.Points != want { // 10 + 2*1 + 5*0
if want := 37; two.Points != want { // 10 + 2*1 + 5*0 + 10 + 15
t.Errorf("two-syllable word at chain 1 scored %d, want %d", two.Points, want)
}
@@ -287,7 +291,7 @@ func TestSubmitScoring(t *testing.T) {
if r != ReasonNone {
t.Fatalf("four-syllable word rejected: %s", r)
}
if want := 24; four.Points != want { // 10 + 2*2 + 5*2
if want := 49; four.Points != want { // 10 + 2*2 + 5*2 + 10 + 15
t.Errorf("four-syllable word at chain 2 scored %d, want %d", four.Points, want)
}
if four.Points <= two.Points {
@@ -295,6 +299,106 @@ func TestSubmitScoring(t *testing.T) {
}
}
// The speed term falls off linearly across the turn and is gone by the
// deadline, which is the last instant a submission is still in time.
func TestSubmitScoringPaysForTimeLeft(t *testing.T) {
half := t0.Add(10 * time.Second) // half of the 20-second turn newGame gives
buzzer := t0.Add(20 * time.Second)
instant, _ := newGame(t, newDict("ngôn ngữ", "ngữ pháp"), "ngôn ngữ").Submit(alice, "ngữ pháp", t0)
halfway, _ := newGame(t, newDict("ngôn ngữ", "ngữ pháp"), "ngôn ngữ").Submit(alice, "ngữ pháp", half)
late, r := newGame(t, newDict("ngôn ngữ", "ngữ pháp"), "ngôn ngữ").Submit(alice, "ngữ pháp", buzzer)
if r != ReasonNone {
t.Fatalf("word played on the deadline rejected: %s", r)
}
if want := 37; instant.Points != want { // ... + speed 10
t.Errorf("instant answer scored %d, want %d", instant.Points, want)
}
if want := 32; halfway.Points != want { // ... + speed 5
t.Errorf("answer with half the turn left scored %d, want %d", halfway.Points, want)
}
if want := 27; late.Points != want { // ... + speed 0
t.Errorf("answer on the buzzer scored %d, want %d", late.Points, want)
}
}
// The rarity term pays for the syllable being answered, not for the word: the
// same word is worth more when the dictionary offers little else on that link,
// and it loses 3 points per doubling of the answers rather than falling in a
// straight line, so a crowded link pays nothing at all.
func TestSubmitScoringPaysForARareLink(t *testing.T) {
// A dictionary where "ngữ" is answered by options words, one of which is
// the word the test plays.
link := func(options int) *fakeDict {
words := []string{"ngôn ngữ", "ngữ pháp"}
for i := 1; i < options; i++ {
words = append(words, fmt.Sprintf("ngữ từ%d", i))
}
return newDict(words...)
}
// 10 base + 2 chain + 0 syllables + 10 speed = 22 before the rarity term.
tests := []struct {
options int
want int
}{
{1, 22 + 15},
{2, 22 + 12},
{3, 22 + 12},
{4, 22 + 9},
{8, 22 + 6},
{16, 22 + 3},
{32, 22},
{64, 22},
}
for _, tc := range tests {
e := newGame(t, link(tc.options), "ngôn ngữ")
m, r := e.Submit(alice, "ngữ pháp", t0)
if r != ReasonNone {
t.Fatalf("word on a %d-answer link rejected: %s", tc.options, r)
}
if m.Points != tc.want {
t.Errorf("word on a %d-answer link scored %d, want %d", tc.options, m.Points, tc.want)
}
}
}
// The chain term stops growing at chainBonusWords. Beyond it a word is priced
// by the move — its length, its speed, its link — and not by how long the game
// happened to have been running.
func TestSubmitScoringStopsPayingForChainLength(t *testing.T) {
// A single line of two-syllable words, "a0 a1" -> "a1 a2" -> ..., so every
// link has exactly one answer and only the chain term varies.
const words = chainBonusWords + 3
var line []string
for i := range words {
line = append(line, fmt.Sprintf("a%d a%d", i, i+1))
}
e := newGame(t, newDict(line...), line[0])
// 10 base + 0 syllables + 10 speed + 15 rarity, plus the chain term.
var points []int
for i := 1; i < words; i++ {
m, r := e.Submit(e.Turn(), line[i], t0)
if r != ReasonNone {
t.Fatalf("word %d of the line rejected: %s", i, r)
}
points = append(points, m.Points)
}
for i, got := range points {
chain := i + 1 // the words already down when this one was played
want := 35 + 2*min(chain, chainBonusWords)
if got != want {
t.Errorf("word at chain %d scored %d, want %d", chain, got, want)
}
}
if first, last := points[chainBonusWords-1], points[len(points)-1]; first != last {
t.Errorf("chain term kept growing past %d words: %d then %d", chainBonusWords, first, last)
}
}
func TestLegalMovesAndHasLegalMove(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")