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
143 log.Printf("created new room '%s' (%s)", name, room.ID)
144
145 if s.nextID%100 == 0 {
146 s.triggerPrune()
147 }
148
149 return room, nil
150}
151
152func (s *Server) triggerPrune() {
153 select {
154 case s.doPrune <- struct{}{}:
155 default:
156 }
157}
158
159func (s *Server) prune() {
160 s.mu.Lock()
161 defer s.mu.Unlock()
162
163 toRemove := make([]string, 0, 1)
164
165 for name, room := range s.rooms {
166 lastSeen := room.lastSeen.Load().(time.Time)
167 if time.Since(lastSeen) > 10*time.Minute {
168 toRemove = append(toRemove, name)
169 }
170 }
171
172 if len(toRemove) == 0 {
173 return
174 }
175
176 for _, name := range toRemove {
177 room := s.rooms[name]
178 room.mu.Lock()
179 room.stopTimer()
180 room.mu.Unlock()
181
182 room.cancel()
183 delete(s.rooms, name)
184 delete(s.roomIDs, room.ID)
185 s.roomCount.Dec()
186 }
187
188 log.Printf("pruned %d rooms", len(toRemove))
189}
190
191func (s *Server) Stats() (rooms, clients int) {
192 s.mu.Lock()
193 defer s.mu.Unlock()
194 return len(s.rooms), int(s.clientCount.Load())
195}
196
197type Room struct {
198 Name string
199 Password string
200 ID string
201
202 ctx context.Context
203 cancel context.CancelFunc
204 clientCount *atomic.Int64
205 roomCount *atomic.Int64
206
207 mu sync.Mutex
208 room *game.Room
209 players map[game.PlayerID]noteSender
210 state *stateCache
211 lastSeen atomic.Value
212
213 timed bool
214 turnSeconds int
215 turnDeadline *time.Time
216 turnTimer *time.Timer
217
218 hideBomb bool
219}
220
221type noteSender func(protocol.ServerNote)
222
223func (r *Room) HandleConn(playerID uuid.UUID, nickname string, c *websocket.Conn) {
224 clientCount := r.clientCount.Inc()
225 log.Printf("client connected to room '%s' (%s); %v clients currently connected to %v rooms", r.Name, r.ID, clientCount, r.roomCount.Load())
226
227 defer func() {
228 clientCount := r.clientCount.Dec()
229 log.Printf("client disconnected from room '%s' (%s); %v clients currently connected to %v rooms", r.Name, r.ID, clientCount, r.roomCount.Load())
230 }()
231
232 defer c.Close(websocket.StatusGoingAway, "going away")
233 g, ctx := errgroup.WithContext(r.ctx)
234
235 r.mu.Lock()
236 r.players[playerID] = func(s protocol.ServerNote) {
237 g.Go(func() error {
238 ctx, cancel := context.WithTimeout(ctx, time.Second)
239 defer cancel()
240 return wsjson.Write(ctx, c, &s)
241 })
242 }
243 r.room.AddPlayer(playerID, nickname)
244 r.sendAll()
245 r.mu.Unlock()
246
247 defer func() {
248 r.mu.Lock()
249 defer r.mu.Unlock()
250 delete(r.players, playerID)
251 r.room.RemovePlayer(playerID)
252 r.sendAll()
253 }()
254
255 g.Go(func() error {
256 ticker := time.NewTicker(time.Minute)
257 defer ticker.Stop()
258
259 for {
260 select {
261 case <-ctx.Done():
262 return ctx.Err()
263 case <-ticker.C:
264 }
265
266 if err := c.Ping(ctx); err != nil {
267 return err
268 }
269
270 r.lastSeen.Store(time.Now())
271 }
272 })
273
274 g.Go(func() error {
275 for {
276 var note protocol.ClientNote
277
278 if err := wsjson.Read(ctx, c, ¬e); err != nil {
279 return err
280 }
281
282 r.lastSeen.Store(time.Now())
283
284 if err := r.handleNote(playerID, ¬e); err != nil {
285 log.Println("error handling note:", err)
286 return err
287 }
288 }
289 })
290
291 _ = g.Wait()
292}
293
294func (r *Room) handleNote(playerID game.PlayerID, note *protocol.ClientNote) error {
295 r.mu.Lock()
296 defer r.mu.Unlock()
297
298 // The client's version was wrong; reject and send them the current state.
299 if note.Version != r.room.Version {
300 r.sendOne(playerID, r.players[playerID])
301 return nil
302 }
303
304 before := r.room.Version
305 resetTimer := false
306
307 defer func() {
308 if r.room.Version != before {
309 if r.timed && resetTimer {
310 r.startTimer()
311 }
312 r.sendAll()
313 }
314 }()
315
316 switch note.Method {
317 case protocol.RevealMethod:
318 var params protocol.RevealParams
319 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
320 return err
321 }
322 prevTurn := r.room.Turn
323 r.room.Reveal(playerID, params.Row, params.Col)
324 resetTimer = prevTurn != r.room.Turn
325
326 case protocol.NewGameMethod:
327 var params protocol.NewGameParams
328 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
329 return err
330 }
331 resetTimer = true
332 r.room.NewGame()
333
334 case protocol.EndTurnMethod:
335 var params protocol.EndTurnParams
336 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
337 return err
338 }
339 resetTimer = true
340 r.room.EndTurn(playerID)
341
342 case protocol.RandomizeTeamsMethod:
343 var params protocol.RandomizeTeamsParams
344 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
345 return err
346 }
347 r.room.RandomizeTeams()
348
349 case protocol.ChangeTeamMethod:
350 var params protocol.ChangeTeamParams
351 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
352 return err
353 }
354 r.room.ChangeTeam(playerID, params.Team)
355
356 case protocol.ChangeNicknameMethod:
357 var params protocol.ChangeNicknameParams
358 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
359 return err
360 }
361
362 // Sync with protocol.go's validation method.
363 if len(params.Nickname) < 3 || len(params.Nickname) > 16 {
364 return nil
365 }
366
367 r.room.AddPlayer(playerID, params.Nickname)
368
369 case protocol.ChangeRoleMethod:
370 var params protocol.ChangeRoleParams
371 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
372 return err
373 }
374 r.room.ChangeRole(playerID, params.Spymaster)
375
376 case protocol.ChangePackMethod:
377 var params protocol.ChangePackParams
378 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
379 return err
380 }
381 r.room.ChangePack(params.Num, params.Enable)
382
383 case protocol.ChangeTurnModeMethod:
384 var params protocol.ChangeTurnModeParams
385 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
386 return err
387 }
388 r.changeTurnMode(params.Timed)
389
390 case protocol.ChangeTurnTimeMethod:
391 var params protocol.ChangeTurnTimeParams
392 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
393 return err
394 }
395 r.changeTurnTime(params.Seconds)
396
397 case protocol.AddPacksMethod:
398 var params protocol.AddPacksParams
399 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
400 return err
401 }
402 for _, p := range params.Packs {
403 if len(p.Words) < 25 {
404 continue
405 }
406 r.room.AddPack(p.Name, p.Words)
407 }
408
409 case protocol.RemovePackMethod:
410 var params protocol.RemovePackParams
411 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
412 return err
413 }
414 r.room.RemovePack(params.Num)
415
416 case protocol.ChangeHideBombMethod:
417 var params protocol.ChangeHideBombParams
418 if err := json.Unmarshal(note.Params, ¶ms); err != nil {
419 return err
420 }
421 r.changeHideBomb(params.HideBomb)
422
423 default:
424 log.Printf("unhandled method: %s", note.Method)
425 }
426
427 return nil
428}
429
430// Must be called with r.mu locked.
431func (r *Room) sendAll() {
432 for playerID, sender := range r.players {
433 r.sendOne(playerID, sender)
434 }
435}
436
437// Must be called with r.mu locked.
438func (r *Room) sendOne(playerID game.PlayerID, sender noteSender) {
439 state := r.createStateFor(playerID)
440 note := protocol.StateNote(state)
441 sender(note)
442}
443
444// Must be called with r.mu locked.
445func (r *Room) createStateFor(playerID game.PlayerID) *protocol.State {
446 if r.state == nil || r.state.version != r.room.Version {
447 r.state = r.createStateCache()
448 }
449
450 if r.room.Players[playerID].Spymaster {
451 return r.state.spymaster
452 }
453 return r.state.guesser
454}
455
456type stateCache struct {
457 version int
458 guesser *protocol.State
459 spymaster *protocol.State
460}
461
462func (r *Room) createStateCache() *stateCache {
463 return &stateCache{
464 version: r.room.Version,
465 guesser: r.createRoomState(false),
466 spymaster: r.createRoomState(true),
467 }
468}
469
470func (r *Room) createRoomState(spymaster bool) *protocol.State {
471 room := r.room
472
473 s := &protocol.State{
474 Version: room.Version,
475 Teams: make([][]*protocol.StatePlayer, len(room.Teams)),
476 Turn: room.Turn,
477 Winner: room.Winner,
478 Board: make([][]*protocol.StateTile, room.Board.Rows),
479 WordsLeft: room.Board.WordCounts,
480 Lists: make([]*protocol.StateWordList, len(room.WordLists)),
481 HideBomb: r.hideBomb,
482 }
483
484 if r.turnDeadline != nil {
485 s.Timer = &protocol.StateTimer{
486 TurnTime: r.turnSeconds,
487 TurnEnd: *r.turnDeadline,
488 }
489 }
490
491 for team, members := range room.Teams {
492 for _, id := range members {
493 p := room.Players[id]
494 s.Teams[team] = append(s.Teams[team], &protocol.StatePlayer{
495 PlayerID: id,
496 Nickname: p.Nickname,
497 Spymaster: p.Spymaster,
498 })
499 }
500
501 if s.Teams[team] == nil {
502 s.Teams[team] = []*protocol.StatePlayer{}
503 }
504 }
505
506 for row := range s.Board {
507 tiles := make([]*protocol.StateTile, room.Board.Cols)
508 for col := range tiles {
509 tile := room.Board.Get(row, col)
510 sTile := &protocol.StateTile{
511 Word: tile.Word,
512 Revealed: tile.Revealed,
513 }
514
515 if spymaster || tile.Revealed || room.Winner != nil {
516 view := &protocol.StateView{
517 Team: tile.Team,
518 Neutral: tile.Neutral,
519 Bomb: tile.Bomb,
520 }
521
522 if !tile.Revealed && room.Winner != nil && r.hideBomb {
523 view.Bomb = false
524 }
525
526 sTile.View = view
527 }
528
529 tiles[col] = sTile
530 }
531
532 s.Board[row] = tiles
533 }
534
535 for i, wl := range room.WordLists {
536 s.Lists[i] = &protocol.StateWordList{
537 Name: wl.Name,
538 Count: wl.List.Len(),
539 Custom: wl.Custom,
540 Enabled: wl.Enabled,
541 }
542 }
543
544 return s
545}
546
547// Must be called with r.mu locked.
548func (r *Room) changeTurnMode(timed bool) {
549 if r.timed == timed {
550 return
551 }
552
553 r.timed = timed
554
555 if timed {
556 r.startTimer()
557 } else {
558 r.stopTimer()
559 }
560
561 r.room.Version++
562}
563
564// Must be called with r.mu locked.
565func (r *Room) changeTurnTime(seconds int) {
566 if seconds <= 0 || r.turnSeconds == seconds {
567 return
568 }
569
570 r.turnSeconds = seconds
571
572 if r.timed {
573 r.startTimer()
574 }
575
576 r.room.Version++
577}
578
579func (r *Room) timerEndTurn() {
580 r.mu.Lock()
581 defer r.mu.Unlock()
582
583 stopped := r.stopTimer()
584 if !stopped {
585 // Room was pruned.
586 return
587 }
588
589 r.turnTimer = nil
590 r.turnDeadline = nil
591
592 if r.room.Winner != nil || r.turnSeconds == 0 {
593 return
594 }
595
596 r.room.ForceEndTurn()
597 r.startTimer()
598 r.sendAll()
599}
600
601// Must be called with r.mu locked.
602func (r *Room) stopTimer() (stopped bool) {
603 if r.turnTimer != nil {
604 r.turnTimer.Stop()
605 stopped = true
606 }
607 r.turnTimer = nil
608 r.turnDeadline = nil
609 return stopped
610}
611
612// Must be called with r.mu locked.
613func (r *Room) startTimer() {
614 if !r.timed {
615 panic("startTimer called on non-timed room")
616 }
617
618 if r.turnTimer != nil {
619 r.turnTimer.Stop()
620 }
621
622 dur := time.Second * time.Duration(r.turnSeconds)
623 deadline := time.Now().Add(dur)
624 r.turnDeadline = &deadline
625 r.turnTimer = time.AfterFunc(dur, r.timerEndTurn)
626}
627
628// Must be called with r.mu locked.
629func (r *Room) changeHideBomb(HideBomb bool) {
630 if r.hideBomb == HideBomb {
631 return
632 }
633
634 r.hideBomb = true
635 r.room.Version++
636 r.sendAll()
637}