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