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