server.go

  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, &note); err != nil {
302				return err
303			}
304
305			r.lastSeen.Store(time.Now())
306			metricReceived.Inc()
307
308			if err := r.handleNote(playerID, &note); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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}