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