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