server.go

  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, &note); 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, &note); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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}