Files
tiennm99 b8565164b3 feat(noitu): add /noitupvp group play
/noitupvp posts the noitu game card in a group and records it as a PvP
card. Everyone who presses Play on that card joins one room: the first
member hosts and starts, 2 to 8 players take 30-second turns, a player
whose turn runs out or who gives up is eliminated, and the last one
standing wins. Each player's points, plus a winner bonus, go to the
card's high-score table, and the bot replies to the card with the result.

The engine now shares one board between the solo game and the new
multiplayer match, so both use the same word rules and scoring.
2026-10-09 16:07:56 +07:00

65 lines
2.1 KiB
Go

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 (b *board) pointsFor(syllables int, link string, now time.Time) int {
parts := []int{
basePoints,
chainBonus * min(b.ChainLength(), chainBonusWords),
syllableBonus * (syllables - dict.MinSyllables),
b.speedPoints(now),
b.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 (b *board) speedPoints(now time.Time) int {
remaining := b.deadline.Sub(now)
if remaining <= 0 {
return 0
}
remaining = min(remaining, b.turnLimit)
return int(int64(speedBonus) * int64(remaining) / int64(b.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 (b *board) rarityPoints(link string) int {
options := b.dict.OutDegree(link)
if options < 1 {
return 0
}
halvings := bits.Len(uint(options)) - 1
return max(rarityBonus-rarityHalvingPenalty*halvings, 0)
}