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
323func (r *Room) handleNote(ctx context.Context, playerID game.PlayerID, note *protocol.ClientNote) error {
324 r.mu.Lock()
325 defer r.mu.Unlock()
326
327 // The client's version was wrong; reject and send them the current state.
328 if note.Version != r.room.Version {
329 p := r.players[playerID]
330 if p == nil {
331 return errMissingPlayer
332 }
333 r.sendOne(playerID, p)
334 return nil
335 }
336
337 before := r.room.Version
338 resetTimer := false
339
340 defer func() {
341 if r.room.Version != before {
342 if r.timed && resetTimer {
343 r.startTimer()
344 }
345 r.sendAll()
346 }
347 }()
348
349 switch note.Method {
350 case protocol.RevealMethod:
351 var params protocol.RevealParams
352 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
353 return err
354 }
355 prevTurn := r.room.Turn
356 r.room.Reveal(playerID, params.Row, params.Col)
357 resetTimer = prevTurn != r.room.Turn
358
359 case protocol.NewGameMethod:
360 var params protocol.NewGameParams
361 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
362 return err
363 }
364 resetTimer = true
365 r.room.NewGame()
366
367 case protocol.EndTurnMethod:
368 var params protocol.EndTurnParams
369 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
370 return err
371 }
372 resetTimer = true
373 r.room.EndTurn(playerID)
374
375 case protocol.RandomizeTeamsMethod:
376 var params protocol.RandomizeTeamsParams
377 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
378 return err
379 }
380 r.room.RandomizeTeams()
381
382 case protocol.ChangeTeamMethod:
383 var params protocol.ChangeTeamParams
384 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
385 return err
386 }
387 r.room.ChangeTeam(playerID, params.Team)
388
389 case protocol.ChangeNicknameMethod:
390 var params protocol.ChangeNicknameParams
391 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
392 return err
393 }
394
395 // Sync with protocol.go's validation method.
396 if len(params.Nickname) == 0 || len(params.Nickname) > 16 {
397 return nil
398 }
399
400 r.room.AddPlayer(playerID, params.Nickname)
401
402 case protocol.ChangeRoleMethod:
403 var params protocol.ChangeRoleParams
404 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
405 return err
406 }
407 r.room.ChangeRole(playerID, params.Spymaster)
408
409 case protocol.ChangePackMethod:
410 var params protocol.ChangePackParams
411 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
412 return err
413 }
414 r.room.ChangePack(params.Num, params.Enable)
415
416 case protocol.ChangeTurnModeMethod:
417 var params protocol.ChangeTurnModeParams
418 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
419 return err
420 }
421 r.changeTurnMode(params.Timed)
422
423 case protocol.ChangeTurnTimeMethod:
424 var params protocol.ChangeTurnTimeParams
425 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
426 return err
427 }
428 r.changeTurnTime(params.Seconds)
429
430 case protocol.AddPacksMethod:
431 var params protocol.AddPacksParams
432 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
433 return err
434 }
435 for _, p := range params.Packs {
436 if len(p.Words) < 25 {
437 continue
438 }
439 r.room.AddPack(p.Name, p.Words)
440 }
441
442 case protocol.RemovePackMethod:
443 var params protocol.RemovePackParams
444 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
445 return err
446 }
447 r.room.RemovePack(params.Num)
448
449 case protocol.ChangeHideBombMethod:
450 var params protocol.ChangeHideBombParams
451 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
452 return err
453 }
454 r.changeHideBomb(params.HideBomb)
455
456 default:
457 ctxlog.Warn(ctx, "unhandled method")
458 }
459
460 return nil
461}
462
463// Must be called with r.mu locked.
464func (r *Room) sendAll() {
465 for playerID, sender := range r.players {
466 r.sendOne(playerID, sender)
467 }
468}
469
470// Must be called with r.mu locked.
471func (r *Room) sendOne(playerID game.PlayerID, sender noteSender) {
472 state := r.createStateFor(playerID)
473 note := protocol.NewStateNote(playerID, state)
474 sender(note)
475}
476
477// Must be called with r.mu locked.
478func (r *Room) createStateFor(playerID game.PlayerID) *protocol.RoomState {
479 if r.state == nil || r.state.version != r.room.Version {
480 r.state = r.createStateCache()
481 }
482
483 // Temporary verbose access to attempt to figure out which of these is (impossibly) failing.
484 room := r.room
485 players := room.Players
486 player := players[playerID]
487 spymaster := player.Spymaster
488
489 if spymaster {
490 return r.state.spymaster
491 }
492 return r.state.guesser
493}
494
495type stateCache struct {
496 version int
497 guesser *protocol.RoomState
498 spymaster *protocol.RoomState
499}
500
501func (r *Room) createStateCache() *stateCache {
502 return &stateCache{
503 version: r.room.Version,
504 guesser: r.createRoomState(false),
505 spymaster: r.createRoomState(true),
506 }
507}
508
509func (r *Room) createRoomState(spymaster bool) *protocol.RoomState {
510 room := r.room
511
512 s := &protocol.RoomState{
513 Version: room.Version,
514 Teams: make([][]*protocol.StatePlayer, len(room.Teams)),
515 Turn: room.Turn,
516 Winner: room.Winner,
517 Board: make([][]*protocol.StateTile, room.Board.Rows),
518 WordsLeft: room.Board.WordCounts,
519 Lists: make([]*protocol.StateWordList, len(room.WordLists)),
520 HideBomb: r.hideBomb,
521 }
522
523 if r.turnDeadline != nil {
524 s.Timer = &protocol.StateTimer{
525 TurnTime: r.turnSeconds,
526 TurnEnd: *r.turnDeadline,
527 }
528 }
529
530 for team, members := range room.Teams {
531 for _, id := range members {
532 p := room.Players[id]
533 s.Teams[team] = append(s.Teams[team], &protocol.StatePlayer{
534 PlayerID: id,
535 Nickname: p.Nickname,
536 Spymaster: p.Spymaster,
537 })
538 }
539
540 if s.Teams[team] == nil {
541 s.Teams[team] = []*protocol.StatePlayer{}
542 }
543 }
544
545 for row := range s.Board {
546 tiles := make([]*protocol.StateTile, room.Board.Cols)
547 for col := range tiles {
548 tile := room.Board.Get(row, col)
549 sTile := &protocol.StateTile{
550 Word: tile.Word,
551 Revealed: tile.Revealed,
552 }
553
554 if spymaster || tile.Revealed || room.Winner != nil {
555 view := &protocol.StateView{
556 Team: tile.Team,
557 Neutral: tile.Neutral,
558 Bomb: tile.Bomb,
559 }
560
561 if view.Bomb && !tile.Revealed && room.Winner == nil && r.hideBomb {
562 view.Neutral = true
563 view.Bomb = false
564 }
565
566 sTile.View = view
567 }
568
569 tiles[col] = sTile
570 }
571
572 s.Board[row] = tiles
573 }
574
575 for i, wl := range room.WordLists {
576 s.Lists[i] = &protocol.StateWordList{
577 Name: wl.Name,
578 Count: wl.List.Len(),
579 Custom: wl.Custom,
580 Enabled: wl.Enabled,
581 }
582 }
583
584 return s
585}
586
587// Must be called with r.mu locked.
588func (r *Room) changeTurnMode(timed bool) {
589 if r.timed == timed {
590 return
591 }
592
593 r.timed = timed
594
595 if timed {
596 r.startTimer()
597 } else {
598 r.stopTimer()
599 }
600
601 r.room.Version++
602}
603
604// Must be called with r.mu locked.
605func (r *Room) changeTurnTime(seconds int) {
606 if seconds <= 0 || r.turnSeconds == seconds {
607 return
608 }
609
610 r.turnSeconds = seconds
611
612 if r.timed {
613 r.startTimer()
614 }
615
616 r.room.Version++
617}
618
619func (r *Room) timerEndTurn() {
620 r.mu.Lock()
621 defer r.mu.Unlock()
622
623 stopped := r.stopTimer()
624 if !stopped {
625 // Room was pruned.
626 return
627 }
628
629 r.turnTimer = nil
630 r.turnDeadline = nil
631
632 if r.room.Winner != nil || r.turnSeconds == 0 {
633 return
634 }
635
636 r.room.ForceEndTurn()
637 r.startTimer()
638 r.sendAll()
639}
640
641// Must be called with r.mu locked.
642func (r *Room) stopTimer() (stopped bool) {
643 if r.turnTimer != nil {
644 r.turnTimer.Stop()
645 stopped = true
646 }
647 r.turnTimer = nil
648 r.turnDeadline = nil
649 return stopped
650}
651
652// Must be called with r.mu locked.
653func (r *Room) startTimer() {
654 if !r.timed {
655 panic("startTimer called on non-timed room")
656 }
657
658 if r.turnTimer != nil {
659 r.turnTimer.Stop()
660 }
661
662 dur := time.Second * time.Duration(r.turnSeconds)
663 deadline := time.Now().Add(dur)
664 r.turnDeadline = &deadline
665 r.turnTimer = time.AfterFunc(dur, r.timerEndTurn)
666}
667
668// Must be called with r.mu locked.
669func (r *Room) changeHideBomb(HideBomb bool) {
670 if r.hideBomb == HideBomb {
671 return
672 }
673
674 r.hideBomb = HideBomb
675 r.room.Version++
676}