Files
tiennm99bot/internal/modules/noitu/rooms.go
T
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

446 lines
12 KiB
Go

package noitu
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
)
const (
maxRooms = 500
maxChatRooms = 3 // live rooms per chat, so one group cannot fill maxRooms
maxRoomMembers = 32 // MaxSeats players plus watchers
// memberStaleTTL drops a lobby member whose page stopped polling, so a
// closed page does not hold a seat, and a lobby nobody polls is dropped
// with its last member. A running game never drops anyone: an absent
// player loses their turns on the clock instead.
memberStaleTTL = time.Minute
// roomStartInterval is the shortest gap between two games in one room.
// Starts also count against the host's startsPerMinute.
roomStartInterval = 3 * time.Second
// winnerBonus is added to the winner's points.
winnerBonus = 50
noSeat = -1
)
// Room statuses shown on the page.
const (
roomLobby = "lobby"
roomPlaying = "playing"
)
// roomMember is one person who joined a room through the card's Play button.
// secret is their bearer credential; it is only ever sent to them.
type roomMember struct {
secret string
userID int64
name string
lastSeen time.Time
lastMove time.Time
}
// roomEntry is one word of a room's chain. Seat indexes the game's players;
// the opening word is the bot's, with seat -1.
type roomEntry struct {
chainEntry
Seat int `json:"seat"`
Name string `json:"name,omitempty"`
}
// roomStanding is one player's line in a finished game's table.
type roomStanding struct {
userID int64
Name string `json:"name"`
Rank int `json:"rank"`
Score int `json:"score"` // word points plus the winner bonus
Bonus int `json:"bonus"`
Words int `json:"words"`
OutReason string `json:"out_reason"`
You bool `json:"you"`
}
// roomReport is one score to report on the card.
type roomReport struct {
claims claims
score int
}
// roomResult is the last finished game, shown in the lobby until the next
// game starts.
type roomResult struct {
Winner string `json:"winner"`
Words int `json:"words"` // words played, opening excluded
EndReason string `json:"end_reason"`
Standings []roomStanding `json:"standings"`
ScoreReported string `json:"score_reported"`
}
// room is the live game of one /noitupvp card. Its mutex guards every field
// below it; the room store's mutex is never taken while holding it.
type room struct {
id string
card claims // ChatID, MessageID and ThreadID of the card
// closed marks a room the sweep dropped. It is atomic so the store can
// replace a closed room without taking the room's mutex.
closed atomic.Bool
mu sync.Mutex
version int64
members []*roomMember // join order; members[0] hosts the lobby
game int // games started, so the page can tell chains apart
match *engine.Match // nil before the first game; kept after one ends
seats []*roomMember // the match's players, by seat
chain []roomEntry
result *roomResult
lastStart time.Time
}
// playing reports whether a game is in progress. Caller holds r.mu.
func (r *room) playing() bool { return r.match != nil && !r.match.Over() }
func (r *room) changed() { r.version++ }
// member finds the member holding secret. Caller holds r.mu.
func (r *room) member(secret string) *roomMember {
for _, m := range r.members {
if m.secret == secret {
return m
}
}
return nil
}
// seatOf is m's seat in the current or last game, noSeat when m watches.
// Caller holds r.mu.
func (r *room) seatOf(m *roomMember) int {
for i, s := range r.seats {
if s == m {
return i
}
}
return noSeat
}
// expired reports whether the room can be dropped: no game running and
// nobody left, every member having stopped polling. Caller holds r.mu.
func (r *room) expired() bool {
return !r.playing() && len(r.members) == 0
}
// roomStore indexes live rooms by card and by id.
type roomStore struct {
mu sync.Mutex
byCard map[string]*room
byID map[string]*room
}
func newRoomStore() *roomStore {
return &roomStore{byCard: map[string]*room{}, byID: map[string]*room{}}
}
func roomCardKey(c claims) string { return cardKey(c.ChatID, c.MessageID) }
// open returns the card's room, creating it when there is none, or only a
// closed one the sweep has not removed yet, and the room caps allow.
func (st *roomStore) open(card claims) (*room, error) {
st.mu.Lock()
defer st.mu.Unlock()
key := roomCardKey(card)
if r := st.byCard[key]; r != nil {
if !r.closed.Load() {
return r, nil
}
st.removeLocked(r)
}
if len(st.byID) >= maxRooms {
return nil, errBusy
}
inChat := 0
for _, r := range st.byID {
if r.card.ChatID == card.ChatID && !r.closed.Load() {
inChat++
}
}
if inChat >= maxChatRooms {
return nil, errChatRoomsAPI
}
id, err := newSessionID()
if err != nil {
return nil, err
}
r := &room{
id: id,
card: claims{ChatID: card.ChatID, MessageID: card.MessageID, ThreadID: card.ThreadID},
version: 1,
}
st.byCard[key] = r
st.byID[id] = r
return r, nil
}
func (st *roomStore) get(id string) *room {
st.mu.Lock()
defer st.mu.Unlock()
return st.byID[id]
}
func (st *roomStore) remove(r *room) {
st.mu.Lock()
defer st.mu.Unlock()
st.removeLocked(r)
}
func (st *roomStore) removeLocked(r *room) {
if st.byID[r.id] == r {
delete(st.byID, r.id)
}
if key := roomCardKey(r.card); st.byCard[key] == r {
delete(st.byCard, key)
}
}
func (st *roomStore) all() []*room {
st.mu.Lock()
defer st.mu.Unlock()
out := make([]*room, 0, len(st.byID))
for _, r := range st.byID {
out = append(out, r)
}
return out
}
// sweepRooms settles expired turns in rooms nobody is polling, so their
// games still end and report, and drops idle rooms.
func (s *service) sweepRooms(now time.Time) {
for _, r := range s.rooms.all() {
r.mu.Lock()
s.settleRoom(r, now)
drop := r.expired()
if drop {
r.closed.Store(true)
}
r.mu.Unlock()
if drop {
s.rooms.remove(r)
}
}
}
// settleRoom catches the room up with the clock: every turn that ran out
// eliminates its player, and lobby members who stopped polling leave.
// Caller holds r.mu.
func (s *service) settleRoom(r *room, now time.Time) {
if r.playing() {
timedOut := false
for r.match.Timeout(now) {
timedOut = true
}
if timedOut {
r.changed()
if r.match.Over() {
s.endRoomGame(r, now)
}
}
}
if r.playing() {
return
}
kept := r.members[:0]
for _, m := range r.members {
if now.Sub(m.lastSeen) <= memberStaleTTL {
kept = append(kept, m)
}
}
if len(kept) != len(r.members) {
clear(r.members[len(kept):])
r.members = kept
r.changed()
}
}
// join adds the user to the room, or returns their existing membership so a
// reloaded page or a second Play press keeps its place. Caller holds r.mu.
func (r *room) join(c claims, now time.Time) (*roomMember, error) {
name := c.Name
if name == "" {
name = "Người chơi"
}
for _, m := range r.members {
if m.userID == c.UserID {
m.lastSeen = now
if m.name != name {
m.name = name
r.changed()
}
return m, nil
}
}
if len(r.members) >= maxRoomMembers {
return nil, errRoomFullAPI
}
secret, err := newSessionID()
if err != nil {
return nil, err
}
m := &roomMember{secret: secret, userID: c.UserID, name: name, lastSeen: now}
r.members = append(r.members, m)
r.changed()
return m, nil
}
// startGame seats the first MaxSeats members in join order and opens a
// match. Caller holds r.mu and has checked the lobby and the host.
func (s *service) startGame(r *room, now time.Time) error {
seats := r.members[:min(len(r.members), engine.MaxSeats)]
if len(seats) < engine.MinSeats {
return errNotEnoughAPI
}
rng := s.cfg.newRNG()
match, err := engine.NewMatch(s.words, len(seats), s.words.RandomOpening(rng, minOpeningOut), turnLimit, turnGrace, now)
if err != nil {
return err
}
r.match = match
r.seats = append([]*roomMember(nil), seats...)
r.chain = []roomEntry{{chainEntry: s.entry(match.Opening(), "bot", 0), Seat: noSeat}}
r.result = nil
r.game++
r.lastStart = now
for _, m := range r.seats {
m.lastMove = time.Time{}
}
r.changed()
return nil
}
// playRoomWord submits seat's word. Caller holds r.mu and has checked that a
// game runs.
func (s *service) playRoomWord(r *room, seat int, word string, now time.Time) moveResult {
mv, reason := r.match.Submit(seat, word, now)
if reason != engine.ReasonNone {
if r.match.Over() || reason == engine.ReasonTimeout {
r.changed()
}
if r.match.Over() {
s.endRoomGame(r, now)
}
return rejectedFor(reason, r.match.Current())
}
r.chain = append(r.chain, roomEntry{chainEntry: s.entry(mv.Word, "player", mv.Points), Seat: seat, Name: r.seats[seat].name})
if len(r.chain) >= maxChain {
r.match.EndMaxMoves()
}
r.changed()
if r.match.Over() {
s.endRoomGame(r, now)
}
return moveResult{Accepted: true, PlayerWord: mv.Word}
}
// resignRoom takes seat out of the running game. Caller holds r.mu.
func (s *service) resignRoom(r *room, seat int, now time.Time) {
if !r.match.Resign(seat, now) {
return
}
r.changed()
if r.match.Over() {
s.endRoomGame(r, now)
}
}
// endRoomGame records the finished game's table, returns the room to the
// lobby, then posts the result and reports every positive score in the
// background. Caller holds r.mu.
func (s *service) endRoomGame(r *room, now time.Time) {
words := make([]int, len(r.seats))
for _, e := range r.chain {
if e.Seat >= 0 {
words[e.Seat]++
}
}
res := &roomResult{
Winner: r.seats[r.match.Winner()].name,
Words: len(r.chain) - 1,
EndReason: string(r.match.EndReason()),
}
var reports []roomReport
for rank, seat := range r.match.Standings() {
m := r.seats[seat]
st := roomStanding{
userID: m.userID,
Name: m.name,
Rank: rank + 1,
Score: r.match.Score(seat),
Words: words[seat],
OutReason: string(r.match.OutReason(seat)),
}
// A game nobody played a word in (everyone left at once) earns
// nothing: no bonus, no score report and no announcement.
if seat == r.match.Winner() && res.Words > 0 {
st.Bonus = winnerBonus
st.Score += winnerBonus
}
res.Standings = append(res.Standings, st)
if st.Score > 0 {
reports = append(reports, roomReport{claims: claims{UserID: m.userID, ChatID: r.card.ChatID, MessageID: r.card.MessageID}, score: st.Score})
}
}
res.ScoreReported = reportPending
if len(reports) == 0 || s.cfg.reporter == nil {
res.ScoreReported = reportSkipped
}
r.result = res
for _, m := range r.members {
// Back in the lobby, the clock for leaving starts now, not at the
// last poll before a long game.
if m.lastSeen.Before(now) {
m.lastSeen = now
}
}
r.changed()
if res.Words == 0 {
return
}
text := fmt.Sprintf("Ván nối từ kết thúc: %s thắng sau %d từ!", res.Winner, res.Words)
go s.publishRoomResult(r, res, text, reports, res.ScoreReported == reportPending) //nolint:gosec // G118: the report must outlive the request that ended the game
}
// publishRoomResult announces the result in the chat and reports the scores
// one by one. Publishing holds the card's lock, so two games on one card
// never edit its game message at the same time. Neither step is fatal to
// the room.
func (s *service) publishRoomResult(r *room, res *roomResult, text string, reports []roomReport, report bool) {
defer s.publishing.Acquire(roomCardKey(r.card))()
if s.cfg.announcer != nil {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
if err := s.cfg.announcer.Announce(ctx, r.card, text); err != nil {
log.Warn("noitu room result message failed", "err", err)
}
cancel()
}
if !report {
return
}
status := reportOK
for _, rep := range reports {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
err := s.cfg.reporter.Report(ctx, rep.claims, rep.score)
cancel()
if err != nil && !scoreNotModified(err) {
status = reportFailed
log.Warn("noitu room setGameScore failed", "err", err)
}
}
r.mu.Lock()
defer r.mu.Unlock()
res.ScoreReported = status
if r.result == res {
r.changed()
}
}