mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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:
1 parent
3ba99ec107
commit
af08df74a9
2 files changed
+184
-16
No files matched your search
@@ -10,20 +10,43 @@ package game
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/bits"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
|
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scoring. Longer words are worth more, which gives players a reason to reach
|
// Scoring. A word is worth more the longer the chain it extends, the longer
|
||||||
// for three- and four-syllable compounds rather than always playing the
|
// the word itself, the faster it was played, and the fewer words the corpus
|
||||||
// shortest legal word.
|
// 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 (
|
const (
|
||||||
basePoints = 10
|
basePoints = 10
|
||||||
chainBonus = 2 // per word already played
|
chainBonus = 2 // per word already played
|
||||||
syllableBonus = 5 // per syllable beyond the minimum
|
// chainBonusWords caps how much of the chain the chain term counts.
|
||||||
maxPointsPerWord = 100
|
// 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.
|
// 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,
|
First: first,
|
||||||
Last: last,
|
Last: last,
|
||||||
Syllables: len(syllables),
|
Syllables: len(syllables),
|
||||||
Points: e.pointsFor(len(syllables)),
|
Points: e.pointsFor(len(syllables), first, now),
|
||||||
At: 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
|
// pointsFor scores a word about to be played. The chain term counts the words
|
||||||
// already down, opening word included, which is what ChainLength reports.
|
// already down, opening word included, which is what ChainLength reports; link
|
||||||
func (e *Engine) pointsFor(syllables int) int {
|
// is the syllable the word answers, and now is when it was played, so both the
|
||||||
points := basePoints + chainBonus*e.ChainLength() + syllableBonus*(syllables-vietnamese.MinSyllables)
|
// 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)
|
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.
|
// LegalMoves lists every word the player to act may play.
|
||||||
//
|
//
|
||||||
// Allocates, so the bot's search uses HasLegalMove and iterates directly rather
|
// Allocates, so the bot's search uses HasLegalMove and iterates directly rather
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"iter"
|
"iter"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"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")
|
d := newDict("ngôn ngữ", "ngữ pháp", "pháp vô tuyến điện")
|
||||||
e := newGame(t, d, "ngôn ngữ")
|
e := newGame(t, d, "ngôn ngữ")
|
||||||
|
|
||||||
// Spec: 10 + 2*chainLength + 5*(syllables-2), where chainLength counts the
|
// Spec: 10 + 2*chainLength + 5*(syllables-2) + speed + rarity, where
|
||||||
// words already down, opening word included. Asserted as literals so the
|
// chainLength counts the words already down, opening word included, speed
|
||||||
// test pins the specified formula rather than whatever the code computes.
|
// 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)
|
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)
|
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 {
|
if r != ReasonNone {
|
||||||
t.Fatalf("four-syllable word rejected: %s", r)
|
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)
|
t.Errorf("four-syllable word at chain 2 scored %d, want %d", four.Points, want)
|
||||||
}
|
}
|
||||||
if four.Points <= two.Points {
|
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) {
|
func TestLegalMovesAndHasLegalMove(t *testing.T) {
|
||||||
e := newGame(t, standardDict(), "ngôn ngữ")
|
e := newGame(t, standardDict(), "ngôn ngữ")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user