horsepaste/game.go
joshii 0046177494
Some checks are pending
Build horsepaste / build (push) Waiting to run
Initial commit
2026-03-15 13:36:37 +01:00

260 lines
5.5 KiB
Go

package horsepaste
import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"time"
)
const wordsPerGame = 25
type Team int
const (
Neutral Team = iota
Red
Blue
Black
)
func (t Team) String() string {
switch t {
case Red:
return "red"
case Blue:
return "blue"
case Black:
return "black"
default:
return "neutral"
}
}
func (t Team) Other() Team {
if t == Red {
return Blue
}
if t == Blue {
return Red
}
return t
}
func (t *Team) UnmarshalJSON(b []byte) error {
var s string
err := json.Unmarshal(b, &s)
if err != nil {
return err
}
switch s {
case "red":
*t = Red
case "blue":
*t = Blue
case "black":
*t = Black
default:
*t = Neutral
}
return nil
}
func (t Team) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
func (t Team) Repeat(n int) []Team {
s := make([]Team, n)
for i := 0; i < n; i++ {
s[i] = t
}
return s
}
// GameState encapsulates enough data to reconstruct
// a Game's state. It's used to recreate games after
// a process restart.
type GameState struct {
Seed int64 `json:"seed"`
PermIndex int `json:"perm_index"`
Round int `json:"round"`
Revealed []bool `json:"revealed"`
WordSet []string `json:"word_set"`
}
func (gs GameState) anyRevealed() bool {
var revealed bool
for _, r := range gs.Revealed {
revealed = revealed || r
}
return revealed
}
func randomState(words []string) GameState {
return GameState{
Seed: rand.Int63(),
PermIndex: 0,
Round: 0,
Revealed: make([]bool, wordsPerGame),
WordSet: words,
}
}
// nextGameState returns a new GameState for the next game.
func nextGameState(state GameState) GameState {
state.PermIndex = state.PermIndex + wordsPerGame
if state.PermIndex+wordsPerGame >= len(state.WordSet) {
state.Seed = rand.Int63()
state.PermIndex = 0
}
state.Revealed = make([]bool, wordsPerGame)
state.Round = 0
return state
}
type Game struct {
GameState
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartingTeam Team `json:"starting_team"`
WinningTeam *Team `json:"winning_team,omitempty"`
Words []string `json:"words"`
Layout []Team `json:"layout"`
RoundStartedAt time.Time `json:"round_started_at,omitempty"`
GameOptions
}
type GameOptions struct {
TimerDurationMS int64 `json:"timer_duration_ms,omitempty"`
EnforceTimer bool `json:"enforce_timer,omitempty"`
}
func (g *Game) StateID() string {
return fmt.Sprintf("%019d", g.UpdatedAt.UnixNano())
}
func (g *Game) checkWinningCondition() {
if g.WinningTeam != nil {
return
}
var redRemaining, blueRemaining bool
for i, t := range g.Layout {
if g.Revealed[i] {
continue
}
switch t {
case Red:
redRemaining = true
case Blue:
blueRemaining = true
}
}
if !redRemaining {
winners := Red
g.WinningTeam = &winners
}
if !blueRemaining {
winners := Blue
g.WinningTeam = &winners
}
}
func (g *Game) NextTurn(currentTurn int) bool {
if g.WinningTeam != nil {
return false
}
// TODO: remove currentTurn != 0 once we can be sure all
// clients are running up-to-date versions of the frontend.
if g.Round != currentTurn && currentTurn != 0 {
return false
}
g.UpdatedAt = time.Now()
g.Round++
g.RoundStartedAt = time.Now()
return true
}
func (g *Game) Guess(idx int) error {
if idx > len(g.Layout) || idx < 0 {
return fmt.Errorf("index %d is invalid", idx)
}
if g.Revealed[idx] {
return errors.New("cell has already been revealed")
}
g.UpdatedAt = time.Now()
g.Revealed[idx] = true
if g.Layout[idx] == Black {
winners := g.currentTeam().Other()
g.WinningTeam = &winners
return nil
}
g.checkWinningCondition()
if g.Layout[idx] != g.currentTeam() {
g.Round = g.Round + 1
g.RoundStartedAt = time.Now()
}
return nil
}
func (g *Game) currentTeam() Team {
if g.Round%2 == 0 {
return g.StartingTeam
}
return g.StartingTeam.Other()
}
func newGame(id string, state GameState, opts GameOptions) *Game {
// consistent randomness across games with the same seed
seedRnd := rand.New(rand.NewSource(state.Seed))
// distinct randomness across games with same seed
randRnd := rand.New(rand.NewSource(state.Seed * int64(state.PermIndex+1)))
game := &Game{
ID: id,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
StartingTeam: Team(randRnd.Intn(2)) + Red,
Words: make([]string, 0, wordsPerGame),
Layout: make([]Team, 0, wordsPerGame),
GameState: state,
RoundStartedAt: time.Now(),
GameOptions: opts,
}
// Pick the next `wordsPerGame` words from the
// randomly generated permutation
perm := seedRnd.Perm(len(state.WordSet))
permIndex := state.PermIndex
for _, i := range perm[permIndex : permIndex+wordsPerGame] {
w := state.WordSet[perm[i]]
game.Words = append(game.Words, w)
}
// Pick a random permutation of team assignments.
var teamAssignments []Team
teamAssignments = append(teamAssignments, Red.Repeat(8)...)
teamAssignments = append(teamAssignments, Blue.Repeat(8)...)
teamAssignments = append(teamAssignments, Neutral.Repeat(7)...)
teamAssignments = append(teamAssignments, Black)
teamAssignments = append(teamAssignments, game.StartingTeam)
shuffleCount := randRnd.Intn(5) + 5
for i := 0; i < shuffleCount; i++ {
shuffle(randRnd, teamAssignments)
}
game.Layout = teamAssignments
return game
}
func shuffle(rnd *rand.Rand, teamAssignments []Team) {
for i := range teamAssignments {
j := rnd.Intn(i + 1)
teamAssignments[i], teamAssignments[j] = teamAssignments[j], teamAssignments[i]
}
}