hiddenrole

package module
v0.0.0-...-818069b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 7 Imported by: 0

README

hiddenrole

English · 中文

A kernel for social deduction games, pure Go, zero dependencies. It does not know what werewolf is.

Go Reference License

go get github.com/Zereker/hiddenrole

What it does know: there are players, there is a cycle of phases, at the end of each phase it asks that phase's resolver what happened, and it folds the answer into the state. Plus the hardest part of these games -- who is allowed to know what.

Roles, skills, ways to die, victory, the information boundary: all of it is installed by a rules package through public options.

"It really does not know" is checkable

In this package's non-test source there are exactly two values of RoleType (RoleUnspecified, RoleSystem), three of PhaseType and three of SkillType, all of them in types.go. Not one "witch", "werewolf" or "NIGHT_WITCH" anywhere.

The harder evidence is three unrelated rules packages running on it, no two of which share a single value:

Rules package What it plays What it proves
werewolf werewolf elimination is the core mechanic, eight phases in a cycle
missions mission-based play (nominate / vote / mission / assassinate) it runs with nobody ever eliminated; transitions are decided by resolution results
onenight one-night card swapping identity has two layers: the card dealt decides what you do at night, the card in hand decides which side you score for

Writing the third one forced zero breaking API changes -- the API is frozen, guarded by TestAPI_SurfaceIsPinned and testdata/api.golden: change a name or a signature and the test goes red.

Where to start reading

To find out Read
which APIs exist and what each promises API.md 🔒 frozen
what it should look like, and why so abstract DESIGN.md
how the code is organised today ARCHITECTURE.md
how others did it, where we are ahead and where we are behind PRIOR-ART.md
what writing a rules package ran into missions · onenight
how to play Werewolf with this example/werewolf/README.md (Chinese) · English overview

What is in this repository

.                     the kernel: types.go, engine.go, phase.go, view.go ...
├── enginetest/       random games and seven general invariants, for your own rules package
├── example/          three rules packages, all peers, all on the public API only
│   ├── werewolf/     Werewolf, the Chinese ruleset
│   │   ├── demo/       runnable: every interface demonstrated
│   │   ├── cli/        runnable: a host console, playable start to finish
│   │   ├── netserver/  runnable: a TCP server (push, concurrency, reconnect)
│   │   └── extension/  runnable: a third-party role the engine did not plan for
│   ├── missions/     The Resistance and its Avalon variant
│   └── onenight/     One Night Ultimate Werewolf
└── docs/ROADMAP.md   how this got here (archived)

The kernel and the three games are four separate packages in one module. That the games use only the public API is enforced by the compiler either way -- Go does not let one package reach into another's unexported names -- and the kernel has no internal/ at all, so every entry point example/ uses is one you can use too.

When writing your own rules package, enginetest gives you random games and seven general invariants (RunFuzz). Not one of them knows any game; they check things at the kernel's level: does what was stored read back the same, does replay arrive at the same board, is somebody the engine says cannot act really unable to act.

A state machine that knows nothing

The engine NewEngine builds can advance phases, but will never decide a winner, recognises no role, and draws no information boundary. Below is a complete ruleset that fits on two pages: red team and blue team, one public vote per round, most votes is eliminated, one side wiped out ends it.

const (
	phaseVote = hiddenrole.PhaseType("VOTE")
	roleRed   = hiddenrole.RoleType("RED")
	roleBlue  = hiddenrole.RoleType("BLUE")
	skillVote = hiddenrole.SkillType("VOTE")
	eventOut  = hiddenrole.EventType("OUT")
	campRed   = hiddenrole.Camp("RED")
	campBlue  = hiddenrole.Camp("BLUE")
)

// What happens when this phase ends. Reads GameView only, returns Effects only.
type vote struct{}

func (vote) Resolve(uses []*hiddenrole.SkillUse, _ hiddenrole.GameView) []*hiddenrole.Effect {
	tally := map[string]int{}
	for _, u := range uses {
		if u.Skill == skillVote {
			tally[u.Target()]++
		}
	}
	out, best := "", 0
	for id, n := range tally {
		if n > best || (n == best && id < out) { // the order must be decided by the board alone
			out, best = id, n
		}
	}
	if out == "" {
		return nil
	}
	return []*hiddenrole.Effect{
		hiddenrole.NewEffect(eventOut, "", out),  // the rules' name for what happened
		hiddenrole.NewSetAliveEffect(out, false), // the one that actually changes state
	}
}

// One side wiped out ends it.
type lastSideStanding struct{}

func (lastSideStanding) CheckVictory(view hiddenrole.GameView) (bool, hiddenrole.Camp) {
	red, blue := 0, 0
	for _, p := range view.AlivePlayers() {
		if p.Role == roleRed {
			red++
		} else {
			blue++
		}
	}
	switch {
	case blue == 0:
		return true, campRed
	case red == 0:
		return true, campBlue
	}
	return false, hiddenrole.CampUnspecified
}

func main() {
	cfg := &hiddenrole.Config{
		StartPhase: phaseVote,
		Phases: map[hiddenrole.PhaseType]*hiddenrole.PhaseConfig{
			phaseVote: {
				Type: phaseVote,
				Steps: []hiddenrole.PhaseStep{
					{Role: roleRed, Skill: skillVote, Required: true, Multiple: true},
					{Role: roleBlue, Skill: skillVote, Required: true, Multiple: true},
				},
				NextPhase:       phaseVote, // a cycle: back to itself
				EndsRound:       true,      // this phase ending is one round
				ClearsRoundVars: true,      // and it begins from a clean board
			},
		},
	}

	e := hiddenrole.MustNewEngine(cfg,
		hiddenrole.WithResolver(phaseVote, vote{}),
		hiddenrole.WithVictoryChecker(lastSideStanding{}))

	_ = e.AddPlayer("r1", roleRed)
	_ = e.AddPlayer("r2", roleRed)
	_ = e.AddPlayer("b1", roleBlue)
	_ = e.Start()

	for _, id := range []string{"r1", "r2", "b1"} {
		_ = e.SubmitSkillUse(&hiddenrole.SkillUse{PlayerID: id, Skill: skillVote, Targets: []string{"b1"}})
	}
	effects, _ := e.EndPhase()
	for _, ef := range effects {
		fmt.Println(ef.Type, ef.TargetID) // OUT b1 / SET_ALIVE b1 / GAME_ENDED
	}
	st := e.Status()
	fmt.Println("over:", st.Over, "winner:", st.Winner) // true RED
}

Leave out WithVictoryChecker and the game never ends; leave out WithResolver and Start() returns an error. The kernel knows nothing is something you can verify this way, not a slogan.

The single write point

SubmitSkillUse  ->  Resolver.Resolve  ->  []*Effect  ->  applyEffect
collect skills      judge (pure func)   describe changes   the only writer

A Resolver is handed a read-only GameView and can express a state change only by returning an Effect. The constraint is held up by the signature rather than by convention: every change to state goes through one write point, which is what makes snapshots, replay and auditing possible at all.

Resolve and CheckVictory are both called while the engine holds its lock, so an implementation must not call back into any Engine method. The order of the effects returned must be decided by the board alone (which is what the id < out above is for), or replay and snapshot comparison lose their determinism.

The two primitives the state machine recognises

Constructor Changes Read back with
NewSetAliveEffect(id, alive) aliveness GameView.Player(id).Alive
NewSetVarEffect(scope, k, v) one piece of custom state GameView.Var(scope, k)

A scope is a 2x2 table -- lifetime crossed with ownership -- and the four cells fall out of two values crossed with one method (see VarScope):

unowned owned by a player
whole game ScopeGame ScopeGame.Of(id)
this round ScopeRound ScopeRound.Of(id)

The table used to exist only in a comment, and the code had eight flat names (four constructors and four readers) -- so nothing forced it to be complete, and "whole game, unowned" was missing for a long time before the mission-based rules ran into it. A missing cell is now not expressible.

There is one more, NewDetourEffect(id, phase), which files a debt (the hunter's shot after being killed is exactly this).

Variable values are strings, and an empty string is equivalent to deletion at the write point, so a has-it/hasn't-it state needs nothing more than one non-empty value (VarPresent by convention).

"Wolf kill", "exile" and "shoot" are the rules' names for what happened, and the state machine does not recognise them -- a KILL effect on its own kills nobody. For the rules to eliminate someone, they emit a SET_ALIVE alongside it. Two effects, two things: the first for the audience and the effect log, the second for the state machine. OUT and SET_ALIVE appearing as a pair in the example above is exactly this.

Who is allowed to know what

e.PlayerView(id)      // everything one player is entitled to know, sendable as-is
e.AudienceOf(event)   // which players should be told about something

The rules draw the lines: AudienceProvider (who should be told about something), TeammateProvider (who is on whose side, asymmetry allowed) and SpeechProvider (who hears a player speak).

At this layer the kernel holds one line, and it is not configurable: its own state primitives never leave the building. They are the state machine's bookkeeping, and pushing them to a player is handing out the god's view.

The player-facing PlayerView / AudienceOf and the god's-view PhaseInfo / PlayerInfo are two different sets of readers; do not mix them up. The first can be sent to a player, the second cannot.

Eight extension points

To add Use
how a phase resolves WithResolver(phase, resolver)
what a role sits down with WithRoleSetup(role, setup), written into that player's Vars
how winning works WithVictoryChecker(checker)
role-specific information WithRoleInfo(role, provider), appears in PlayerView.RoleInfo
who should be told about something WithAudience(provider)
who is on whose side WithTeammates(provider)
who hears a player speak WithSpeech(provider)
logging WithLogger(l)

Plus two that are not options: a state change during play goes through an Effect primitive, and a host-level state change goes through Engine.Apply (the same single write point, but bypassing phase resolution -- a sharp knife).

All eight can be installed with a plain function: ResolverFunc / VictoryFunc / RoleSetupFunc / GameSetupFunc / RoleInfoFunc / AudienceFunc / TeammateFunc / SpeechFunc. The first two were added later -- they were the only two without an adapter, for no reason but history, which meant installing a three-line resolver first required declaring an empty struct.

All of them can only be given at construction: once the engine is in the caller's hands, they no longer change. All four entry points accept them -- NewEngine, MustNewEngine, RestoreEngine and ReplayEngine.

Two decisions the kernel refuses to make for the rules

Two decisions about how a game proceeds have answers only the rules know:

Decision Who decides
which phase comes next PhaseConfig.NextPhase is the default exit; the rules can override it during resolution with NewGotoPhaseEffect
whether a new round begins after this step declared by PhaseConfig.EndsRound

The kernel used to decide both: the exit came from a static graph, and the round boundary was guessed as "looping back to the start phase counts". In werewolf both guesses happen to hold (night -> day -> night); in another ruleset they do not.

The test is one sentence: can the kernel judge this correctly without knowing what game it is? "Did the state change" it can judge, so that belongs to the kernel; "is this a new round" it cannot, so that belongs to the rules.

// Go to the mission if the vote passed, back to nomination otherwise -- the
// outcome is computed by this phase's resolution, and a static graph cannot
// express it.
if approved {
	effects = append(effects, hiddenrole.NewGotoPhaseEffect(phaseMission))
} else {
	effects = append(effects, hiddenrole.NewGotoPhaseEffect(phasePropose))
}

Exit priority: a pending detour queue > GOTO_PHASE > NextPhase. Detours come first because the queue has to drain -- the victory check and the round boundary are both waiting on it, and jumping away mid-queue would drop a death ability that was never settled. A destination absent from the configuration is logged as an error and falls back to the default exit.

Who may act in this phase

Two layers, highest priority first:

Who
the players the rules named NewSetActorsEffect(phase, ids...), or the list a death detour writes on entering the phase. Aliveness is the rules' business, and the kernel does not veto a second time
the default the living players matching PhaseStep.Role

Skill validation, AllowedSkills, PhaseReadiness and PhaseInfo all share the single actorsForStep read point -- four questions with one source is what keeps "the kernel accepted his submission while telling everyone else he should not be acting" from arising.

A detour (NewDetourEffect) used to be a third layer here, answering the same question as naming with a nearly word-for-word identical implementation. It no longer answers "who may act": on entering the phase it is owed in, the kernel writes the head of the queue as that phase's actor list, and everything after that follows the naming path. It is written on entering the phase rather than at the effect's write point because the queue may hold several detours pointing at the same phase (two hunters eliminated on one night), and writing at the effect would have them overwrite each other, leaving only the last one able to act.

Extension points must not call back into the engine

All eight extension points are called synchronously while the engine holds its lock. Calling any Engine method from inside one hangs, it does not error -- Go's RWMutex is not reentrant, and that game stops responding for good.

They do not need to call back: everything they could want is in the arguments. The signatures are deliberately narrow, an extension point never receives an *Engine, and getting around the constraint means stashing the engine in a struct yourself, which is a deliberate act.

To ask the engine something from a callback, use an OnEvent / OnMessage handler -- events and messages are both published outside the lock:

e.OnEvent(func(ev *hiddenrole.Event) {
	audience, known := e.AudienceOf(ev) // safe: no lock is held here
	if !known {
		return // a third-party event type the engine does not know; route it yourself, do not broadcast by default
	}
	for _, id := range audience {
		send(id, ev)
	}
})

Wiring the engine into a server is exactly this; see example/werewolf/netserver in the werewolf repository.

Unit-testing your own resolver

No need to run a whole game. Board lets you lay one out by hand:

b := hiddenrole.Board{
	Players: []hiddenrole.PlayerInfo{
		hiddenrole.Seat("r1", roleRed, true),
		hiddenrole.Seat("b1", roleBlue, true),
	},
	Round: 1,
	Phase: phaseVote,
}

effects := vote{}.Resolve([]*hiddenrole.SkillUse{
	{PlayerID: "r1", Skill: skillVote, Targets: []string{"b1"}},
}, b.View())

after := b.Apply(effects)          // fold the effects back in
p, _ := after.Player("b1")
// p.Alive == false

Seat(id, role, alive, vars...) places a player, Mark(p, keys...) puts this round's markers on them, and Board.Var(scope, k) reads any one of the four cells.

Saving, replay and errors

snap := e.Snapshot()                                   // plain data, json.Marshal it directly
e2, err := hiddenrole.RestoreEngine(cfg, snap, opts...) // the options must match those used to create the game

log := e.EffectLog()                                   // the complete effect log since the game was created
e3, err := hiddenrole.ReplayEngine(cfg, log, opts...)  // rebuild from the log

The effect log is history, a snapshot is state: persist with Snapshot, and use EffectLog for in-process replay, post-game analysis and investigation. A snapshot carries a version (SnapshotVersion), and a format it does not understand is explicitly rejected rather than guessed at.

Errors all carry a code, and both errors.Is and HasCode classify them:

if err := e.SubmitSkillUse(use); err != nil {
	switch {
	case errors.Is(err, hiddenrole.ErrPlayerDead):
		...
	case hiddenrole.HasCode(err, hiddenrole.CodeSkillNotAllowed):
		...
	}
}

Report your own rules' errors with WrapError(code, format, args...), the same machinery the kernel uses.

What the kernel does not do

  • It keeps no clock. PhaseConfig.Timeout is advice, when EndPhase is called is entirely up to the caller, and PhaseReadiness() tells you who is still missing.
  • No networking, no lobbies, no matchmaking.
  • No storage. Snapshot exports the board and RestoreEngine rebuilds it; where it is stored is the user's business.
  • It knows no game's rules. That is a rules package's job.

The full API

go doc github.com/Zereker/hiddenrole

The package documentation is in doc.go. For real, running rules packages see example/ -- every entry point the three of them use is one you can use too, and the compiler is what says so: they are ordinary packages outside this one, with no access to anything you lack.

License

MIT License. See LICENSE.

Documentation

Overview

Package hiddenrole is a kernel for social deduction games.

It does not know what Werewolf is. What it knows is: there are players, there is a cycle of phases, and at the end of each phase you ask that phase's resolver "what happened", then fold the answer into state. Plus the hard part — who is entitled to know what.

A concrete rule set (roles, skills, ways to die, victory, information boundaries) comes from a rules package, installed entirely through public constructor options. github.com/Zereker/hiddenrole/example/werewolf was the first such package, and that it uses no back door is checkable: in this package's non-test sources RoleType has exactly two values (RoleUnspecified, RoleSystem), PhaseType three and SkillType three, all in types.go. Not one "Witch", not one "Werewolf".

Everything the state machine knows

SubmitSkillUse  ->  Resolver.Resolve  ->  []*Effect  ->  applyEffect
 collect skills      adjudicate            described             the one
                     (pure function)       state changes         write point

A Resolver receives a read-only GameView and can express state changes only by returning Effects. That constraint is enforced by the signature, not by convention — every change to state flows through one write point, which is what makes snapshots, replay and auditing possible at all.

The state machine knows two primitives:

NewSetAliveEffect              flip a player's alive bit
NewSetVarEffect(scope, k, v)   write one piece of custom state

Scope is a 2x2 table (lifetime x ownership); the four cells come from ScopeGame / ScopeRound crossed with .Of(playerID). See VarScope.

Plus NewDetourEffect, which files an IOU: take a trip through some phase for the sake of some player.

"Wolf kill", "lynch", "shoot" are names the rules give to what happened. The state machine does not know them — a KILL effect on its own kills nobody. To make someone die, the rules emit a SET_ALIVE next to it. Two effects, two jobs: the first is for the audience and the effect log, the second is for the state machine.

Who may know what

Engine.PlayerView(id)     everything this player is entitled to know,
                          safe to send to them verbatim
Engine.AudienceOf(event)  which players an event should go to

The rules draw the actual lines: AudienceProvider (who hears about an event), TeammateProvider (who is on whose side — asymmetry allowed), SpeechProvider (who can hear a message).

The kernel holds exactly one floor here, and it is not configurable: its own state primitives are never sent out. They are the state machine's bookkeeping; pushing them to players hands out the god view directly.

Writing a rules package

cfg := &hiddenrole.Config{StartPhase: myFirstPhase, Phases: ...}
e, err := hiddenrole.NewEngine(cfg,
	hiddenrole.WithResolver(myPhase, myResolver),   // how this phase resolves
	hiddenrole.WithRoleSetup(myRole, mySetup),      // what this role sits down with
	hiddenrole.WithVictoryChecker(myChecker),       // what counts as winning
	hiddenrole.WithAudience(myAudience),            // who hears about an event
	hiddenrole.WithTeammates(myTeammates),          // who is on whose side
	hiddenrole.WithSpeech(mySpeech))                // who can hear a message

Without these you still get an engine that advances phases — it just never decides a winner and does not recognise a single role. That is precisely what "the kernel knows nothing" means.

To unit-test your own resolver, use Board: lay out a position by hand, turn it into a GameView, feed it to the resolver, then fold the resulting effects back with Board.Apply and assert on what the position became.

Two decisions the kernel refuses to make for the rules

Two questions about how a game advances have answers only the rules know, so the kernel does not guess:

which phase comes next    PhaseConfig.NextPhase is the default exit;
                          rules may rewrite it during resolution with
                          NewGotoPhaseEffect
is this a new round       declared by PhaseConfig.EndsRound

Both used to be the kernel's own calls: the exit came from a static graph, and the round boundary was guessed as "we looped back to the start phase". In Werewolf both guesses happen to hold (night -> day -> night); change the rules and they stop holding. In the mission-based pack every proposal goes once round the loop, so "round" degenerated into a proposal counter — and a branch like "go to the mission if the vote passed, back to nomination otherwise" cannot be expressed by a static graph at all.

The test is one sentence: can the kernel decide whether this is right without knowing what game it is? "Did state change" it can decide, so that belongs to the kernel. "Is this a new round" it cannot, so that belongs to the rules.

Exit priority: pending detour queue > GOTO_PHASE > NextPhase. Detours come first because the queue must drain — both victory checking and the round boundary are waiting on it.

Giving up the decision bought back checkability: while the kernel was guessing the round boundary it could not check whether the guess was right; once the rules declare it, Config.Validate can. A looping config that declares no EndsRound is now rejected at construction, whereas its consequence — round state that never resets — used to surface only mid-game.

Extension points must not call back into the engine

The eight extension points — Resolver, VictoryChecker, AudienceProvider, TeammateProvider, SpeechProvider, RoleInfoProvider, RoleSetup, GameSetup — are all invoked synchronously while the engine holds its lock. Calling any Engine method from inside one hangs the game; it does not return an error. Go's RWMutex is not reentrant, and that game stops responding for good.

They do not need to call back: everything they could want is in the parameters. The GameView handed to a Resolver or a provider is the complete position at that instant; RoleSetup does not even need a GameView, because seating happens before the game starts. The signatures are deliberately narrow — an extension point cannot reach an *Engine, and routing around that means storing the engine in your own struct, which is a deliberate act.

To ask the engine something from a callback, use an OnEvent / OnMessage handler: events and messages are published outside the lock, so calling AudienceOf, PlayerView or Snapshot from a handler is the supported usage. Wiring the engine into a server is exactly that — receive an event, ask "who should get this", write to those connections; see example/netserver in the werewolf repository. TestCallbacks_MayCallBackIntoTheEngine watches this property and carries a timeout: if dispatch is ever moved inside the lock that test goes red instead of hanging the whole suite.

Boundaries: what the kernel does not do

  • No timers. PhaseConfig.Timeout is advisory only.
  • No networking, no rooms, no matchmaking.
  • No storage. Snapshot exports a position and RestoreEngine rebuilds one; where it is stored is the caller's business.
  • No knowledge of any game's rules. That is the rules package's job.

Index

Constants

View Source
const (
	// DefaultPhaseTimeout is the fallback suggestion when PhaseConfig.Timeout
	// is not given.
	//
	// It is advice for the caller and the engine does not time anything by
	// it -- when EndPhase is called is entirely up to the caller. Per-phase
	// suggestions are board data and live in the rules package.
	DefaultPhaseTimeout = 30 * time.Second
)

Timeout constants.

The engine keeps no clock of its own -- when a phase ends is entirely the caller's decision (they call EndPhase). These constants and PhaseConfig.Timeout are advisory values for the caller, who sets their own timer from them.

View Source
const SnapshotVersion = 13

SnapshotVersion is the version of the current snapshot format.

It is bumped on every change to the snapshot structure that is not backwards compatible. RestoreEngine rejects a version it does not recognise, so that old data is never read through a new structure into a board that looks fine and is in fact scrambled.

The mechanism had a hole: changing the structure and **forgetting** to bump raised no alarm anywhere -- which is exactly what the version number is meant to prevent. There is now a golden test in the rules package (TestSnapshot_ShapeIsPinnedToVersion) pinning the serialised shape; adding, removing or renaming a field turns it red, and the bump decision is made once it is.

View Source
const VarCamp = "camp"

VarCamp is the canonical key under which a player's camp lives in Vars.

This is the one key the kernel recognises: its value is copied into the Camp field of PlayerInfo and SelfInfo, so that "which side is this player on" does not have to be dug out of Vars by every caller. The value is handed out by the rules (see RoleSetup); the kernel neither checks nor interprets it.

There is only this one. Sub-divisions within a camp -- "special roles" vs "plain villagers" -- exist only because werewolf needs them for its wipe-out-one-side victory check; the kernel does not recognise them, and a rules package can simply define its own key (see werewolf.VarCategory).

View Source
const VarPresent = "1"

VarPresent is the conventional "present" value for boolean-ish Vars.

Vars values are strings, and at the write point an empty string is equivalent to deletion, so a has-it/hasn't-it state needs nothing more than one non-empty value. The built-in roles all use this one; extensions are under no obligation to.

Variables

View Source
var (
	ErrPlayerNotFound    = &GameError{Code: CodePlayerNotFound, Message: "player not found"}
	ErrPlayerDead        = &GameError{Code: CodePlayerDead, Message: "player is dead"}
	ErrTargetNotFound    = &GameError{Code: CodeTargetNotFound, Message: "target not found"}
	ErrTargetDead        = &GameError{Code: CodeTargetDead, Message: "target is dead"}
	ErrSkillNotAllowed   = &GameError{Code: CodeSkillNotAllowed, Message: "skill not allowed in this phase"}
	ErrGameNotStarted    = &GameError{Code: CodeGameNotStarted, Message: "game not started"}
	ErrGameEnded         = &GameError{Code: CodeGameEnded, Message: "game has ended"}
	ErrInvalidPhase      = &GameError{Code: CodeInvalidPhase, Message: "invalid phase"}
	ErrMessageNotAllowed = &GameError{Code: CodeMessageNotAllowed, Message: "message not allowed in this phase"}

	// Player and start-of-game validation.
	ErrPlayerExists        = &GameError{Code: CodePlayerExists, Message: "player already exists"}
	ErrInvalidPlayerID     = &GameError{Code: CodeInvalidPlayerID, Message: "player id must not be empty"}
	ErrInvalidRole         = &GameError{Code: CodeInvalidRole, Message: "role cannot be assigned to a player"}
	ErrGameAlreadyStarted  = &GameError{Code: CodeGameAlreadyStarted, Message: "game already started"}
	ErrInvalidBoard        = &GameError{Code: CodeInvalidBoard, Message: "invalid board"}
	ErrBoardAlreadyDecided = &GameError{Code: CodeInvalidBoard, Message: "board is already decided before the game starts", sentinel: ErrInvalidBoard}

	// Snapshots and effect logs.
	ErrInvalidSnapshot  = &GameError{Code: CodeInvalidSnapshot, Message: "invalid snapshot"}
	ErrNilSnapshot      = &GameError{Code: CodeInvalidSnapshot, Message: "snapshot must not be nil", sentinel: ErrInvalidSnapshot}
	ErrInvalidEffectLog = &GameError{Code: CodeInvalidEffectLog, Message: "invalid effect log"}

	// Config.
	ErrInvalidConfig = &GameError{Code: CodeInvalidConfig, Message: "invalid game config"}
)

Predefined errors.

View Source
var ScopeGame = VarScope{}

ScopeGame lives for the whole game and belongs to no player. Scores, counters and whose-turn-it-is go here.

Add .Of(playerID) to get "follows one player for the whole game": the witch's two potions, the knight's spent duel, the idiot's flipped card.

View Source
var ScopeRound = VarScope{/* contains filtered or unexported fields */}

ScopeRound lives for this round only and belongs to no player. Tonight's kill target goes here.

Add .Of(playerID) to get "marked on someone this round": who was guarded tonight, who was healed, who was poisoned. Both round-level cells are cleared together on entering the next round (or on a phase configured with ClearsRoundVars).

Functions

func HasCode

func HasCode(err error, code ErrorCode) bool

HasCode reports whether an error carries the given code.

It goes through errors.As rather than a bare type assertion: wrapping an error in context with fmt.Errorf("...: %w", err) is the most common thing a caller does, and a bare assertion stops matching the moment they do.

Types

type AudienceFunc

type AudienceFunc func(event *Event, view GameView) ([]string, bool)

AudienceFunc lets a plain function satisfy AudienceProvider.

func (AudienceFunc) Audience

func (f AudienceFunc) Audience(event *Event, view GameView) ([]string, bool)

Audience implements AudienceProvider.

type AudienceProvider

type AudienceProvider interface {
	Audience(event *Event, view GameView) ([]string, bool)
}

AudienceProvider answers "which players should be told about this".

Same shape as Resolver and VictoryChecker: it takes a read-only GameView, returns a conclusion, and touches no state. It is called while the engine holds its lock, so an implementation must not call back into any Engine method -- the consequence is a hang, not an error. See "Extension points must not call back into the engine" in doc.go.

The second result is "do I recognise this event type", which is a different thing from "show it to nobody" and must stay distinguishable: the former asks the caller to route it themselves, the latter is a definite verdict. When it is false the first result is ignored.

type Board

type Board struct {
	// Players are the players at the table. The order does not matter; the
	// view sorts by ID.
	Players []PlayerInfo

	// Round is the current round, counting from 1. Zero is treated as 1.
	Round int

	// Phase is the current phase.
	Phase PhaseType

	// Vars is state that lives for the whole game and belongs to no player
	// (ScopeGame).
	Vars map[string]string

	// RoundVars is state that lives for this round and belongs to no player
	// (ScopeRound).
	//
	// The two owned cells live on PlayerInfo (Vars / RoundVars), and all four
	// are needed before an arbitrary board can be laid out. The cell above
	// used to be missing here, for the same reason the kernel was missing one:
	// werewolf does not need it, so nobody noticed.
	RoundVars map[string]string
}

Board is a board laid out by hand, used to construct a GameView.

func (Board) Apply

func (b Board) Apply(effects []*Effect) Board

Apply folds a batch of effects into the board and returns the modified copy.

A rules test uses it to catch a resolver's output -- `b = b.Apply(r.Resolve(uses, b.View()))` -- and then asserts what the board became. It goes through exactly the same write point as the engine, so an effect that fails to land shows up in a unit test rather than requiring a whole game to be run.

A vetoed effect and a type the kernel does not recognise both change nothing -- which is precisely what this is meant to verify.

func (Board) Player

func (b Board) Player(id string) (PlayerInfo, bool)

Player returns one player; the second result is false when there is no such player.

func (Board) Var

func (b Board) Var(scope VarScope, key string) string

Var reads one piece of state in the given scope; all four cells are readable (see VarScope).

func (Board) View

func (b Board) View() GameView

View builds a read-only view of this board.

The returned view is a snapshot: modifying the Board afterwards does not affect it.

type Camp

type Camp string

Camp labels one side. It is what a victory check resolves to.

The kernel **presumes no values**: villagers and werewolves are the two sides of werewolf, the mission-based games have good and evil, and Blood on the Clocktower additionally has travellers who are scored separately. The kernel only knows that there are some number of sides, one of which may win, and that each player may belong to one of them (VarCamp) -- not which one, nor what it means.

const CampUnspecified Camp = ""

CampUnspecified means no side has won yet, or this player belongs to no side.

func (Camp) String

func (v Camp) String() string

String implements fmt.Stringer.

type Config

type Config struct {
	// StartPhase is the first phase entered after Start. It has no default;
	// Validate requires it.
	StartPhase PhaseType

	// Phases is the per-phase configuration.
	Phases map[PhaseType]*PhaseConfig

	// DefaultTimeout is the suggested timeout when PhaseConfig.Timeout is not
	// given. It is advice and the engine does not time by it -- see the
	// timeout constants above. Use Config.PhaseTimeout(phase) to get the
	// final suggestion for one phase.
	DefaultTimeout time.Duration
}

Config configures the phase machine: where it starts, how phases flow, and how long each is expected to take.

Only the three things the phase machine needs. Rules switches (werewolf's "may the witch save herself" and the like) do not belong here -- the kernel should not recognise those concepts, and they live on a rules package's own struct.

It used to be called GameConfig. That name was dropped because it claimed too much: it configures the phase machine, not "a game".

func (*Config) PhaseTimeout

func (c *Config) PhaseTimeout(phase PhaseType) time.Duration

PhaseTimeout is the suggested timeout for one phase.

A phase without its own falls back to DefaultTimeout, and a config without that falls back to DefaultPhaseTimeout. These two fields used to be written and never read -- a caller wanting the suggestion the engine actually uses had to reconstruct the configuration and compare.

func (*Config) Validate

func (c *Config) Validate() error

Validate checks that the configuration is internally consistent.

The phase graph is data the user can replace, and a dangling NextPhase makes the engine silently declare the game over when it reaches it -- that class of problem has to surface at construction, not by the game abruptly ending in round three.

This checks the shape of the configuration only. Two classes of problem it cannot check have homes of their own:

  • "does every phase have a Resolver" depends on runtime registration and is checked by Engine.Start;
  • the dynamic transitions a detour brings (the phase a Resolver's NewDetourEffect points at) are edges known only at runtime, and are checked by the engine before enqueueing -- a detour whose destination is not in the configuration is vetoed on the spot and logged as an error, rather than carrying the game into an empty phase.

type Detour

type Detour struct {
	PlayerID string    // whose sake this trip is for
	Phase    PhaseType // which phase to visit
}

Detour is one pending detour: **for the sake of someone, take a trip through some phase**.

It used to be called PendingTrigger, documented as "a pending death ability". That is werewolf's phrasing -- the hunter shooting after being killed. What the kernel recognises was never death and was never a skill, only "who, and to which phase": what triggered it and what they do once there is entirely the rules' business.

It governs three things, the last two of which nothing else can provide:

  1. routing the phase to where the debt is -- GOTO_PHASE can do this too
  2. holding off the victory check and the round boundary until it drains -- a detour can turn the game around (that shot takes the last wolf)
  3. taking them one at a time from the head -- two people owing on the same night each get their own trip

It does **not** answer "who may act": on entering the phase owed to, it writes an actor list (see gameState.nameDetourActor), and everything after that takes exactly the same path as NewSetActorsEffect.

type DetourSnapshot

type DetourSnapshot struct {
	PlayerID string    `json:"player_id"`
	Phase    PhaseType `json:"phase"`
}

DetourSnapshot is one pending detour.

type Effect

type Effect struct {
	Type     EventType
	SourceID string                 // where it came from (player ID)
	TargetID string                 // what it is aimed at (player ID)
	Data     map[string]interface{} // extra payload
	Canceled bool                   // vetoed, e.g. by a protection
	Reason   string                 // why it was vetoed
}

Effect describes one state change.

func NewDetourEffect

func NewDetourEffect(playerID string, phase PhaseType) *Effect

NewDetourEffect declares "for the sake of this player, take a trip through that phase" (see Detour).

Werewolf uses it for "the hunter shoots after being killed", but what the kernel recognises is neither death nor a skill -- only "who, and to which phase". What triggered it and what they do once there is entirely the rules' business. Shooting on elimination, self-detonating, flipping a card, any "hold on, someone still has to act" goes through here.

The division of labour with NewGotoPhaseEffect: that one is a **one-off rewrite of the next stop**, this one **files a debt** -- victory checks and the round boundary all wait until the queue drains.

func NewEffect

func NewEffect(eventType EventType, sourceID, targetID string) *Effect

NewEffect builds an effect.

func NewGotoPhaseEffect

func NewGotoPhaseEffect(phase PhaseType) *Effect

NewGotoPhaseEffect declares "once this phase resolves, go to that phase".

It overrides the default exit in PhaseConfig.NextPhase. Phase progression used to be a purely static graph whose only dynamic jump was the detour queue -- so every conditional branch had to go through that back door, whose meaning is "someone's skill is pending", not "where to go next".

The missions package's "go to the mission if the vote passes, back to nomination otherwise" is the plainest form of such a branch: the outcome is computed by this phase's resolution, and a static graph cannot express it.

Priority: a pending detour queue > this effect > PhaseConfig.NextPhase. Detours come first because the queue has to drain -- victory checks and the round boundary are waiting on it, and jumping away mid-queue would drop a debt that has not been settled.

When the destination is not in the configuration the kernel logs an error and falls back to NextPhase: one malformed effect should not bring down a whole game, but neither may it quietly jump somewhere nobody expected.

func NewSetActorsEffect

func NewSetActorsEffect(phase PhaseType, playerIDs ...string) *Effect

NewSetActorsEffect declares "these players may act in the given phase".

The kernel's default way of deciding actors is to match PhaseStep.Role against a player's role -- and a role is fixed at seating time, so any set of actors **chosen at runtime** is inexpressible: the missions package's team is voted on in the previous phase, and its leader rotates by seat. Without this effect the rules could only let everyone submit and then throw away what should not count, while the kernel told unqualified players "you may act".

Priority: a pending detour queue > this effect > PhaseStep.Role. Same layering as NewGotoPhaseEffect -- a default plus a runtime override.

The list is normally computed in an **earlier phase**, which is why it names a phase rather than applying to the current one. A phase's list is consumed once that phase resolves: without clearing it, the next visit to the same phase would inherit the previous round's list.

Passing an empty list is meaningful: it says "nobody can act in this phase", which is different from "the rules did not say".

Players in the list who do not exist are ignored; the list is stored sorted by ID, which keeps the effect log deterministic.

func NewSetAliveEffect

func NewSetAliveEffect(playerID string, alive bool) *Effect

NewSetAliveEffect declares "set this player's alive flag to this value".

This is the engine's only life-and-death primitive. A wolf kill, a poisoning, an exile and a gunshot each used to be an event type that changed the alive flag, which wrote a werewolf rule -- "here are the ways to die" -- into the engine; a different ruleset (death by duel, dying of a broken heart) meant one more event type and one more branch.

The ways to die are now named by the rules: emit an event of your own (KILL / SHOOT / heartbreak) as the account of what happened, and emit a SET_ALIVE to actually change the state. Two effects, two things -- the first for the audience and the effect log, the second for the state machine.

func NewSetVarEffect

func NewSetVarEffect(scope VarScope, key, value string) *Effect

NewSetVarEffect declares "set this piece of custom state to this value", in the scope given by scope.

The four scopes used to be four constructors, so nothing forced the 2x2 table to be complete -- the "whole game, unowned" cell was missing for a long time and nobody noticed. The scope is now a parameter:

NewSetVarEffect(ScopeGame, "score", "3")              whole game, unowned
NewSetVarEffect(ScopeGame.Of(id), "antidote", "used") whole game, one player
NewSetVarEffect(ScopeRound, "kill", target)           this round, unowned
NewSetVarEffect(ScopeRound.Of(id), "guarded", "1")    this round, one player

This is the proper way for a role to store its own state. The idiot's "card already flipped", the knight's "duel spent", the witch's two potions and the guard's protection record are all the same thing and take the same route. Taking it is what earns the whole apparatus for free: the state travels with the snapshot, the effect log can replay it, and a Resolver can therefore stay stateless -- which is what the Resolver interface demands.

Passing an empty value deletes the entry, identically in all four scopes.

func (*Effect) Cancel

func (e *Effect) Cancel(reason string)

Cancel vetoes an effect.

func (*Effect) SetsAlive

func (e *Effect) SetsAlive() (alive, ok bool)

SetsAlive reports whether this effect changes the alive flag, and to what.

An extension that wants to intercept a death needs it: the idiot surviving an exile by flipping their card works by vetoing the lethal primitive. Intercepting the primitive rather than the word "exile" makes it **independent of the cause** -- one piece of code stops a wolf kill, a poisoning, a gunshot and any third-party ruleset's way of dying, because all of them end up here.

func (*Effect) SetsVar

func (e *Effect) SetsVar() (scope VarScope, key, value string, ok bool)

SetsVar reports whether this effect writes a piece of custom state, and if so which cell, key and value.

Same use as SetsAlive: an extension that wants to intercept or observe a class of write needs it. With the four scopes folded into one event type, Type alone no longer distinguishes whole-game from this-round, or owned from unowned -- read them from here.

func (*Effect) ToEvent

func (e *Effect) ToEvent() *Event

ToEvent converts an effect into an outward event.

Data is flattened from map[string]interface{} to map[string]string; Canceled and Reason are carried over verbatim -- an action the rules vetoed that lost its marker here would reach the caller looking exactly like one that really happened.

func (*Effect) WithData

func (e *Effect) WithData(key string, value interface{}) *Effect

WithData attaches extra payload.

It builds Data in place when nil: Effect is an exported type with all fields exported, constructing one as a literal is the documented thing for a third-party Resolver to do, and it should not run into an "assignment to entry in nil map" here.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

func MustNewEngine

func MustNewEngine(config *Config, opts ...EngineOption) *Engine

MustNewEngine is NewEngine, panicking on an invalid configuration.

For cases where the configuration is a compile-time constant: examples, tests, and service start-up paths with a hard-coded default.

func NewEngine

func NewEngine(config *Config, opts ...EngineOption) (*Engine, error)

NewEngine creates a game engine.

What comes out is a state machine that **recognises nothing**: no resolvers, no victory check, no audience rules. Every rule arrives through opts -- werewolf's whole set is werewolf.New, which is assembled exactly this way and takes no back door.

config is required: the kernel has no default board to offer. It is checked by Config.Validate first -- the phase graph is data the user can replace, and a dangling NextPhase makes the game end silently halfway through, a class of problem that has to surface at construction.

func ReplayEngine

func ReplayEngine(config *Config, log []*Effect, opts ...EngineOption) (*Engine, error)

ReplayEngine rebuilds an engine from an effect log.

config must match the one used during recording -- the effect log records what happened, not the rules.

The rebuilt engine matches the recorded one in player state, phase and round; but skills submitted in the current phase and not yet resolved are not in the effect log (they have not become effects yet), so use Snapshot if you need those.

Resolvers for custom roles must be passed through opts, for the same reason as with RestoreEngine. Initial state need not be: it is recorded on the seating entry of the effect log (see Engine.seatPlayer).

func RestoreEngine

func RestoreEngine(config *Config, snap *Snapshot, opts ...EngineOption) (*Engine, error)

RestoreEngine rebuilds an engine from a snapshot.

A nil config means the default configuration. **The rules configuration supplied on restore must match the one in force when the snapshot was taken** -- a snapshot records the board, not the rules, and restoring under a different configuration gives you a game whose rules changed halfway through.

Resolvers for custom roles must be passed through opts (WithResolver). Omitting one makes that phase's skills be silently dropped, so resolver validation runs here and a missing one is an outright error.

Errors: a nil snapshot; an unsupported version; an empty or duplicate player ID; a phase not present in the config; a phase with no resolver.

func (*Engine) AddPlayer

func (e *Engine) AddPlayer(id string, role RoleType) error

AddPlayer seats one player.

It may only be called before Start. Errors: the game has already started; an empty ID; an ID already taken; a role that cannot be assigned to a player.

Camp and role category are **not parameters**: they are the rules' way of dividing things up, handed out as initial state by that role's RoleSetup at seating time (see WithRoleSetup). There used to be an overload here taking two more parameters, so that an extension role could state its camp and category explicitly -- which made the answer to "which side is this role on" depend on the caller filling it in correctly at every seating, rather than being written on the role itself.

func (*Engine) AlivePlayerIDs

func (e *Engine) AlivePlayerIDs() []string

AlivePlayerIDs returns the IDs of every living player, sorted lexicographically.

Who is still alive is public information. A caller wanting this list used to have to go through PhaseInfo().RoleInfos[UNSPECIFIED] -- an entry point that depends on the current phase happening to declare a step for all players, and which stopped working once the day had no player skill step.

func (*Engine) AllowedSkills

func (e *Engine) AllowedSkills(playerID string) []SkillType

AllowedSkills are the skills this player may submit right now; empty means it is not their turn.

It takes the same path as PlayerView(id).AllowedSkills and agrees with SubmitSkillUse's validation: were the three to differ, a caller running the phase by one of them would have the player's submission rejected by another.

func (*Engine) Apply

func (e *Engine) Apply(effects ...*Effect) []*Effect

Apply applies a batch of effects directly, bypassing phase resolution.

This is a tool with an edge, and a necessary one: a host really does meet state changes that belong to no phase -- "the player disconnected, count them dead", "an admin kicked someone", "correct a misjudgement from the back office" -- and a rules package needs it to unit-test its own resolvers.

It still goes through the **same write point**: effects enter the effect log, vetoed ones do not take effect, kernel state primitives are not sent out, and the rest are pushed to OnEvent. So saves, replays and audits do not lose fidelity because someone used it -- which is exactly what makes it better than reaching in and editing a playerState.

What it does not do: it does not check for victory and it does not transition phases. To make the engine reconsider the outcome, call EndPhase.

It returns the effects that actually took hold (nils are dropped).

func (*Engine) AudienceOf

func (e *Engine) AudienceOf(event *Event) ([]string, bool)

AudienceOf returns which players should be told about something.

This is PlayerView's other half: the view settles what state a player should see, and this settles who should be told about what happened. A caller routes on it instead of having to remember for itself that "a check result goes to the seer only".

The parameter is an outward Event rather than an internal Effect: the question is what the outside world should see, and an Event is exactly what OnEvent pushes to the caller. When you hold an Effect (EndPhase's return value), convert it with Effect.ToEvent().

The kernel's state primitives (SET_ALIVE and friends) always return empty, and that part is not configurable -- they are the state machine's bookkeeping and have no business in front of any player. Everything else goes to the AudienceProvider; werewolf's is wolfAudience, and it can be replaced wholesale.

The second result says whether the event type is recognised. A third-party Resolver may emit events of its own types, whose visibility the rules cannot judge, and (nil, false) is the answer then: the caller has to route it themselves, and "I don't know" must not be mistaken for "show it to nobody".

func (*Engine) EffectLog

func (e *Engine) EffectLog() []*Effect

EffectLog returns the complete effect log since the game was created.

This architecture already produces a clean event stream -- a Resolver is a pure function, and every state change goes through the single write point of applyEffect -- so accumulating it is nearly free, and it gives replays, post-game analysis and "what actually happened on night three" investigations something to stand on.

The returned slice is a copy, but the *Effect values inside it are the engine's own objects; do not modify them.

Division of labour with Snapshot

The effect log is history; a snapshot is state. For persistence use Snapshot: Effect.Data is a map[string]interface{} whose types degrade on a JSON round trip, and the effect log is designed for in-process replay and auditing, not as a storage format.

func (*Engine) EndPhase

func (e *Engine) EndPhase() ([]*Effect, error)

EndPhase ends the current phase: resolve the skills, apply the effects, check for victory, and transition to the next phase.

This is the sole entry point that drives the game forward. Transitions follow the phase configuration (PhaseConfig.NextPhase) and handle the dynamic phases a detour brings (the hunter's shot after being killed).

func (*Engine) MessageReceivers

func (e *Engine) MessageReceivers(senderID string) []string

MessageReceivers returns the receivers of a message. It reports which players a message from the given sender may reach in the current phase.

func (*Engine) OnEvent

func (e *Engine) OnEvent(handler EventHandler)

OnEvent registers an event handler.

func (*Engine) OnMessage

func (e *Engine) OnMessage(handler MessageHandler)

OnMessage registers a message handler. When a player sends a message the handler receives it along with the list of receivers.

func (*Engine) PhaseInfo

func (e *Engine) PhaseInfo() *PhaseInfo

PhaseInfo returns information about the current phase, from the god's point of view.

What it returns contains sensitive information -- the wolf roster, the kill the witch can see -- for the caller to run this phase as the host, and **must not be forwarded to players wholesale**. For content that can be sent straight to one player, use PlayerView.

Each role's information is derived from the phase configuration (PhaseConfig.Steps), so a custom role added by a third party through WithResolver gets the same treatment.

func (*Engine) PhaseReadiness

func (e *Engine) PhaseReadiness() PhaseReadiness

PhaseReadiness reports who has yet to act in the current phase.

A step with no eligible actor (the guard is dead, say) counts as automatically satisfied, so it can never wedge the phase open forever.

func (*Engine) PlayerInfo

func (e *Engine) PlayerInfo(playerID string) (PlayerInfo, bool)

PlayerInfo reads one player's information from the **god's view**; the second result is false when there is no such player.

It returns a copy, Vars and RoundVars included -- which is for the host and the rules, **not** for the player. For what to send a player, use PlayerView.

func (*Engine) PlayerView

func (e *Engine) PlayerView(playerID string) *PlayerView

PlayerView returns one player's view.

What it returns can be sent straight to that player with no further filtering by the caller. It returns nil when there is no such player.

By contrast PhaseInfo and PlayerInfo are god's-view APIs: a caller acting as the host needs them, but their contents must not be forwarded to players wholesale.

func (*Engine) RoundContext

func (e *Engine) RoundContext() *RoundContext

RoundContext returns a read-only copy of the round context.

func (*Engine) SendMessage

func (e *Engine) SendMessage(senderID, content string) error

SendMessage sends one player's speech, routed by the current phase to whoever should hear it.

The audible range is answered by a SpeechProvider (see WithSpeech). **With no provider installed** the kernel falls back to a default: eliminated players may not speak, and a phase where nobody can hear is a rejection. With one installed it decides -- whether the dead may speak is the rules' judgement, not the kernel's law (the dead in Blood on the Clocktower hold a ghost vote, and werewolf has a last-words phase).

Errors: no such player (ErrPlayerNotFound); an eliminated player speaking under the default rule (ErrPlayerDead); no receivers at all in the current phase (ErrMessageNotAllowed).

func (*Engine) Snapshot

func (e *Engine) Snapshot() *Snapshot

Snapshot exports the engine's current state.

The returned snapshot is a deep copy: it is safe to serialise, pass across goroutines, or hold onto indefinitely, and later play does not affect it.

It includes the skills submitted in the current phase but not yet resolved, so a game can be saved mid-phase, restored, keep collecting skills, and then resolve with EndPhase.

func (*Engine) Start

func (e *Engine) Start() error

Start begins the game.

The start event is pushed to OnEvent subscribers through the same channel as every other event.

func (*Engine) Status

func (e *Engine) Status() Status

Status reads the summary once. All four come out under one read lock, so they are consistent with each other.

func (*Engine) SubmitSkillUse

func (e *Engine) SubmitSkillUse(use *SkillUse) error

SubmitSkillUse submits one use of a skill.

func (*Engine) Teammates

func (e *Engine) Teammates(playerID string) []string

Teammates are the players this one is told are on their side, excluding themselves.

It shares one TeammateProvider with PlayerView.Teammates and the copy in PhaseInfo -- replace the provider and all three change together.

func (*Engine) Var

func (e *Engine) Var(scope VarScope, key string) string

Var reads one piece of custom state in the given scope, or the empty string (see VarScope).

The rules use it to offer readers of their own: werewolf's "tonight's kill" is Var(ScopeRound, ...), the missions package's "which mission" is Var(ScopeGame, ...), and the kernel knows only that some such key exists.

func (*Engine) View

func (e *Engine) View() GameView

View returns a read-only view of the current board.

It is the same thing a Resolver is handed. A host uses it to work something out for itself ("who is winning by my own reckoning", "how many special roles are still alive") without reading the board out field by field.

A view holds the values of that moment: later play does not change a copy already taken.

type EngineOption

type EngineOption func(*Engine) error

EngineOption is an optional setting applied while constructing an engine.

All three entry points (NewEngine / RestoreEngine / ReplayEngine) accept them, so an extension role is written the same way whether the game is starting fresh or resuming from a save.

func WithAudience

func WithAudience(provider AudienceProvider) EngineOption

WithAudience replaces the "who should be told" decision.

The kernel's state primitives are filtered out before this point and never reach it: they are the state machine's bookkeeping, they have no business in front of any player, and that part is not configurable.

func WithGameSetup

func WithGameSetup(setup GameSetup) EngineOption

WithGameSetup registers the game's opening initialisation.

It is called once inside Start(), and the effects it produces land through the same write point as every other effect. Registering twice keeps the last registration.

func WithLogger

func WithLogger(logger Logger) EngineOption

WithLogger sets the logger. A nil logger leaves the default no-op in place.

func WithResolver

func WithResolver(phase PhaseType, resolver Resolver) EngineOption

WithResolver registers or replaces one phase's resolver.

This is the only way to extend the game with a new role, and it works for RestoreEngine and ReplayEngine just as well:

cfg := werewolf.DefaultGameConfig()
cfg.Phases[myPhase] = &werewolf.PhaseConfig{ ... }
engine, err := werewolf.RestoreEngine(cfg, snap,
	werewolf.WithResolver(myPhase, myResolver))

func WithRoleInfo

func WithRoleInfo(role RoleType, provider RoleInfoProvider) EngineOption

WithRoleInfo registers a provider of role-specific information for one role.

engine, _ := werewolf.NewEngine(cfg,
	werewolf.WithResolver(phaseThief, thiefResolver{}),
	werewolf.WithRoleInfo(roleThief, werewolf.RoleInfoFunc(
		func(id string, view werewolf.GameView) map[string]string {
			return map[string]string{"spare_cards": view.Var(werewolf.ScopeRound, "thief.spares")}
		})))

Registering the same role twice keeps the last registration, so this is also how you replace a built-in provider.

func WithRoleSetup

func WithRoleSetup(role RoleType, setup RoleSetup) EngineOption

WithRoleSetup registers an initial state for one role.

const roleKnight = engine.RoleType("KNIGHT")

e, _ := engine.NewEngine(cfg,
	engine.WithResolver(phaseKnight, knightResolver{}),
	engine.WithRoleSetup(roleKnight, engine.RoleSetupFunc(
		func(id string, role engine.RoleType) map[string]string {
			return map[string]string{"knight.duel": "1"}
		})))

Registering the same role twice keeps the last registration, so this is also how you replace a built-in one (a witch who starts with her antidote already spent, say).

Neither replay nor restore needs it passed again: the initial state is recorded on the seating entry of the effect log (ReplayEngine) and in the snapshot's Vars (RestoreEngine). This differs from resolvers: a resolver is a rule and must be supplied by the caller, whereas an initial state is a fact, and recording it is enough.

func WithSpeech

func WithSpeech(provider SpeechProvider) EngineOption

WithSpeech replaces the audible range of speech.

func WithTeammates

func WithTeammates(provider TeammateProvider) EngineOption

WithTeammates replaces the "who is on whose side" decision.

func WithVictoryChecker

func WithVictoryChecker(checker VictoryChecker) EngineOption

WithVictoryChecker replaces the built-in victory check.

Once replaced, Config.VictoryMode no longer has any effect -- that field only feeds the built-in check. To add a condition on top of the built-in rules (say "the lovers win if both survive"), wrap DefaultVictoryChecker: ask your own condition first, then ask it.

type ErrorCode

type ErrorCode string

ErrorCode classifies an error.

Like the other enums it is a string underneath -- error codes show up in logs and in JSON, and the name itself is both the most stable and the most readable representation.

const (
	CodeUnspecified        ErrorCode = ""
	CodePlayerNotFound     ErrorCode = "PLAYER_NOT_FOUND"     // no such player
	CodePlayerDead         ErrorCode = "PLAYER_DEAD"          // the player is dead
	CodeTargetNotFound     ErrorCode = "TARGET_NOT_FOUND"     // no such target
	CodeTargetDead         ErrorCode = "TARGET_DEAD"          // the target is dead
	CodeSkillNotAllowed    ErrorCode = "SKILL_NOT_ALLOWED"    // the skill is not allowed in this phase
	CodeGameNotStarted     ErrorCode = "GAME_NOT_STARTED"     // the game has not started
	CodeGameEnded          ErrorCode = "GAME_ENDED"           // the game is over
	CodeInvalidPhase       ErrorCode = "INVALID_PHASE"        // no such phase
	CodeMessageNotAllowed  ErrorCode = "MESSAGE_NOT_ALLOWED"  // speaking is not allowed in this phase
	CodePlayerExists       ErrorCode = "PLAYER_EXISTS"        // that player id is taken
	CodeInvalidPlayerID    ErrorCode = "INVALID_PLAYER_ID"    // malformed player id
	CodeInvalidRole        ErrorCode = "INVALID_ROLE"         // this role cannot be assigned to a player
	CodeGameAlreadyStarted ErrorCode = "GAME_ALREADY_STARTED" // the game has already started
	CodeInvalidBoard       ErrorCode = "INVALID_BOARD"        // the board setup is invalid
	CodeInvalidSnapshot    ErrorCode = "INVALID_SNAPSHOT"     // the snapshot is invalid or of an incompatible version
	CodeInvalidConfig      ErrorCode = "INVALID_CONFIG"       // the game config is invalid
	CodeInvalidEffectLog   ErrorCode = "INVALID_EFFECT_LOG"   // the effect log is invalid and cannot be replayed
)

func CodeOf

func CodeOf(err error) ErrorCode

CodeOf extracts the error code, returning CodeUnspecified for an error that did not come from this library.

func (ErrorCode) String

func (v ErrorCode) String() string

String implements fmt.Stringer.

type Event

type Event struct {
	Type     EventType         `json:"type"`
	SourceID string            `json:"source_id,omitempty"` // player the event came from
	TargetID string            `json:"target_id,omitempty"` // player the event was aimed at
	Data     map[string]string `json:"data,omitempty"`      // extra payload

	// Canceled / Reason record whether the rules vetoed the action, and why.
	//
	// "The witch clicked poison but had already used the antidote tonight"
	// has to be expressible: without these two fields a vetoed action reaches
	// the caller looking exactly like a successful one, and gets broadcast as
	// though it really happened.
	Canceled bool   `json:"canceled,omitempty"`
	Reason   string `json:"reason,omitempty"`
}

Event is one externally visible thing that happened.

Built by Effect.ToEvent and received by handlers registered through Engine.OnEvent. Which players it should be sent to is answered by Engine.AudienceOf.

type EventHandler

type EventHandler func(event *Event)

EventHandler handles one event.

type EventType

type EventType string

EventType is the type of an event or effect.

There are two classes, and the split is decided by **who owns the name**, not by a numeric range:

kernel state primitives   SET_ALIVE / SET_VAR / ... -- state-machine bookkeeping, never sent out
everything else           the rules' name for something that happened -- pushed to OnEvent, audience decided by the rules

In the numbered era this was three ranges: 1..99 external, 100..999 internal, 1000 and up third-party. That convention bit itself: every third-party event type landed inside the "internal" range, so extension events could not be sent at all (a rules package's own public events were invisible to everyone). With names there are no ranges: the kernel recognises its own handful and treats everything else as external.

const (
	EventGameStarted EventType = "GAME_STARTED"
	EventGameEnded   EventType = "GAME_ENDED"

	// -- state primitives, never sent out --
	EventDetour       EventType = "DETOUR"        // detour through a phase for someone's sake, pending
	EventPlayerAdded  EventType = "PLAYER_ADDED"  // a player took a seat (for effect-log replay)
	EventPhaseChanged EventType = "PHASE_CHANGED" // a phase transition (for effect-log replay)
	EventSetAlive     EventType = "SET_ALIVE"     // change a player's alive flag
	EventSetVar       EventType = "SET_VAR"       // write custom state, scope carried in the effect
	EventGotoPhase    EventType = "GOTO_PHASE"    // the rules pick the next phase, overriding NextPhase
	EventSetActors    EventType = "SET_ACTORS"    // name the players who may act in a phase
)

The kernel's own events: it emits game start and game end; the rest are state primitives and are never sent out.

const EventUnspecified EventType = ""

EventUnspecified is unspecified.

func (EventType) String

func (v EventType) String() string

String implements fmt.Stringer.

type Field

type Field struct {
	Key   string
	Value interface{}
}

Field is one structured log field.

Only the kernel writes logs -- a Resolver or VictoryChecker is handed a GameView and nothing else, never a Logger. So this package exports no constructor for Field: whoever implements Logger only ever reads one, and reading needs no constructor. If you really do want to build one (say to wrap a Logger and add a field), the fields are exported and Field{Key: ..., Value: ...} is enough.

type GameError

type GameError struct {
	Code    ErrorCode
	Message string
	// contains filtered or unexported fields
}

GameError is this package's error type.

func WrapError

func WrapError(code ErrorCode, format string, args ...interface{}) *GameError

WrapError builds an error that carries context.

The sentinel for the given code is attached, so errors.Is(err, ErrPlayerExists) holds for errors built by WrapError too.

func (*GameError) Error

func (e *GameError) Error() string

Error implements error.

func (*GameError) Unwrap

func (e *GameError) Unwrap() error

Unwrap returns the predefined sentinel, so errors.Is can see through an error that carries context.

type GameSetup

type GameSetup interface {
	Setup(view GameView) []*Effect
}

GameSetup is how the rules lay out the board at the moment play begins.

It pairs with RoleSetup: that one covers what **one player** sits down with, this one covers the initial state of the **whole game**. It sees the board with everyone already seated, so it can do what RoleSetup cannot -- "which seat leads first", for instance, which depends on who is at the table.

Typical uses: initialising game-long counters (a game-scoped Var), and **naming the actors of the first phase** (SetActors). The latter is the direct reason this extension point exists: the set of actors is normally computed by the previous phase's resolver, and the first phase has no previous phase.

It is called once inside Start(), and the effects it produces go through exactly the same write point as every other effect, so they enter the effect log, replay, and the snapshot.

Same contract as every other extension point: it may read GameView only, it is called while the engine holds its lock, and it must not call back into any Engine method -- the consequence is a hang, not an error. See "Extension points must not call back into the engine" in doc.go.

type GameSetupFunc

type GameSetupFunc func(view GameView) []*Effect

GameSetupFunc lets a plain function satisfy GameSetup.

func (GameSetupFunc) Setup

func (f GameSetupFunc) Setup(view GameView) []*Effect

Setup implements GameSetup.

type GameView

type GameView interface {
	// Player returns a read-only copy of a player's information.
	Player(id string) (PlayerInfo, bool)

	// AlivePlayers returns every living player, sorted by ID.
	//
	// The ordering is something the rules may rely on: the order of the
	// effects a rule produces has to be uniquely determined by the board, or
	// replay and snapshot comparison lose their determinism.
	AlivePlayers() []PlayerInfo

	// AllPlayers returns every player including the eliminated ones, sorted
	// by ID.
	//
	// Victory checks need it: "how many special roles were there at the
	// start" has to count the dead ones too, and a wipe-out condition cannot
	// be computed from the living alone.
	AllPlayers() []PlayerInfo

	// AlivePlayerIDsByRole returns the IDs of living players with the given role.
	AlivePlayerIDsByRole(role RoleType) []string

	// RoundContext returns a read-only copy of this round's context.
	RoundContext() RoundContext

	// Var returns one piece of custom state in the given scope, or the empty
	// string if it is not set.
	//
	// Scopes form a 2x2 table (see VarScope):
	//
	//	Var(ScopeGame, "score")            whole game, unowned
	//	Var(ScopeGame.Of(id), "antidote")  whole game, one player
	//	Var(ScopeRound, "kill")            this round, unowned
	//	Var(ScopeRound.Of(id), "guarded")  this round, one player
	//
	// The rules keep all of their own state here, and built-in roles take the
	// same route as third-party ones. Writes go through NewSetVarEffect, and
	// a player's initial state is handed out by RoleSetup.
	Var(scope VarScope, key string) string

	// Round returns the current round number.
	Round() int

	// Phase returns the current phase.
	Phase() PhaseType
}

GameView is a read-only view of the game.

A Resolver is handed this rather than a *gameState. "Every state change goes through an Effect" is this engine's most important invariant, and it used to live only in the documentation with no help from the type system -- any Resolver, including a third-party one, could mutate state directly and bypass the whole effect pipeline, forfeiting replayability and auditability. The constraint is now part of the signature.

The view offers facts, never judgements: judging is the Resolver's job, so what you get here is "who was guarded last round", not "may I guard right now".

type Logger

type Logger interface {
	// Debug logs at debug level.
	Debug(msg string, fields ...Field)
	// Info logs at info level.
	Info(msg string, fields ...Field)
	// Warn logs at warning level.
	Warn(msg string, fields ...Field)
	// Error logs at error level.
	Error(msg string, fields ...Field)
}

Logger is the logging interface. It lets a caller inject their own logging implementation for game events and debugging information.

type Message

type Message struct {
	SenderID  string    // who sent it
	Content   string    // what was said
	Phase     PhaseType // the phase it was sent in
	Round     int       // the round it was sent in
	Timestamp time.Time // when it was sent
}

Message is an in-game message.

type MessageHandler

type MessageHandler func(msg *Message, receiverIDs []string)

MessageHandler handles one message. msg: the message itself. receiverIDs: who it should reach.

type PendingAction

type PendingAction struct {
	PlayerID string    // the player who should act; listed one by one when the step requires everyone
	Role     RoleType  // their role
	Skill    SkillType // the skill they have not submitted
}

PendingAction is one outstanding action.

type PhaseConfig

type PhaseConfig struct {
	Type      PhaseType     // which phase this is
	Steps     []PhaseStep   // its steps, in order
	Timeout   time.Duration // suggested timeout; advice, the engine does not time by it
	NextPhase PhaseType     // the default exit, overridable by a GOTO_PHASE effect

	// EndsRound means that once this phase resolves a new round begins: the
	// round number goes up by one and all round-scoped state is cleared.
	//
	// The kernel used to guess this for itself: "looping back to StartPhase
	// counts as a new round". In werewolf that guess happens to hold (night
	// -> day -> night); in another ruleset it does not -- the mission-based
	// games go round the loop once per nomination, so the engine's "round"
	// became a nomination counter, out by as much as a factor of five from
	// what those rules call "which mission we are on", and it was handed to
	// players verbatim in PlayerView.Round.
	//
	// What one round of a game is, only the rules know. The kernel no longer
	// guesses and reads this field instead: declaring it on a phase says
	// "once this phase resolves, the round is over".
	//
	// It **only governs counting**. When round-scoped variables are cleared
	// is a separate matter, declared by ClearsRoundVars below -- the two are
	// often marked on adjacent phases, but they are two different things.
	EndsRound bool

	// ClearsRoundVars means round-scoped variables are all cleared **before
	// entering** this phase.
	//
	// Read it as "this phase starts from a clean board" -- unlike EndsRound,
	// it describes how the phase begins, not what it does when it finishes.
	//
	// "The round number" and "a variable's lifetime" used to be welded
	// together, with EndsRound doing both jobs. In werewolf they happen to
	// coincide (a night marker lives until the next night, and that is
	// exactly one round), which is why nothing looked wrong. In the
	// mission-based games they do not:
	//
	//	team markers live until the next nomination begins   one mission may take five nominations
	//	the round number tracks which mission it is          or the number shown to players is meaningless
	//
	// So the mission rules had to clear them by hand in the nomination
	// resolver -- the kernel was one lifetime short and the rules made up the
	// difference. The two are now declared separately, and each phase speaks
	// only about itself.
	//
	// Validate checks that at least one phase declares it: with none, round
	// variables are never cleared, and in werewolf the antidote the witch
	// spent would go on saving the same person night after night.
	ClearsRoundVars bool
}

PhaseConfig configures one phase.

type PhaseInfo

type PhaseInfo struct {
	Phase       PhaseType                   // the current phase
	Round       int                         // the current round
	Steps       []PhaseStep                 // this phase's steps (both announcements and player actions)
	ActiveRoles []RoleType                  // the roles that act, excluding the system role
	RoleInfos   map[RoleType]*RolePhaseInfo // per-role information for this phase
}

PhaseInfo is information about the current phase, from the god's point of view.

The caller uses it to run this phase and make its announcements. It contains sensitive information -- the wolf roster, the kill the witch can see -- and must not be forwarded to players wholesale; for player-facing content use Engine.PlayerView.

func (*PhaseInfo) GodAnnouncementStep

func (p *PhaseInfo) GodAnnouncementStep() *PhaseStep

GodAnnouncementStep returns the announcement step, if there is one.

func (*PhaseInfo) NeedsGodAnnouncement

func (p *PhaseInfo) NeedsGodAnnouncement() bool

NeedsGodAnnouncement reports whether this phase opens with an announcement.

func (*PhaseInfo) PlayerActionSteps

func (p *PhaseInfo) PlayerActionSteps() []PhaseStep

PlayerActionSteps returns the player action steps, excluding the announcement.

type PhaseReadiness

type PhaseReadiness struct {
	Phase PhaseType // the current phase
	Round int       // the current round

	// Ready reports whether every Required step is satisfied.
	//
	// Note that it does **not** mean "everyone has acted": optional skills do
	// not count. While it is false the caller may keep waiting; whether to
	// force the phase forward on a timeout is the caller's call, and EndPhase
	// never refuses on the grounds of not being ready.
	Ready bool

	// Pending lists the required actions still outstanding. Empty when Ready
	// is true.
	Pending []PendingAction

	// Optional lists the players who may act in this phase but have not
	// submitted anything.
	//
	// It does not affect Ready -- declining is legal for them (this is what
	// rules phrasings like "or choose not to guard" mean). A host uses it to
	// decide whether to wait a little longer.
	Optional []PendingAction

	// Acted lists the players who have submitted a skill this phase, sorted
	// by ID.
	Acted []string
}

PhaseReadiness describes how far along the current phase's actions are.

The engine keeps no clock and will not decide for the caller when a phase ends -- but it holds every fact about who should act and who already has, so there is no reason to make the caller count for themselves.

Two questions, answered by Pending and Optional respectively

"Who still **must** act" and "who **may** act in this phase" are different questions, and in the default configuration only the wolf kill and the vote are Required -- the guard, the witch, the seer and the hunter may all decline. Drive the game off Pending alone and those roles are never called on for a whole game.

So Ready and Pending cover the required actions (the basis for advancing on a timeout), and Optional lists whoever may act but has not (the basis for a host nudging them along).

type PhaseStep

type PhaseStep struct {
	// Role is which role acts. RoleUnspecified means "every role", and
	// RoleSystem means "no player carries this step".
	Role RoleType

	// Skill is what this step submits.
	//
	// **Leaving it empty means "this role wakes, but takes no action"** -- it
	// only receives information and submits nothing. The One Night minion
	// opening their eyes to see the wolves, the masons recognising each
	// other, the insomniac looking at their own card are all this kind of
	// step: no target, no state change, just "it is your turn to learn
	// something".
	//
	// It mirrors RoleSystem: that one is "this step has no player", this one
	// is "this step has a player, who does not act". Together they complete
	// the four combinations of what a step in a phase can be.
	//
	// An empty step does not appear in AllowedSkills (there is nothing they
	// can submit) and does not enter the readiness decision (there is nothing
	// to satisfy), but it **does appear in PhaseInfo.ActiveRoles** -- the
	// host has to know who to wake, which is the entire reason such a step
	// exists.
	//
	// This was previously inexpressible, so a rules package had to hang a
	// SkillSkip on it as a placeholder -- and SKIP means "declining to act",
	// while they are not declining: there was never an action to decline.
	Skill SkillType

	// Required says whether the phase counts as ready only once this step is
	// done.
	//
	// The engine keeps no clock and will not refuse EndPhase over it -- it
	// only uses it to answer "who has yet to act" in
	// Engine.PhaseReadiness(), leaving the caller to decide between waiting
	// and advancing on a timeout. With no eligible actor at all (the guard is
	// dead, say), the step counts as automatically satisfied.
	Required bool

	// Multiple says whether every eligible actor has to act.
	//
	// true: done only once all of them have submitted (wolves agreeing on a
	// kill, everyone voting).
	// false: any one submission completes it.
	// It affects readiness only; how a phase's Resolver treats repeated
	// submissions is its own business.
	Multiple bool

	// Group is a mutually exclusive alternative group. Steps within one
	// phase sharing a non-empty Group are a pick-one-of set: an actor
	// submitting any one of them completes the whole group.
	//
	// The hunter's "shoot" and "do not shoot" are such a pair: without this
	// field, judging step by step would consider a hunter who submitted SKIP
	// as still owing a SHOOT, and marking both Required as the documentation
	// literally says would leave the phase never ready.
	//
	// It affects readiness only, not skill validation: which skills a phase
	// allows is still decided by all of its steps together.
	Group string

	// AllowDeadTarget says whether this skill may target an eliminated
	// player.
	//
	// By default it may not -- pointing a skill at a corpse is nearly always
	// a mis-submission. The witch's antidote is the exception: the person she
	// wants to save is precisely the one already marked dead tonight.
	//
	// The exception used to be hard-coded by skill name inside the kernel's
	// validation, meaning the kernel recognised "the antidote". It is now
	// data declared by the rules.
	AllowDeadTarget bool
}

PhaseStep is one step of a phase. Their order is the slice's order.

type PhaseType

type PhaseType string

PhaseType is a phase of play. The values are defined by the rules.

const (
	PhaseUnspecified PhaseType = ""
	PhaseStart       PhaseType = "START" // not started yet
	PhaseEnd         PhaseType = "END"   // already over
)

Three phases the kernel owns itself: they are the state machine's lifecycle, not a step in anybody's rules.

A rules package's phase cycle starts at Config.StartPhase and terminates at PhaseEnd; PhaseStart is the "not started yet" state itself, and AddPlayer is only allowed while the game is in it.

func (PhaseType) String

func (v PhaseType) String() string

String implements fmt.Stringer.

type PlayerInfo

type PlayerInfo struct {
	ID    string   `json:"id"`
	Role  RoleType `json:"role"`
	Alive bool     `json:"alive"`

	// RoundVars are this player's markers for the current round, cleared
	// every round.
	//
	// This used to be a bool called Protected -- "was this player guarded
	// tonight" is a werewolf concept and the kernel has no business knowing
	// it. It is now just a key the rules define, alongside every other
	// marker.
	RoundVars map[string]string `json:"round_vars,omitempty"`

	// Vars is the role's private state, under keys the rules choose.
	//
	// It deliberately appears only here, in the god's view, and not on the
	// player-facing SelfInfo: what goes into it is up to the role, and
	// handing it to the player by default would make every role work out for
	// itself whether each entry may be shown -- exactly the class of
	// judgement this library sets out to take off a caller's hands. What a
	// player should see is projected explicitly by the role through a
	// RoleInfoProvider.
	Vars map[string]string `json:"vars,omitempty"`
}

PlayerInfo is a read-only view of a player, from the god's point of view.

It contains information only the god should know, and must not be forwarded to players wholesale -- for what to send a player, use Engine.PlayerView.

func Mark

func Mark(p PlayerInfo, keys ...string) PlayerInfo

Mark adds this round's markers to a player and returns the modified copy.

func Seat

func Seat(id string, role RoleType, alive bool, vars ...string) PlayerInfo

Seat builds one player for use in a Board. vars is a variadic list of alternating keys and values.

engine.Seat("wi", "WITCH", true, engine.VarCamp, "GOOD", "witch.antidote", "1")

If the count is odd the trailing lone key is ignored -- this is a test helper, and a mistyped call is not worth an error return.

type PlayerSnapshot

type PlayerSnapshot struct {
	ID    string   `json:"id"`
	Role  RoleType `json:"role"`
	Alive bool     `json:"alive"`

	// RoundVars are this player's markers for the current round, cleared
	// every round. Who was guarded, healed or poisoned tonight all live here
	// -- they used to be three []string fields on RoundCtxSnapshot, and since
	// v8 they are folded into the player, on the same footing as the markers
	// a rules package defines for itself.
	RoundVars map[string]string `json:"round_vars,omitempty"`

	// Vars is the role's private state (werewolf's witch potions are in
	// here) -- they used to be two named bool fields, and since v7 they are
	// folded into Vars, on the same footing as a third-party role. Storing
	// this is what makes the whole mechanism work: without it a role's state
	// could only hide inside its Resolver, which is the very problem being
	// solved.
	Vars map[string]string `json:"vars,omitempty"`
}

PlayerSnapshot is one player's snapshot.

type PlayerView

type PlayerView struct {
	PlayerID string    `json:"player_id"` // whose view this is
	Round    int       `json:"round"`     // the current round
	Phase    PhaseType `json:"phase"`     // the current phase

	// Self is their own information: role, camp, whether they are alive.
	Self SelfInfo `json:"self"`

	// Players is the public information about everyone at the table, sorted
	// by ID. A role is filled in only where it is revealed to this view
	// (themselves, their teammates).
	Players []PublicPlayerInfo `json:"players"`

	// AllowedSkills are the skills they may submit this phase, never nil.
	// It is an empty slice when it is not their turn -- which is also how you
	// answer "is it my turn".
	AllowedSkills []SkillType `json:"allowed_skills"`

	// Teammates are the players this one is told are on their side; their
	// roles are revealed to them.
	//
	// Answered by the TeammateProvider (see WithTeammates); the kernel does
	// not know about camps. Werewolf's default implementation is "the other
	// players in the wolf camp" -- by camp rather than by role, or a custom
	// same-camp role from a rules package would find the teammate list empty.
	Teammates []string `json:"teammates,omitempty"`

	// RoleInfo is role-specific information: what this role additionally
	// lets them see.
	//
	// Answered by the role's own RoleInfoProvider (see WithRoleInfo); the
	// engine recognises no specific role. The built-in witch's kill target
	// and remaining potions live here (under the keys RoleInfoKillTarget /
	// RoleInfoAntidote / RoleInfoPoison) -- they used to be named fields on
	// PlayerView and SelfInfo, which made built-in roles first-class
	// citizens next to third-party ones, and adding a role should not
	// require editing the engine.
	RoleInfo map[string]string `json:"role_info,omitempty"`
}

PlayerView is everything one player is entitled to know at this moment, seen from where they sit.

Why it exists

The one genuinely hard thing about these games is who is allowed to know what. The engine used to offer the god's view only -- PlayerInfo could look up anybody's role, PhaseInfo handed over the wolf roster and the kill in one go -- and pushed the most safety-critical filtering onto the caller. One handler that slips and broadcasts a whole PhaseInfo voids the game on the spot.

A caller acting as the host does need the god's view; but it should not be forced to implement the projection itself. PlayerView pulls that back inside the library: give it a player ID and what comes back can be sent straight to them.

What it does not contain

A view is the state right now, not the history. The seer's past checks and the public record of deaths are history, and are carried by the effect log (Engine.EffectLog).

type PublicPlayerInfo

type PublicPlayerInfo struct {
	ID    string `json:"id"`
	Alive bool   `json:"alive"`

	// Role is filled in only where this player's role is revealed to this
	// view, and is UNSPECIFIED otherwise. The engine reveals only "yourself"
	// and "your teammates" by default -- whether an eliminated player's card
	// is turned over is a table rule, decided by the caller, and the engine
	// does not decide it for them.
	Role RoleType `json:"role,omitempty"`
}

PublicPlayerInfo is the publicly visible information about one player.

It, SelfInfo and PlayerInfo are three faces of the same player, and keeping them apart is not a naming coincidence: this type **structurally cannot hold** Vars, which turns "should they be shown this" into a question about signatures rather than one about runtime. Merging them into one type with optional fields would throw that guarantee away.

The rule is enforced by TestPlayerView_CarriesNoFreeFormState: any free-form state bag appearing in a player-facing struct turns it red.

type Resolver

type Resolver interface {
	Resolve(uses []*SkillUse, view GameView) []*Effect
}

Resolver resolves the conflicts of one phase.

An implementation may read GameView only, and may express state changes only by returning Effects -- the engine's most important invariant, held up by the signature rather than by convention.

Note: Resolve is called while the engine holds its lock, so an implementation must not call back into any Engine method -- the consequence is a hang, not an error. See "Extension points must not call back into the engine" in doc.go.

type ResolverFunc

type ResolverFunc func(uses []*SkillUse, view GameView) []*Effect

ResolverFunc lets a plain function satisfy Resolver.

Same thing as AudienceFunc and RoleSetupFunc. Of the eight extension points, Resolver and VictoryChecker were the only two without this adapter -- no reason, just history -- which meant that installing a three-line resolver first required declaring an empty struct.

func (ResolverFunc) Resolve

func (f ResolverFunc) Resolve(uses []*SkillUse, view GameView) []*Effect

Resolve implements Resolver.

type RoleInfoFunc

type RoleInfoFunc func(playerID string, view GameView) map[string]string

RoleInfoFunc lets a plain function satisfy RoleInfoProvider.

func (RoleInfoFunc) RoleInfo

func (f RoleInfoFunc) RoleInfo(playerID string, view GameView) map[string]string

RoleInfo implements RoleInfoProvider.

type RoleInfoProvider

type RoleInfoProvider interface {
	RoleInfo(playerID string, view GameView) map[string]string
}

RoleInfoProvider answers "what else should this player know".

Same shape as Resolver and VictoryChecker: it takes a read-only GameView, returns a conclusion, and touches no state. It is called while the engine holds its lock, so an implementation must not call back into any Engine method -- the consequence is a hang, not an error. See "Extension points must not call back into the engine" in doc.go.

Returning nil or an empty map means there is nothing extra. The keys are the role's own, and appear verbatim in PlayerView.RoleInfo and RolePhaseInfo.RoleInfo.

type RolePhaseInfo

type RolePhaseInfo struct {
	PlayerIDs     []string            // the players holding this role
	AllowedSkills []SkillType         // the skills they may use
	Teammates     map[string][]string // teammates, player ID -> teammate IDs; empty when they know of none

	// RoleInfo is role-specific information: player ID -> what that player
	// gets to see beyond the common facts.
	//
	// Answered by the role's own RoleInfoProvider; the engine recognises no
	// specific role. The built-in witch's kill target lives here under the
	// key RoleInfoKillTarget.
	RoleInfo map[string]map[string]string
}

RolePhaseInfo is one role's information for this phase.

type RoleSetup

type RoleSetup interface {
	Setup(playerID string, role RoleType) map[string]string
}

RoleSetup answers "what state does this role sit down with".

Same shape as Resolver, VictoryChecker and RoleInfoProvider: it touches no state and only returns a conclusion. The key/value pairs it returns are written verbatim into that player's Vars, and are afterwards read with GameView.Var(ScopeGame.Of(id), key) and changed with NewSetVarEffect.

Seating happens before the game starts, when there is no board to look at yet, which is why the signature has no GameView: an initial state can only be decided by the role itself, never by who sat down first or who else is at the table. Initialisation that does need to see the board (cupid pairing lovers, the thief picking a spare card) is a phase, and belongs in a Resolver.

Returning nil or an empty map means this role carries no initial state.

It is called while the engine holds its lock, so an implementation must not call back into any Engine method -- the consequence is a hang, not an error. See "Extension points must not call back into the engine" in doc.go.

type RoleSetupFunc

type RoleSetupFunc func(playerID string, role RoleType) map[string]string

RoleSetupFunc lets a plain function satisfy RoleSetup.

func (RoleSetupFunc) Setup

func (f RoleSetupFunc) Setup(playerID string, role RoleType) map[string]string

Setup implements RoleSetup.

type RoleType

type RoleType string

RoleType is a role. The values are defined by the rules.

const (
	// RoleUnspecified is unspecified. On a PhaseStep it means "every role".
	RoleUnspecified RoleType = ""

	// RoleSystem means "no player carries this step".
	//
	// It is not an identity, it is a **marker**: a phase step declaring it is
	// a broadcast (something is to be announced), not a wait for someone to
	// act. Seating it is rejected, and readiness does not count it.
	//
	// It used to be called RoleGod, with the value "GOD". That name implied
	// the identity of a host -- but a host is a werewolf concept, the
	// mission-based games have nobody hosting at all, and Blood on the
	// Clocktower calls theirs a storyteller. What the kernel recognises is
	// not "who is hosting", it is "this step waits for nobody". If you want a
	// role literally named god, name it in your rules package (that is
	// exactly what werewolf.RoleGod is).
	RoleSystem RoleType = "SYSTEM"
)

func (RoleType) String

func (v RoleType) String() string

String implements fmt.Stringer.

type RoundContext

type RoundContext struct {
	// Detours are the pending detours, first in first out.
	//
	// This used to be two fields belonging to one specific role, so every
	// role that shoots on death meant two more fields and one more branch in
	// the engine's phase transitions. As a queue, the engine recognises no
	// specific role.
	Detours []Detour

	// Vars is this round's custom state, cleared automatically each round,
	// belonging to no player.
	//
	// Werewolf's "tonight's kill" is stored here. It used to be a field above
	// called KillTarget which, together with three other maps, wrote "which
	// round state one particular ruleset has" into the kernel -- change the
	// ruleset and not one of the four is any use.
	//
	// The four scope cells (see VarScope): playerState.Vars follows a player
	// for the whole game, this one is cleared each round, and
	// playerState.RoundVars is "a marker on one player this round". Write
	// with NewSetVarEffect(ScopeRound, ...), read with
	// GameView.Var(ScopeRound, ...).
	Vars map[string]string
}

RoundContext is the round context, rebuilt each round. It holds the temporary state shared between the phases of one round: valid within this round, cleared automatically across rounds.

type RoundCtxSnapshot

type RoundCtxSnapshot struct {
	Detours []DetourSnapshot `json:"detours,omitempty"`

	// Vars is round-scoped custom state, including a third-party role's.
	Vars map[string]string `json:"vars,omitempty"`
}

RoundCtxSnapshot is the round context's snapshot.

type SelfInfo

type SelfInfo struct {
	ID    string   `json:"id"`
	Role  RoleType `json:"role"`
	Alive bool     `json:"alive"`

	// Camp is which side this player is on.
	//
	// An **opaque** label, taken from the canonical Vars key VarCamp. The
	// kernel only carries it: it does not know what "EVIL" means, nor
	// whether this player should know their own camp -- the rules decide
	// that when they hand out the initial state.
	//
	// Sub-divisions within a camp (werewolf's special roles vs plain
	// villagers) do not live here: that is the rules' own key, read from
	// Vars.
	Camp Camp `json:"camp,omitempty"`
}

SelfInfo is everything a player is entitled to know about themselves.

It deliberately does not reuse the god's-view PlayerInfo: that struct carries Protected (whether the guard shielded them tonight), and who the guard protected is the guard's exclusive information -- the moment the protected player knows, they know they cannot be killed tonight, and the guard's possible positions narrow sharply. A visibility difference of one field should not depend on the caller remembering to blank it.

type SkillType

type SkillType string

SkillType is a skill. The values are defined by the rules.

const (
	// SkillUnspecified is unspecified.
	SkillUnspecified SkillType = ""

	// SkillSkip declines to act. Every turn-based game has this move, so the
	// kernel provides one shared name for it instead of letting each rules
	// package invent its own.
	//
	// **It carries no kernel privilege.** validateSkillUse used to have a
	// branch reading "skipping needs no target, let it through" -- that branch
	// was empty: a submission with no target already passes target validation
	// (the loop never runs), and a submission that *does* carry a target
	// **should** be validated. Its only real effect was to make the kernel
	// recognise one specific skill, which is precisely what this library sets
	// out to eliminate.
	SkillSkip SkillType = "SKIP"

	// SkillAnnounce is a broadcast, paired with RoleSystem. The content is up
	// to the caller.
	SkillAnnounce SkillType = "ANNOUNCE"
)

func (SkillType) String

func (v SkillType) String() string

String implements fmt.Stringer.

type SkillUse

type SkillUse struct {
	PlayerID string    // the player using the skill
	Skill    SkillType // which skill

	// Targets are the skill's targets. The vast majority of skills have one;
	// a few name a whole set at once.
	//
	// This used to be `TargetID string` -- one target. That shape was fixed
	// by a sample size of one: werewolf's nine skills happen to have exactly
	// one target each. The missions package's "nominate a team" names 2-5
	// people at once and could only be split into several submissions, at the
	// cost of readiness being unable to say how many were still missing -- it
	// only knew whether the leader had submitted, and reported Ready=true
	// after one nomination out of two. That is the same class of problem as
	// "AllowedSkills telling an unqualified player he may act": the kernel
	// saying something untrue to a player.
	//
	// A single-target skill writes Targets: []string{"x"} and reads Target().
	Targets []string

	// The fields below are filled in by the Engine on submission; a caller
	// does not set them.
	Phase PhaseType
	Round int
}

SkillUse records one use of a skill.

Player speech does not go through the skill channel; it is handled by Engine.SendMessage, where visibility is routed by phase.

func (*SkillUse) Target

func (u *SkillUse) Target() string

Target is the one target of a single-target skill, or the empty string.

The vast majority of skills have exactly one target, and this saves them writing Targets[0] and checking for empty every time. A multi-target skill reads Targets directly.

type SkillUseSnapshot

type SkillUseSnapshot struct {
	PlayerID string    `json:"player_id"`
	Skill    SkillType `json:"skill"`
	Targets  []string  `json:"targets,omitempty"`
	Phase    PhaseType `json:"phase"`
	Round    int       `json:"round"`
}

SkillUseSnapshot is a skill submitted but not yet resolved.

type Snapshot

type Snapshot struct {
	Version int `json:"version"`

	Phase PhaseType `json:"phase"`
	Round int       `json:"round"`

	// Vars is state that lives for the whole game and belongs to no player.
	Vars map[string]string `json:"vars,omitempty"`

	// Actors are the per-phase actors the rules named. Such a list is often
	// computed in an earlier phase (the missions package picks the team
	// during nomination), so it has to travel with the snapshot, or a game
	// restored between nomination and the mission would lose its team.
	Actors map[PhaseType][]string `json:"actors,omitempty"`

	// Winner is who won this game, empty while it is undecided.
	//
	// It cannot be derived from anything else: who won was settled by the
	// VictoryChecker **at the moment the game ended** and does not change
	// afterwards, and a restored engine does not run the check again. Miss it
	// and a finished game restores as Over=true with an empty Winner --
	// Status claims its four fields come from one instant, and on this path
	// they would not line up.
	Winner Camp `json:"winner,omitempty"`

	Players      []PlayerSnapshot   `json:"players"`
	RoundContext RoundCtxSnapshot   `json:"round_context"`
	PendingUses  []SkillUseSnapshot `json:"pending_uses"`
}

Snapshot is the engine's complete serialisable state.

The snapshot types and the engine's internal types are deliberately two separate sets: the internal ones evolve with refactoring, while a snapshot is a format written to storage and its field names must stay stable. The conversion between them is all in this file, so adding or removing a field produces an explicit compile error here rather than silently losing data.

A snapshot does **not** contain the Config, the Logger, or the callbacks: the caller supplies those on restore, and the caller should own the versioning of the rules configuration itself.

Enums serialise by **name** ("NIGHT_GUARD", not 21). A save file is meant to be read by people and possibly by other languages, and numbers do not line up.

Since v10 the types themselves guarantee this: an enum is a string underneath, with no number-to-name translation layer left. A third-party custom value used to have no name and was written as a number (`"role":1000`), and is now a name like any built-in (`"role":"WOLF_KING"`) -- which is the entire difference between v9 and v10, and the reason it needed a version bump.

type SpeechFunc

type SpeechFunc func(senderID string, view GameView) []string

SpeechFunc lets a plain function satisfy SpeechProvider.

func (SpeechFunc) Receivers

func (f SpeechFunc) Receivers(senderID string, view GameView) []string

Receivers implements SpeechProvider.

type SpeechProvider

type SpeechProvider interface {
	Receivers(senderID string, view GameView) []string
}

SpeechProvider answers "if this player speaks right now, who hears it".

By convention the returned list includes the sender, so a caller can broadcast to it directly. Returning nil means they cannot speak at this moment.

It is called while the engine holds its lock, so an implementation must not call back into any Engine method -- the consequence is a hang, not an error. See "Extension points must not call back into the engine" in doc.go.

type Status

type Status struct {
	// Phase is the current phase.
	Phase PhaseType

	// Round is the current round, counting from 1.
	Round int

	// Over says whether this game is over.
	Over bool

	// Winner is who won, CampUnspecified while it is undecided.
	Winner Camp
}

Status is the game at a glance: where it is, whether it is over, who won.

This used to be four methods, Phase / Round / IsGameOver / Winner. Each took its own read lock, so **the four answers could disagree**: a host rendering "the day of round 3" had to ask twice, and if another goroutine resolved a phase in between, it read a combination of values that never held at the same time. Reading them once removes that.

All four are scalars and allocate nothing -- the "it's cheap" argument (no cloning of the whole board the way View does) still holds, it is just no longer spread across four names. For the player roster use AlivePlayerIDs; for a full board you can query repeatedly, use View.

Winner is settled by the VictoryChecker at the moment the game ends and does not change afterwards -- swapping the checker later does not rewrite a game that is already over.

type TeammateFunc

type TeammateFunc func(playerID string, view GameView) []string

TeammateFunc lets a plain function satisfy TeammateProvider.

func (TeammateFunc) Teammates

func (f TeammateFunc) Teammates(playerID string, view GameView) []string

Teammates implements TeammateProvider.

type TeammateProvider

type TeammateProvider interface {
	Teammates(playerID string, view GameView) []string
}

TeammateProvider answers "who is this player told is on their side".

The IDs it returns appear in PlayerView.Teammates and RolePhaseInfo.Teammates, and those players' roles are revealed to them. It excludes the player themselves; returning nil means they know of no teammates.

This relation is allowed to be **asymmetric**: the demon in Blood on the Clocktower knows its minions, and the reverse does not hold. The kernel does not check the two directions against each other.

It is called while the engine holds its lock, so an implementation must not call back into any Engine method -- the consequence is a hang, not an error. See "Extension points must not call back into the engine" in doc.go.

type VarScope

type VarScope struct {
	// contains filtered or unexported fields
}

VarScope is a variable's scope: how long a piece of custom state lives, and whom it belongs to.

A scope is a 2x2 table -- lifetime (whole game / this round) crossed with ownership (unowned / belonging to some player):

                 unowned       owned by a player
whole game       ScopeGame     ScopeGame.Of(id)
this round       ScopeRound    ScopeRound.Of(id)

The table used to exist only in a comment; the code had eight unrelated names (four constructors and four readers). Nothing forced it to be complete: a missing cell was nobody's job to notice, and in fact one was missing -- "whole game, unowned" -- until the mission-based rules ran into it: the score, the consecutive-reject count and whose turn it is to lead are all game-long and belong to nobody, and had to be filed under some arbitrary player as a ledger.

Now the four cells fall out of two values crossed with one method, and a missing cell is not expressible.

func (VarScope) Of

func (s VarScope) Of(playerID string) VarScope

Of binds a scope to a player, leaving the lifetime unchanged.

It returns a copy; the ScopeGame and ScopeRound values themselves are never modified.

func (VarScope) String

func (s VarScope) String() string

String is for logging and debugging, in the form game, round, game:p1, round:p1.

type VictoryChecker

type VictoryChecker interface {
	CheckVictory(view GameView) (over bool, winner Camp)
}

VictoryChecker decides whether the game is decided at this moment.

Returning (false, CampUnspecified) means it is not decided yet. winner may be any camp the rules like -- Camp is a string underneath, the kernel presumes no values and only reports the conclusion back verbatim.

Same contract as Resolver: it may read GameView only, and it is called while the engine holds its lock, so an implementation must not call back into any Engine method -- the consequence is a hang, not an error. See "Extension points must not call back into the engine" in doc.go.

type VictoryFunc

type VictoryFunc func(view GameView) (over bool, winner Camp)

VictoryFunc lets a plain function satisfy VictoryChecker.

Like ResolverFunc, this is filling a gap: the eight extension points should all be assembled the same way, and there was no reason for these two to be the exceptions.

func (VictoryFunc) CheckVictory

func (f VictoryFunc) CheckVictory(view GameView) (bool, Camp)

CheckVictory implements VictoryChecker.

Directories

Path Synopsis
Package enginetest runs random games against a set of general invariants, for every rules package to reuse.
Package enginetest runs random games against a set of general invariants, for every rules package to reuse.
example
missions
Package missions is this engine's second rules package: the mission-based social deduction of The Resistance and its Avalon variant.
Package missions is this engine's second rules package: the mission-based social deduction of The Resistance and its Avalon variant.
onenight
vocab.go is One Night's vocabulary: ten phases, eleven roles, thirteen skills, eleven events.
vocab.go is One Night's vocabulary: ten phases, eleven roles, thirteen skills, eleven events.
werewolf
Package werewolf 是狼人杀(Mafia)的规则引擎,零依赖。
Package werewolf 是狼人杀(Mafia)的规则引擎,零依赖。
werewolf/cli command
Package main 是一个可以真的从头玩完一局的命令行主持台。
Package main 是一个可以真的从头玩完一局的命令行主持台。
werewolf/demo command
Package main 演示如何使用狼人杀游戏引擎
Package main 演示如何使用狼人杀游戏引擎
werewolf/extension command
Package main 演示怎么在不 fork 这个库的前提下加一个新角色。
Package main 演示怎么在不 fork 这个库的前提下加一个新角色。
werewolf/netserver command
Package main 是一个 TCP 长连接的狼人杀服务端,也是这个库的第二个真实使用者。
Package main 是一个 TCP 长连接的狼人杀服务端,也是这个库的第二个真实使用者。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL