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
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, &note); err != nil {
279				return err
280			}
281
282			r.lastSeen.Store(time.Now())
283
284			if err := r.handleNote(playerID, &note); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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 view.Bomb && !tile.Revealed && room.Winner == nil && r.hideBomb {
523					view.Neutral = true
524					view.Bomb = false
525				}
526
527				sTile.View = view
528			}
529
530			tiles[col] = sTile
531		}
532
533		s.Board[row] = tiles
534	}
535
536	for i, wl := range room.WordLists {
537		s.Lists[i] = &protocol.StateWordList{
538			Name:    wl.Name,
539			Count:   wl.List.Len(),
540			Custom:  wl.Custom,
541			Enabled: wl.Enabled,
542		}
543	}
544
545	return s
546}
547
548// Must be called with r.mu locked.
549func (r *Room) changeTurnMode(timed bool) {
550	if r.timed == timed {
551		return
552	}
553
554	r.timed = timed
555
556	if timed {
557		r.startTimer()
558	} else {
559		r.stopTimer()
560	}
561
562	r.room.Version++
563}
564
565// Must be called with r.mu locked.
566func (r *Room) changeTurnTime(seconds int) {
567	if seconds <= 0 || r.turnSeconds == seconds {
568		return
569	}
570
571	r.turnSeconds = seconds
572
573	if r.timed {
574		r.startTimer()
575	}
576
577	r.room.Version++
578}
579
580func (r *Room) timerEndTurn() {
581	r.mu.Lock()
582	defer r.mu.Unlock()
583
584	stopped := r.stopTimer()
585	if !stopped {
586		// Room was pruned.
587		return
588	}
589
590	r.turnTimer = nil
591	r.turnDeadline = nil
592
593	if r.room.Winner != nil || r.turnSeconds == 0 {
594		return
595	}
596
597	r.room.ForceEndTurn()
598	r.startTimer()
599	r.sendAll()
600}
601
602// Must be called with r.mu locked.
603func (r *Room) stopTimer() (stopped bool) {
604	if r.turnTimer != nil {
605		r.turnTimer.Stop()
606		stopped = true
607	}
608	r.turnTimer = nil
609	r.turnDeadline = nil
610	return stopped
611}
612
613// Must be called with r.mu locked.
614func (r *Room) startTimer() {
615	if !r.timed {
616		panic("startTimer called on non-timed room")
617	}
618
619	if r.turnTimer != nil {
620		r.turnTimer.Stop()
621	}
622
623	dur := time.Second * time.Duration(r.turnSeconds)
624	deadline := time.Now().Add(dur)
625	r.turnDeadline = &deadline
626	r.turnTimer = time.AfterFunc(dur, r.timerEndTurn)
627}
628
629// Must be called with r.mu locked.
630func (r *Room) changeHideBomb(HideBomb bool) {
631	if r.hideBomb == HideBomb {
632		return
633	}
634
635	r.hideBomb = HideBomb
636	r.room.Version++
637	r.sendAll()
638}