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