1package server
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strconv"
8 "sync"
9 "time"
10
11 "github.com/zikaeroh/codies/internal/game"
12 "github.com/zikaeroh/codies/internal/protocol"
13 "github.com/zikaeroh/codies/internal/uid"
14 "github.com/zikaeroh/ctxjoin"
15 "github.com/zikaeroh/ctxlog"
16 "go.uber.org/atomic"
17 "go.uber.org/zap"
18 "golang.org/x/sync/errgroup"
19 "nhooyr.io/websocket"
20 "nhooyr.io/websocket/wsjson"
21)
22
23const maxRooms = 1000
24
25var (
26 ErrRoomExists = errors.New("server: rooms exist")
27 ErrTooManyRooms = errors.New("server: too many rooms")
28)
29
30type Server struct {
31 clientCount atomic.Int64
32 roomCount atomic.Int64
33 doPrune chan struct{}
34 ready chan struct{}
35
36 genRoomID *uid.Generator
37
38 ctx context.Context
39
40 mu sync.Mutex
41 rooms map[string]*Room
42 roomIDs map[string]*Room
43}
44
45func NewServer() *Server {
46 return &Server{
47 ready: make(chan struct{}),
48 doPrune: make(chan struct{}, 1),
49 genRoomID: uid.NewGenerator(salt()), // IDs are only valid for this server instance; ok to randomize salt.
50 rooms: make(map[string]*Room),
51 roomIDs: make(map[string]*Room),
52 }
53}
54
55func salt() string {
56 x := time.Now().Unix()
57 return strconv.FormatInt(x, 10)
58}
59
60func (s *Server) Run(ctx context.Context) error {
61 s.ctx = ctx
62
63 close(s.ready)
64 ticker := time.NewTicker(5 * time.Minute)
65 defer ticker.Stop()
66
67 for {
68 select {
69 case <-ctx.Done():
70 return ctx.Err()
71
72 case <-s.doPrune:
73 s.prune(ctx)
74
75 case <-ticker.C:
76 s.prune(ctx)
77 }
78 }
79}
80
81func (s *Server) FindRoom(name string) *Room {
82 <-s.ready
83
84 s.mu.Lock()
85 defer s.mu.Unlock()
86 return s.rooms[name]
87}
88
89func (s *Server) FindRoomByID(id string) *Room {
90 <-s.ready
91
92 s.mu.Lock()
93 defer s.mu.Unlock()
94 return s.roomIDs[id]
95}
96
97func (s *Server) CreateRoom(ctx context.Context, name, password string) (*Room, error) {
98 <-s.ready
99
100 s.mu.Lock()
101 defer s.mu.Unlock()
102
103 room := s.rooms[name]
104 if room != nil {
105 return nil, ErrRoomExists
106 }
107
108 if len(s.rooms) >= maxRooms {
109 return nil, ErrTooManyRooms
110 }
111
112 id, idRaw := s.genRoomID.Next()
113
114 roomCtx, roomCancel := context.WithCancel(s.ctx)
115
116 room = &Room{
117 Name: name,
118 Password: password,
119 ID: id,
120 clientCount: &s.clientCount,
121 roomCount: &s.roomCount,
122 genPlayerID: uid.NewGenerator(id),
123 ctx: roomCtx,
124 cancel: roomCancel,
125 room: game.NewRoom(nil),
126 players: make(map[game.PlayerID]noteSender),
127 turnSeconds: 60,
128 }
129
130 room.lastSeen.Store(time.Now())
131
132 room.room.NewGame()
133
134 s.rooms[name] = room
135 s.roomIDs[room.ID] = room
136 s.roomCount.Inc()
137 metricRooms.Inc()
138
139 ctxlog.Info(ctx, "created new room", zap.String("roomName", name), zap.String("roomID", room.ID))
140
141 if idRaw%100 == 0 {
142 s.triggerPrune()
143 }
144
145 return room, nil
146}
147
148func (s *Server) triggerPrune() {
149 select {
150 case s.doPrune <- struct{}{}:
151 default:
152 }
153}
154
155func (s *Server) prune(ctx context.Context) {
156 s.mu.Lock()
157 defer s.mu.Unlock()
158
159 toRemove := make([]string, 0, 1)
160
161 for name, room := range s.rooms {
162 lastSeen := room.lastSeen.Load().(time.Time)
163 if time.Since(lastSeen) > 10*time.Minute {
164 toRemove = append(toRemove, name)
165 }
166 }
167
168 if len(toRemove) == 0 {
169 return
170 }
171
172 for _, name := range toRemove {
173 room := s.rooms[name]
174 room.mu.Lock()
175 room.stopTimer()
176 room.mu.Unlock()
177
178 room.cancel()
179 delete(s.rooms, name)
180 delete(s.roomIDs, room.ID)
181 s.roomCount.Dec()
182 metricRooms.Dec()
183 }
184
185 ctxlog.Info(ctx, "pruned rooms", zap.Int("count", len(toRemove)))
186}
187
188func (s *Server) Stats() (rooms, clients int) {
189 s.mu.Lock()
190 defer s.mu.Unlock()
191 return len(s.rooms), int(s.clientCount.Load())
192}
193
194type Room struct {
195 Name string
196 Password string
197 ID string
198
199 ctx context.Context
200 cancel context.CancelFunc
201 clientCount *atomic.Int64
202 roomCount *atomic.Int64
203 genPlayerID *uid.Generator
204
205 mu sync.Mutex
206 room *game.Room
207 players map[game.PlayerID]noteSender
208 state *stateCache
209 lastSeen atomic.Value
210
211 timed bool
212 turnSeconds int
213 turnDeadline *time.Time
214 turnTimer *time.Timer
215
216 hideBomb bool
217}
218
219type noteSender func(protocol.ServerNote)
220
221func (r *Room) HandleConn(ctx context.Context, nickname string, c *websocket.Conn) {
222 playerID, _ := r.genPlayerID.Next()
223
224 ctx, cancel := ctxjoin.AddCancel(ctx, r.ctx)
225 defer cancel()
226
227 ctx = ctxlog.With(ctx, zap.String("roomName", r.Name), zap.String("roomID", r.ID), zap.String("playerID", playerID), zap.String("nickname", nickname))
228
229 metricClients.Inc()
230 defer metricClients.Dec()
231
232 clientCount := r.clientCount.Inc()
233 ctxlog.Info(ctx, "client connected", zap.Int64("clientCount", clientCount), zap.Int64("roomCount", r.roomCount.Load()))
234
235 defer func() {
236 clientCount := r.clientCount.Dec()
237 ctxlog.Info(ctx, "client disconnected", zap.Int64("clientCount", clientCount), zap.Int64("roomCount", r.roomCount.Load()))
238 }()
239
240 defer c.Close(websocket.StatusGoingAway, "going away")
241
242 g, ctx := errgroup.WithContext(ctx)
243
244 r.mu.Lock()
245 r.players[playerID] = func(s protocol.ServerNote) {
246 if ctx.Err() != nil {
247 return
248 }
249
250 // It's not safe to start more group goroutines concurrently; just use a regular
251 // goroutine and hope that errors here will be reflected later via ping/receive failures.
252 go func() {
253 ctx, cancel := context.WithTimeout(ctx, time.Second)
254 defer cancel()
255 if err := wsjson.Write(ctx, c, &s); err != nil {
256 return
257 }
258 metricSent.Inc()
259 }()
260 }
261 r.room.AddPlayer(playerID, nickname)
262 r.sendAll()
263 r.mu.Unlock()
264
265 defer func() {
266 r.mu.Lock()
267 defer r.mu.Unlock()
268 delete(r.players, playerID)
269 r.room.RemovePlayer(playerID)
270 r.sendAll()
271 }()
272
273 g.Go(func() error {
274 <-ctx.Done()
275 return c.Close(websocket.StatusGoingAway, "going away")
276 })
277
278 g.Go(func() error {
279 ticker := time.NewTicker(time.Minute)
280 defer ticker.Stop()
281
282 for {
283 select {
284 case <-ctx.Done():
285 return ctx.Err()
286 case <-ticker.C:
287 }
288
289 if err := c.Ping(ctx); err != nil {
290 return err
291 }
292
293 r.lastSeen.Store(time.Now())
294 }
295 })
296
297 g.Go(func() error {
298 for {
299 var note protocol.ClientNote
300
301 if err := wsjson.Read(ctx, c, ¬e); err != nil {
302 return err
303 }
304
305 ctx := ctxlog.With(ctx, zap.String("method", string(note.Method)))
306
307 r.lastSeen.Store(time.Now())
308 metricReceived.Inc()
309
310 if err := r.handleNote(ctx, playerID, ¬e); err != nil {
311 metricHandleErrors.Inc()
312 ctxlog.Error(ctx, "error handling note", zap.Error(err))
313 return err
314 }
315 }
316 })
317
318 _ = g.Wait()
319}
320
321var errMissingPlayer = errors.New("missing player during handleNote")
322
323//nolint:gocyclo
324func (r *Room) handleNote(ctx context.Context, playerID game.PlayerID, note *protocol.ClientNote) error {
325 r.mu.Lock()
326 defer r.mu.Unlock()
327
328 // The client's version was wrong; reject and send them the current state.
329 if note.Version != r.room.Version {
330 p := r.players[playerID]
331 if p == nil {
332 return errMissingPlayer
333 }
334 r.sendOne(playerID, p)
335 return nil
336 }
337
338 before := r.room.Version
339 resetTimer := false
340
341 defer func() {
342 if r.room.Version != before {
343 if r.timed && resetTimer {
344 r.startTimer()
345 }
346 r.sendAll()
347 }
348 }()
349
350 switch note.Method {
351 case protocol.RevealMethod:
352 var params protocol.RevealParams
353 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
354 return err
355 }
356 prevTurn := r.room.Turn
357 r.room.Reveal(playerID, params.Row, params.Col)
358 resetTimer = prevTurn != r.room.Turn
359
360 case protocol.NewGameMethod:
361 var params protocol.NewGameParams
362 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
363 return err
364 }
365 resetTimer = true
366 r.room.NewGame()
367
368 case protocol.EndTurnMethod:
369 var params protocol.EndTurnParams
370 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
371 return err
372 }
373 resetTimer = true
374 r.room.EndTurn(playerID)
375
376 case protocol.RandomizeTeamsMethod:
377 var params protocol.RandomizeTeamsParams
378 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
379 return err
380 }
381 r.room.RandomizeTeams()
382
383 case protocol.ChangeTeamMethod:
384 var params protocol.ChangeTeamParams
385 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
386 return err
387 }
388 r.room.ChangeTeam(playerID, params.Team)
389
390 case protocol.ChangeNicknameMethod:
391 var params protocol.ChangeNicknameParams
392 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
393 return err
394 }
395
396 // Sync with protocol.go's validation method.
397 if len(params.Nickname) == 0 || len(params.Nickname) > 16 {
398 return nil
399 }
400
401 r.room.AddPlayer(playerID, params.Nickname)
402
403 case protocol.ChangeRoleMethod:
404 var params protocol.ChangeRoleParams
405 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
406 return err
407 }
408 r.room.ChangeRole(playerID, params.Spymaster)
409
410 case protocol.ChangePackMethod:
411 var params protocol.ChangePackParams
412 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
413 return err
414 }
415 r.room.ChangePack(params.Num, params.Enable)
416
417 case protocol.ChangeTurnModeMethod:
418 var params protocol.ChangeTurnModeParams
419 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
420 return err
421 }
422 r.changeTurnMode(params.Timed)
423
424 case protocol.ChangeTurnTimeMethod:
425 var params protocol.ChangeTurnTimeParams
426 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
427 return err
428 }
429 r.changeTurnTime(params.Seconds)
430
431 case protocol.AddPacksMethod:
432 var params protocol.AddPacksParams
433 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
434 return err
435 }
436 for _, p := range params.Packs {
437 if len(p.Words) < 25 {
438 continue
439 }
440 r.room.AddPack(p.Name, p.Words)
441 }
442
443 case protocol.RemovePackMethod:
444 var params protocol.RemovePackParams
445 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
446 return err
447 }
448 r.room.RemovePack(params.Num)
449
450 case protocol.ChangeHideBombMethod:
451 var params protocol.ChangeHideBombParams
452 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
453 return err
454 }
455 r.changeHideBomb(params.HideBomb)
456
457 default:
458 ctxlog.Warn(ctx, "unhandled method")
459 }
460
461 return nil
462}
463
464// Must be called with r.mu locked.
465func (r *Room) sendAll() {
466 for playerID, sender := range r.players {
467 r.sendOne(playerID, sender)
468 }
469}
470
471// Must be called with r.mu locked.
472func (r *Room) sendOne(playerID game.PlayerID, sender noteSender) {
473 state := r.createStateFor(playerID)
474 note := protocol.NewStateNote(playerID, state)
475 sender(note)
476}
477
478// Must be called with r.mu locked.
479func (r *Room) createStateFor(playerID game.PlayerID) *protocol.RoomState {
480 if r.state == nil || r.state.version != r.room.Version {
481 r.state = r.createStateCache()
482 }
483
484 // Temporary verbose access to attempt to figure out which of these is (impossibly) failing.
485 room := r.room
486 players := room.Players
487 player := players[playerID]
488 spymaster := player.Spymaster
489
490 if spymaster {
491 return r.state.spymaster
492 }
493 return r.state.guesser
494}
495
496type stateCache struct {
497 version int
498 guesser *protocol.RoomState
499 spymaster *protocol.RoomState
500}
501
502func (r *Room) createStateCache() *stateCache {
503 return &stateCache{
504 version: r.room.Version,
505 guesser: r.createRoomState(false),
506 spymaster: r.createRoomState(true),
507 }
508}
509
510func (r *Room) createRoomState(spymaster bool) *protocol.RoomState {
511 room := r.room
512
513 s := &protocol.RoomState{
514 Version: room.Version,
515 Teams: make([][]*protocol.StatePlayer, len(room.Teams)),
516 Turn: room.Turn,
517 Winner: room.Winner,
518 Board: make([][]*protocol.StateTile, room.Board.Rows),
519 WordsLeft: room.Board.WordCounts,
520 Lists: make([]*protocol.StateWordList, len(room.WordLists)),
521 HideBomb: r.hideBomb,
522 }
523
524 if r.turnDeadline != nil {
525 s.Timer = &protocol.StateTimer{
526 TurnTime: r.turnSeconds,
527 TurnEnd: *r.turnDeadline,
528 }
529 }
530
531 for team, members := range room.Teams {
532 for _, id := range members {
533 p := room.Players[id]
534 s.Teams[team] = append(s.Teams[team], &protocol.StatePlayer{
535 PlayerID: id,
536 Nickname: p.Nickname,
537 Spymaster: p.Spymaster,
538 })
539 }
540
541 if s.Teams[team] == nil {
542 s.Teams[team] = []*protocol.StatePlayer{}
543 }
544 }
545
546 for row := range s.Board {
547 tiles := make([]*protocol.StateTile, room.Board.Cols)
548 for col := range tiles {
549 tile := room.Board.Get(row, col)
550 sTile := &protocol.StateTile{
551 Word: tile.Word,
552 Revealed: tile.Revealed,
553 }
554
555 if spymaster || tile.Revealed || room.Winner != nil {
556 view := &protocol.StateView{
557 Team: tile.Team,
558 Neutral: tile.Neutral,
559 Bomb: tile.Bomb,
560 }
561
562 if view.Bomb && !tile.Revealed && room.Winner == nil && r.hideBomb {
563 view.Neutral = true
564 view.Bomb = false
565 }
566
567 sTile.View = view
568 }
569
570 tiles[col] = sTile
571 }
572
573 s.Board[row] = tiles
574 }
575
576 for i, wl := range room.WordLists {
577 s.Lists[i] = &protocol.StateWordList{
578 Name: wl.Name,
579 Count: wl.List.Len(),
580 Custom: wl.Custom,
581 Enabled: wl.Enabled,
582 }
583 }
584
585 return s
586}
587
588// Must be called with r.mu locked.
589func (r *Room) changeTurnMode(timed bool) {
590 if r.timed == timed {
591 return
592 }
593
594 r.timed = timed
595
596 if timed {
597 r.startTimer()
598 } else {
599 r.stopTimer()
600 }
601
602 r.room.Version++
603}
604
605// Must be called with r.mu locked.
606func (r *Room) changeTurnTime(seconds int) {
607 if seconds <= 0 || r.turnSeconds == seconds {
608 return
609 }
610
611 r.turnSeconds = seconds
612
613 if r.timed {
614 r.startTimer()
615 }
616
617 r.room.Version++
618}
619
620func (r *Room) timerEndTurn() {
621 r.mu.Lock()
622 defer r.mu.Unlock()
623
624 stopped := r.stopTimer()
625 if !stopped {
626 // Room was pruned.
627 return
628 }
629
630 r.turnTimer = nil
631 r.turnDeadline = nil
632
633 if r.room.Winner != nil || r.turnSeconds == 0 {
634 return
635 }
636
637 r.room.ForceEndTurn()
638 r.startTimer()
639 r.sendAll()
640}
641
642// Must be called with r.mu locked.
643func (r *Room) stopTimer() (stopped bool) {
644 if r.turnTimer != nil {
645 r.turnTimer.Stop()
646 stopped = true
647 }
648 r.turnTimer = nil
649 r.turnDeadline = nil
650 return stopped
651}
652
653// Must be called with r.mu locked.
654func (r *Room) startTimer() {
655 if !r.timed {
656 panic("startTimer called on non-timed room")
657 }
658
659 if r.turnTimer != nil {
660 r.turnTimer.Stop()
661 }
662
663 dur := time.Second * time.Duration(r.turnSeconds)
664 deadline := time.Now().Add(dur)
665 r.turnDeadline = &deadline
666 r.turnTimer = time.AfterFunc(dur, r.timerEndTurn)
667}
668
669// Must be called with r.mu locked.
670func (r *Room) changeHideBomb(hideBomb bool) {
671 if r.hideBomb == hideBomb {
672 return
673 }
674
675 r.hideBomb = hideBomb
676 r.room.Version++
677}