diff --git a/server/internal/game/engine.go b/server/internal/game/engine.go index 493387e..8f43484 100644 --- a/server/internal/game/engine.go +++ b/server/internal/game/engine.go @@ -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 diff --git a/server/internal/game/engine_test.go b/server/internal/game/engine_test.go index 286b2e1..726013b 100644 --- a/server/internal/game/engine_test.go +++ b/server/internal/game/engine_test.go @@ -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ữ")