Documentation
¶
Overview ¶
Package host keeps tables running: a registry of table configurations, one goroutine per table that plays match after match with bot seats, sessions that subscribe to overview widgets or a focused table's event stream, turn-start snapshots that answer "view at seq N", append-only persistence, and crash handling that halts a table rather than hiding a bug. It imports rules and seat but exposes neither: a client sees protocol frames and view.Views only. It is the first package allowed to import time, and the only clock it uses is the injected Sleep.
Index ¶
- Constants
- Variables
- func MatchSeed(tableSeed uint64, k int) uint64
- func MatchTossWinner(tableSeed uint64, k, seats int) int
- func NewBotPolicySeat(name string, seed uint64) (seat.Seat, error)
- func NewBotPolicySeatWithAutoPayMana(name string, seed uint64, autoPayMana bool) (seat.Seat, error)
- func NewBotPolicySeatWithDeps(name string, seed uint64, autoPayMana bool, deps bots.Deps) (seat.Seat, error)
- func NormalizeBotPolicy(name string) (string, error)
- func RunCaretakerLifePlanContract(t ContractT, policy string)
- func RunClosedPrivacyContract(t ContractT, c ClosedPrivacyContract)
- func RunEnvLeakContract(t ContractT, c EnvLeakContract) uint64
- func RunShadowParityContract(t ContractT, o Options, policy string, seed uint64, maxIntents int)
- func SeededShuffle(seed uint64, in []string) []string
- func TallyDecks(matches []protocol.MatchInfo, minGames int) protocol.DeckTally
- type ClosedPrivacyContract
- type ContractRun
- type ContractT
- type Deck
- type EnvLeakContract
- type ErrBeyondHead
- type FeedbackLog
- type FeedbackMatch
- type FeedbackSnapshot
- type Format
- type FrameType
- type HumanSeat
- type OnBurstFunc
- type OnMatchEndFunc
- type OnRewindFunc
- type Options
- type Registry
- func (r *Registry) AddTable(c TableConfig) error
- func (r *Registry) AllMatches() []protocol.MatchInfo
- func (r *Registry) Close() error
- func (r *Registry) CloseSession(id string)
- func (r *Registry) Done() <-chan struct{}
- func (r *Registry) Events(id TableID, k int, since uint64) ([]protocol.EventBody, error)
- func (r *Registry) EventsSeat(id TableID, k int, since uint64, player state.PlayerID) ([]protocol.EventBody, error)
- func (r *Registry) Hello(s *Session) protocol.Frame
- func (r *Registry) LobbyTables() []protocol.TableInfo
- func (r *Registry) Matches(id TableID) ([]protocol.MatchInfo, error)
- func (r *Registry) OpenSession() *Session
- func (r *Registry) Pending(id TableID, k int, player state.PlayerID) (*decision.Decision, error)
- func (r *Registry) Provenance(id TableID, k int) ([]uint8, error)
- func (r *Registry) Publish(s *Session, f protocol.Frame) bool
- func (r *Registry) Session(id string) (*Session, bool)
- func (r *Registry) ShadowScan(id TableID, k int, seat state.PlayerID, policy string) ([]divergence.Divergence, error)
- func (r *Registry) SnapshotForFeedback(id TableID, seat *state.PlayerID) (FeedbackSnapshot, error)
- func (r *Registry) SnapshotMatch(id TableID, k int, seat *state.PlayerID) (FeedbackSnapshot, error)
- func (r *Registry) Start(id TableID) error
- func (r *Registry) StartAll() error
- func (r *Registry) SubmitIntent(id TableID, k int, player state.PlayerID, in decision.Intent) error
- func (r *Registry) SubmitIntentFrom(id TableID, k int, player state.PlayerID, in decision.Intent, auto bool) error
- func (r *Registry) Subscribe(s *Session, id TableID, mode string) error
- func (r *Registry) Tables() []protocol.TableInfo
- func (r *Registry) Undo(id TableID, k int, player state.PlayerID) error
- func (r *Registry) Unsubscribe(s *Session, id TableID) error
- func (r *Registry) ViewAt(id TableID, k int, seq uint64) (view.View, error)
- func (r *Registry) ViewAtSeat(id TableID, k int, seq uint64, player state.PlayerID) (view.View, error)
- func (r *Registry) Wait(id TableID)
- type SearchProbe
- type SeriesFunc
- type SeriesGame
- type Session
- type TableConfig
- type TableID
Constants ¶
const ( BotPolicy = bots.BotPolicy LethalPressurePolicy = bots.LethalPressurePolicy CastProfilePolicy = bots.CastProfilePolicy )
const DefaultMaxDecisionsPerTurn = 25000
DefaultMaxDecisionsPerTurn is the recommended value for Options.MaxDecisionsPerTurn — the one gorged installs for its served tables. The number is sized from measurement, not chosen round: the largest legitimate single turn measured across the gate population (sample-deck and repo-deck matches at 2/4/6/8 seats) is 244 decisions, and the observed policy stall ran ~10000 decisions per turn without the turn advancing, so 25000 clears the legitimate ceiling by ~100x and is swept by that stall class within three stall-turns. See the Options field's comment for the full reasoning, including why the unit is a count and never a duration.
Note this is NOT installed when the field is 0: zero means "no guard", so every caller that does not opt in keeps exactly today's behaviour.
Variables ¶
var ErrNotFound = errors.New("host: not found")
Functions ¶
func MatchSeed ¶
MatchSeed derives match k's engine seed from its table's seed with splitmix64's finaliser over tableSeed XOR k·φ, so a table's whole history is a pure function of its configuration (spec D14) and consecutive matches share nothing but the table.
func MatchTossWinner ¶
MatchTossWinner is the table seat that wins match k's CR 103.1 toss when no chooser is configured: rules.TossWinner over the match's engine seed (MatchSeed(tableSeed, k)). A SeriesFunc that reports Chooser -1 gets this seat for free; the value is exported so an embedder can show who would have won without asking the engine.
func NewBotPolicySeat ¶
NewBotPolicySeat builds a fresh deterministic seat for a supported hosted policy.
func NewBotPolicySeatWithAutoPayMana ¶
NewBotPolicySeatWithAutoPayMana builds a named hosted bot. Its public signature is load-bearing (cmd/cardfuzz and tests) and carries no card dependency: policies whose factory needs one refuse here, and the host's own paths build through NewBotPolicySeatWithDeps with host.Options.BotDeps.
func NewBotPolicySeatWithDeps ¶
func NewBotPolicySeatWithDeps(name string, seed uint64, autoPayMana bool, deps bots.Deps) (seat.Seat, error)
NewBotPolicySeatWithDeps builds a named hosted bot with the embedder's card dependency (BP-13): every bots.New the host makes carries host.Options.BotDeps, so a policy whose factory reads card facts — the sb-tactical heuristic (bots/sbtactical) — builds on a served table and refuses, with its own error, when a caller builds it bare.
func NormalizeBotPolicy ¶
NormalizeBotPolicy returns a hosted policy's stable name.
func RunCaretakerLifePlanContract ¶
RunCaretakerLifePlanContract asserts, for the hosted policy, that the host's human-seat caretaker offered only a life-paying payment plan answers from the ordinary options, while the same policy's hosted bot (auto-pay on) submits the plan.
func RunClosedPrivacyContract ¶
func RunClosedPrivacyContract(t ContractT, c ClosedPrivacyContract)
RunClosedPrivacyContract plays whole hosted games, the policy (seat 0) against a human (seat 1, answered by the host's own timeout caretaker), and proves the human's unrevealed cards never reach the bot.
revealed, the ground truth, is built from the LIVE game independently of the feed and the redealer: a secret card counts as revealed once its object has been the subject of an event into a public zone or onto the stack, or named in a public reveal (a non-secret Note's IDs), or shown in a View the bot seat was handed. The assertion, at every decision of the bot seat: the secret names found in what the host handed the seat -- the Env's Setup, root engine, Board, feed and refusal reason; the Board and View the non-searched paths get -- are a subset of revealed. The root engine is walked whole, through unexported fields, except the fields named in skipFields (each is a residual the az-c4 report's leak audit names). Every searched decision must get a root (bar posed-resolution refusals), and the games must give real evidence: at least 10 roots, and at least 10 decisions at which a secret card was still hidden.
func RunEnvLeakContract ¶
func RunEnvLeakContract(t ContractT, c EnvLeakContract) uint64
RunEnvLeakContract is the hidden-card leak row: two live matches built around the REAL hosted policy at slot 0 (a heuristic bot at slot 1), driven through the REAL host Env path, with the swap fixture (swapOpponentHidden) applied to the second match's live engine at the first decision the policy wants an Env for (or, with a Probe, the first decision it searched). The decision played at the asserted boundary must be answered identically from two honest roots whose hidden worlds are name-identical, while the two live positions are not: what the honest root deals is a function of the seat's observation and the seed alone. It returns the asserted decision's seq.
func RunShadowParityContract ¶
RunShadowParityContract is the shadow-scan parity row for policy: a 2-seat table whose seat 0 answers every decision with a FRESH bots.New(policy) seeded ShadowSeed(seed, 0, n) through the host's own dispatch (Env, then Board, then View) is, to the scan, indistinguishable from its shadow: ShadowScan reports 0 divergences over every decision the seat answered. maxIntents caps the match (0 plays the whole game; a search policy is capped, since a searched decision costs a whole search). o is the registry's base options; set o.BotDeps to the policy's card dependency.
func SeededShuffle ¶
SeededShuffle returns in reordered by a deterministic Fisher-Yates keyed on seed, so a caller can assign decks "at random" and have the assignment be a pure function of the seed: the same seed reproduces the same order, which is what keeps an on-demand game replayable from its configuration. It is the one place in host that turns a seed into an ordering; the game creator (cmd/gorged) shuffles a format's deck pool with the game's own seed, and the resulting order is persisted verbatim in TableConfig.Decks, so both the dealt decks and the match are reproducible from the recorded config. in is not modified.
func TallyDecks ¶
TallyDecks aggregates per-deck results over finished bot-vs-bot matches.
Counted: State == finished with no Human seat, and either Result "draw" (every seat draws) or a non-nil Winner naming a seat. Excluded: live, aborted, crashed, any match with a human seat, and finished matches whose outcome is unreadable (nil Winner that is not a draw, or a winner index outside the seat list).
Each seat counts for its deck: the winner's deck gets a win, every other seat's deck a loss, and a draw is a draw for all seats. A mirror match (every seat plays the same deck) is skipped entirely, because it says nothing about the deck's strength: it would add one win and one loss for the same name. A match that merely has a repeated deck among different decks still counts each seat.
Decks with fewer than minGames games are dropped. Output is sorted by win rate desc, games desc, name asc; win_rate is wins/games.
Types ¶
type ClosedPrivacyContract ¶
type ClosedPrivacyContract struct {
// Options is the registry's base options; the contract sets LoadDeck
// (decks "bot" and "human"), Seats, ThinkTimeout, MaxIntents and the
// unbounded-search knobs over it.
Options Options
// Policy is the registered closed-list policy under test; its seats are
// built with bots.New(Policy, ...).
Policy string
// Belief is the belief the policy was enabled with: its corpus is
// public knowledge, so a card in it is never secret.
Belief *searchprobe.Belief
// BotDeck is the policy's own deck; HumanDeck and HumanSideboard the
// human's. Every card of the human's that is in neither the belief nor
// BotDeck is secret; the fixture must yield at least 8 secret names.
BotDeck, HumanDeck, HumanSideboard []*cards.Card
// Seeds are the games played, one single-shot human table each.
Seeds []uint64
}
ClosedPrivacyContract configures RunClosedPrivacyContract.
type ContractRun ¶
type ContractRun struct {
Info protocol.MatchInfo
// Log is a copy of the match's event log (seed, events, intents).
Log *events.Log
// Reason is the match's end or crash reason.
Reason string
// RootRefusals counts the honest roots the host refused (BP-08).
RootRefusals int
// Feeds reports whether the match kept observation feeds (an Env seat).
Feeds bool
}
ContractRun is one table's finished match as RunContractTable saw it.
func RunContractTable ¶
func RunContractTable(t ContractT, o Options, cfg TableConfig) ContractRun
RunContractTable builds a registry from o, adds cfg, plays its first match to completion, and replays the match's log under the match's own rules configuration: the replay must reach the recorded head (a failure is fatal). The registry is closed before it returns.
type ContractT ¶
type ContractT interface {
Helper()
Logf(format string, args ...any)
Errorf(format string, args ...any)
Fatalf(format string, args ...any)
Cleanup(func())
}
ContractT is the part of testing.TB the contract harness uses; a *testing.T satisfies it.
type Deck ¶
type Deck struct {
Name string
Cards []*cards.Card
Sideboard []*cards.Card
// Archetype is the deck file's authoring archetype (deck.File.Archetype),
// "" when it declares none. The loader carries it so a seat's own prior
// rides rules.Config.Archetypes into deck.Manifest and reaches
// view.View.OwnDeck. It never reaches an event.
Archetype string
Commanders []int
}
Deck is a resolved deck list: the name a seat is called and the cards it is dealt. Options.LoadDeck produces it; the host never reads files for decks itself.
Commanders is the flat indices into Cards that are this deck's commanders, in the deck file's order (one or two — deck.File.CommanderNames); the engine places those objects in the command zone at genesis. nil for a constructed deck. The loader — which owns the deck files — computes it (deck.File.CommanderIndex, validated by deck.ValidateCommander), so the host never guesses which card a deck means by its commander.
type EnvLeakContract ¶
type EnvLeakContract struct {
// Options builds the registries the row drives (a deck loader serving
// "a" and "b" at least). The row overrides nothing in it.
Options Options
// Policy names the policy in messages.
Policy string
// NewSeat builds a FRESH seat of the policy for one match (a hosted bot
// carries its own RNG state, so the two driven matches never share one).
NewSeat func(actor uint64) seat.Seat
// ViewPath selects the View-path spy, for a policy that implements no
// heuristic.BoardSeat (sb-tactical and the SpellBench adapters): a spy
// with a DecideBoard would send the host down a branch the policy never
// takes in production. It forwards seat.PaymentPlanConsumer and
// bots.RefusalAnswerer.
ViewPath bool
// Probe, when non-nil, places the swap at the first decision the seat
// actually SEARCHED (found by a deterministic unswapped pre-pass of the
// same seed and seats) and re-verifies that both matches searched it:
// a search that skips a decision without consulting its world source
// would otherwise make the row vacuous.
Probe *SearchProbe
// PlannerOf, when non-nil, returns the planner the policy's seat (the
// one NewSeat built) holds after answering; at the asserted boundary it
// must be the honest root the Env carried, in both matches (the §5.1
// SetPlanner routing). It returns the planner as an any so this
// signature names no engine type.
PlannerOf func(seat.Seat) any
}
EnvLeakContract configures RunEnvLeakContract for one hosted Env policy.
type ErrBeyondHead ¶
type ErrBeyondHead struct{ Head uint64 }
ErrBeyondHead says a requested seq is past the match's last event; Head is the last valid seq, which the http layer returns with a 409.
func (ErrBeyondHead) Error ¶
func (e ErrBeyondHead) Error() string
type FeedbackLog ¶
type FeedbackLog struct {
events.Log
Head string `json:"head"`
IntentCount int `json:"intent_count"`
Turn int32 `json:"turn"`
Step string `json:"step"`
Priority state.PlayerID `json:"priority"`
Active state.PlayerID `json:"active"`
}
FeedbackLog is log.json's content: the match's events.Log as of the snapshot — Seed, Events and Intents, the exact replay input — plus where that prefix stood: Head (the chain hash at that prefix, what a replay must reproduce), IntentCount, and the game's Turn, Step and Priority/Active players read off the same locked instant the log was copied. The Log is embedded, so its own keys (seed, events, intents) marshal at the top level and a caller unmarshals straight back into an events.Log.
type FeedbackMatch ¶
type FeedbackMatch struct {
Table string `json:"table"`
Match int `json:"match"`
Seed uint64 `json:"seed"`
Seats []protocol.SeatInfo `json:"seats"`
Names []string `json:"names"`
PlayerNames []string `json:"player_names,omitempty"`
Decks []string `json:"decks"`
DeckCards [][]string `json:"deck_cards"`
Sideboards [][]string `json:"sideboards,omitempty"`
Spectator string `json:"spectator"`
State string `json:"state"`
Result string `json:"result,omitempty"`
Winner *uint8 `json:"winner"`
Head string `json:"head,omitempty"`
Events int `json:"events"`
Turns int32 `json:"turns"`
Reason string `json:"reason,omitempty"`
Mulligans int `json:"mulligans,omitempty"`
Format Format `json:"format,omitempty"`
StartingLife int32 `json:"starting_life,omitempty"`
Commanders [][]int `json:"commanders,omitempty"`
// StartChooser mirrors the sidecar's field of the same name: the
// configured seat posed CR 103.1's starting-player choice in place of
// the toss winner (E3-2). It is genesis replay input like Mulligans —
// the genesis Note names the chooser — so a replay rebuilt from
// match.json alone needs it recorded. Absent = the toss, which every
// match before E3 used.
StartChooser *int `json:"start_chooser,omitempty"`
BotPolicy string `json:"bot_policy"`
// NameUniverse and NameUniverseNames mirror the sidecar's fields of the
// same names, and for the same reason the deck contents and the token
// scripts are here: a name-card universe is a match MODE, not data the
// log can recover. A match played with one poses NameCard asks the
// legacy no-universe path never poses, so a replay rebuilt from
// match.json alone with the mode missing finds no decision pending at
// the recorded intent and refuses. NameUniverseNames is the immutable
// sorted label list that match actually offered, so a corpus addition
// or rename after the report was filed cannot renumber a recorded
// numeric name choice. A capture from a pre-feature match carries
// neither field, which reads back as the legacy path (R-8.4).
NameUniverse bool `json:"name_universe,omitempty"`
// NameUniverseNames is stripped from a COMMITTED fixture (it is ~24k
// entries, half a megabyte per fixture): the committed shape keeps only
// the mode bit and the replay re-derives the list from the live corpus,
// diverging on a corpus pin move exactly the way a token-stripped
// fixture does, and saying so.
NameUniverseNames []string `json:"name_universe_names,omitempty"`
// PaidManaCensus mirrors the sidecar's field of the same name: whether the
// live match ran with the widened paid-cost mana census. Like the name
// universe it is a match MODE, not data the log can recover — the census
// changes the offered option list, so a replay rebuilt from match.json
// alone without the bit takes a different option list and diverges at the
// first census-sensitive decision. A pre-flip capture carries no field,
// which reads back as census OFF (the narrow census those matches ran).
PaidManaCensus bool `json:"paid_mana_census,omitempty"`
// Tokens carries the raw token scripts — keyed by file stem, the exact
// spelling a card's TokenScript$ parameter uses — behind the match's
// rules.Config.Tokens. rules.Config.Tokens is replay input (rules.New
// copies it onto Game.Tokens, and events.Apply's TokenCreate case mints
// from it), so a replay rebuilt from match.json alone needs it exactly
// as it needed the deck contents: without it, the replayed engine's
// effToken emits an "unknown token script" Note where the log records a
// TokenCreate, and the replay diverges at that event.
//
// The scripts are shipped as text, not as compiled cards, because a
// compiled *cards.Card cannot survive a JSON round trip: its derived
// P/T, CMC and colour-identity fields are unexported (engine/cards/ir.go), so
// a JSON decode would leave them zero — a 1/1 Goblin replayed as a 0/0.
// A token Card IS fully determined by its script text, and the repro
// tool recompiles each entry with cards.ParseBytes + Card.Link +
// ApplyIntrinsics — the exact pipeline compileScripts runs the corpus
// through — so the rebuilt cards are the ones the match played with.
//
// The whole cfg.Tokens is captured, not only the tokens some deck seems
// to reference: the engine never enumerates which tokens a deck can
// create (TokenScript$ reaches effToken through spell abilities,
// triggers, replacements, keyword expansions and SVar bodies alike), so
// any filtered capture could miss an indirect creator and reproduce the
// very unknown-token divergence this field exists to prevent. The whole
// pinned corpus's token text is ~150 KB, noise beside the snapshot cap.
Tokens map[string]string `json:"tokens,omitempty"`
// TokensUnread lists the tokens whose script text could not be read
// back, as "<stem>: <reason>". The live match's cfg.Tokens holds the
// compiled cards; only each card's own source path needs reading back,
// and a miss (a corpus file moved or vanished since the match started,
// or a synthetic token compiled from bytes with no file behind it) is
// recorded here rather than dropped silently: a replay that later
// reaches one of these stems will diverge the same way a missing
// Tokens table would, and match.json says why.
TokensUnread []string `json:"tokens_unread,omitempty"`
}
FeedbackMatch is match.json's content: the sidecar of the table's current match — the same fields server/host/persist.go persists, in the same shape — plus DeckCards, the deck CONTENTS (card name per card, in deck order, one list per seat), and Sideboards, the sideboard CONTENTS the same way (card name per card, in sideboard order, one list per seat; a seat with no sideboard carries nil). Sideboards are genesis configuration exactly like Decks — the engine mints their objects before the first event — so a replay rebuilt from match.json alone needs them recorded here, or the genesis object IDs shift and the replayed match diverges at the first shuffle. Like DeckCards, the list is what a replay is actually built from, so the snapshot never depends on the deck files still existing. The fields mirror sidecar's on purpose: a test pins that the two marshal the same keys, so the snapshot cannot drift from the persisted shape without failing.
type FeedbackSnapshot ¶
type FeedbackSnapshot struct {
Match FeedbackMatch
Log FeedbackLog
View *view.View
ViewErr string
}
FeedbackSnapshot is everything a feedback report captures about the table's match: the replayable configuration (Match), the event log as of the submit (Log), and — when the report named a seat — that seat's own redacted view of the board (View). View is nil when the report named no seat, or when the projection failed (ViewErr says why): a failed view never discards the match and log, which are the part a reproduction needs, and it can never carry another seat's hidden zones — View is projected with seat (viewer-scoped) visibility, the same redaction ViewAtSeat serves the player's own client.
type Format ¶
type Format uint8
Format is a table's construction format, named on the wire like a Visibility: "constructed" is the zero value and the only value every table before m39 carried; "commander" runs the CR 903 rules the engine gates on its own rules.Format. The host-local enum exists so TableConfig — persisted verbatim in tables.json — reads as names, the same convention Spectator follows; newMatch converts it to the engine's int format at match build, so the two never need to grow in lockstep.
const ( // FormatConstructed is a 20-life game with no command zone — the zero // value, so a tables.json written before the field existed loads as // constructed and validates exactly as it always did. FormatConstructed Format = iota // FormatCommander is a Commander game: 40 life (CR 903.6), a command // zone built from each seat's deck's commanders, and the CR 903.8 tax // and CR 903.10 damage clock — whatever the engine's FormatCommander // gates on. FormatCommander )
func ParseFormat ¶
ParseFormat is String's inverse for flags and table configs.
func (Format) MarshalText ¶
MarshalText/UnmarshalText make a Format its name in JSON, so a table configuration on disk reads "commander", not 1.
func (Format) String ¶
String is the wire name; an out-of-range value prints "unknown", the same total shape as state.Step.String.
func (*Format) UnmarshalText ¶
type HumanSeat ¶
type HumanSeat struct {
// contains filtered or unexported fields
}
HumanSeat is a seat.Seat that can hold one decision pending and be answered from outside (Task M2b-2's SubmitIntent). It is "just another seat.Seat": the engine hands it the same view.View and decision.Decision it hands any bot, and blocks on Decide until an intent is submitted, ctx is cancelled, or ThinkTimeout elapses. It carries a caretaker seat (Task M2b-3): the deterministic bot that would have occupied this slot in a pure-bot game, so an unanswered decision is turned by exactly the intent that bot would have produced and the logged game replays byte-identically (D3).
func NewHumanSeat ¶
func NewHumanSeat() *HumanSeat
NewHumanSeat returns a HumanSeat with no decision pending.
func (*HumanSeat) Decide ¶
func (s *HumanSeat) Decide(ctx context.Context, v view.View, d decision.Decision) (decision.Intent, error)
Decide records d as the pending decision and blocks until a matching intent is submitted, ctx is done, or ThinkTimeout elapses — park immediately followed by await. It satisfies seat.Seat. The plain seat API never sees an undo channel, so Decide's parking carries a nil one (a nil channel never fires).
type OnBurstFunc ¶
OnBurstFunc observes one recorded burst of a match's event chain: evs is that burst's events (the slice appended since the previous burst), in is the intent that drove it (nil for the genesis burst, which has no decision). t is the producing table and k the match number, so one sink can serve many tables and matches (Task M2c-1, FL-81: mtgserve runs the registry in-process and persists every match to SQLite through this hook).
The callback runs on the match's own goroutine, while the table's match lock is held — so it must NOT call back into the Registry (that re-enters the same goroutine holding the lock and deadlocks), and it must not block on anything that could, in turn, wait on this goroutine. It should copy anything it wants to keep: evs is a fresh slice (a stable snapshot of this burst) but the events' guts and in are read-only references into the live match. Returning an error crashes the match exactly as a persist failure does (D15): the table halts and the event chain does not continue.
A nil OnBurst is today's behaviour — nothing fires — so an embedder that does not set it pays no cost and sees no change.
type OnMatchEndFunc ¶
OnMatchEndFunc observes the terminal state of a finished, aborted or crashed match. It runs on the match's own goroutine after the match's final state has already been recorded, and like OnBurstFunc it must not call back into the Registry or block. Its error return cannot change that already-decided outcome and is only surfaceable by the embedder itself; a non-nil sink that needs an outcome to succeed should log/verify inside the callback.
type OnRewindFunc ¶
OnRewindFunc observes one truncation of a live match's log (see Undo). toIntent is the intent count the match was rewound TO (its log now holds exactly that many intents) and headSeq is the seq of the new chain head — the last event the match KEPT. An embedder that persists bursts through OnBurst must, on this hook, drop every stored event past headSeq and every stored intent past toIntent for (t, k): later OnBurst bursts legitimately RE-USE seq numbers at or below the old head (the replayed game re-decides the undone prefix), so the truncation is what keeps the sink's stream a prefix-consistent chain rather than a log with a rewritten tail.
Exactly one OnRewind fires per undo, on the match goroutine with the match lock held (the same discipline as OnBurst), BEFORE any new burst — so the sink never sees a post-rewind burst it has not been told to make room for. A nil OnRewind is a no-op. A returned error crashes the match exactly as an OnBurst error does (D15): a sink that cannot record the truncation must not keep receiving a chain it has diverged from.
type Options ¶
type Options struct {
Dir string
LoadDeck func(name string) (Deck, error)
// Tokens is the token corpus the engine needs (rules.Config.Tokens):
// the token definitions the decks in this match can create. Passed into
// every rules.Config the host builds — live matches and the replays
// that serve finished ones from disk — so a persisted match replays
// with the same token definitions (Ruling FL-40).
Tokens map[string]*cards.Card
// NameUniverse is the compiled card corpus a NameCard decision ranges
// over (rules.Config.NameUniverse): Pithing Needle names any card, a
// land included, and this is the only source of names a seat has never
// seen. Passed into every rules.Config the host builds, live and replay
// alike, for the same reason Tokens is (the replay must offer the same
// options the live match did).
NameUniverse *cards.Universe
// Sleep is the table's only clock read (PL-11): run calls it between
// matches for Cooldown, and play calls it after every decision for
// Pace. It must return once d elapses OR stop closes, whichever comes
// first — that is what lets Close interrupt a table sitting in a long
// cooldown or a slow pace (Ruling FL-18) instead of blocking up to d.
// A nil Sleep gets defaultSleep, which does exactly that with a real
// timer; it is the only place in the package that touches the clock,
// so a caller wanting a faster-than-realtime test still goes through
// this field rather than host reading time.Now/time.Sleep itself.
Sleep func(d time.Duration, stop <-chan struct{})
// Seats is an explicit embedder controller override. Nil selects the
// table's named hosted policy through defaultSeats in play.
Seats func(names []string, seed uint64) []seat.Seat
Sync bool
Ring int
Cooldown time.Duration
// MaxIntents caps a whole match's total intent count (play's final
// termination guard, 400000 by default — see match.go). It bounds a
// match that will not terminate but says nothing about where it is
// stuck, so it is a different question from MaxDecisionsPerTurn below.
MaxIntents int
// MaxDecisionsPerTurn is the host's progress guard for the stall that
// keeps the SAME turn alive forever — the shape observed in the wild when
// a bot re-offers one no-op action repeatedly (seed 175's 20000 intents
// inside 2 turns; the measured 36 of 200 four-seat Commander games frozen
// on main by a policy that kept re-activating an Equip on the creature
// already carrying it). play counts decisions answered since the last
// turn advance and, when the count reaches this limit with the turn
// still unchanged, crashes the match with a stall reason and halts the
// table through the ordinary crash path. Before this guard such a match
// sat TableLive forever — the lobby showed LIVE, no match ever followed —
// because Options.ThinkTimeout covers only a HumanSeat parked on a
// decision, and a bot loop parks on nothing.
//
// The unit is a DECISIONS count, never a duration. A wall-clock watchdog
// would make a match's outcome depend on how loaded the box was, which
// destroys the determinism the whole engine is built on: the same seed
// must produce the same event chain and the same result on every machine
// and every replay (botbench's own watchdog is a -max-intents count for
// the same reason). A turn that is merely LONG still passes: the counter
// resets on every turn advance, and that reset is what distinguishes
// "one very long turn" from "a turn that never ends". The measured
// stall did ~10000 decisions per turn without the turn advancing; the
// largest LEGITIMATE single turn across this repo's whole gate
// population (2/4/6/8-seat sample-deck and legacy and Commander
// repo-deck matches, 40+ games, measured for this task's sizing) is 244
// decisions.
//
// DefaultMaxDecisionsPerTurn (25000) is the recommended production value
// — 100x the measured legit ceiling above, and below the observed
// stall's own ~10000-per-turn rate, so the stall class that froze the
// demo trips within its third turn instead of freezing the table. 0 —
// the zero value — means "no guard": every existing test and embedder
// that never sets the field keeps exactly today's behaviour, so this
// option is strictly opt-in. 0 ALSO disables the engine's own livelock
// watcher (rules.Config.LoopGuard.Disabled, wired in match.go): the two
// are the same non-terminating-loop protection at two levels, and the
// engine aborting a game whose stall guard the embedder explicitly
// turned off would defeat the opt-out.
MaxDecisionsPerTurn int
// ThinkTimeout is how long a HumanSeat parks on a decision before its
// deterministic caretaker bot (the already-seeded bot for that slot) is
// asked to answer in the player's place (Task M2b-3, D3). 0 — the default
// — means "no timeout": the seat waits for a human SubmitIntent forever,
// and only falls back to the caretaker when the table's context is
// cancelled (a disconnected human seat must never wedge play, FL-17).
// A non-zero value is a live clock read (host is the package allowed
// time) that converts an unanswered decision into exactly the intent the
// slot's bot would have produced, so a timed-out human game replays
// byte-identically (the caretaker intent is committed to the log like any
// other). The player may reconnect and answer later decisions via
// SubmitIntent (D2).
ThinkTimeout time.Duration
// DefaultBotAutoPayMana is applied only while restoring a table written
// before TableConfig recorded bot_auto_pay_mana. New tables always carry
// their explicit setting, including false, so a restart preserves their
// configured behaviour. gorged supplies its -bot-auto-mana startup flag
// here to migrate an existing deployment when it first runs this build.
DefaultBotAutoPayMana bool
// DefaultBotCommonSense is the same restore-time migration for
// TableConfig.BotCommonSense (the bots/sanity CommonSense guard): a table
// written before TableConfig recorded bot_common_sense adopts the
// server's -bot-common-sense startup flag when it is first loaded by this
// build, while a new table's explicit setting — including false — always
// survives a restart verbatim. Default false leaves every pre-field
// deployment byte-identical to the unwrapped policies it served.
DefaultBotCommonSense bool
// MaxOnDemandTables bounds browser-created private tables retained by a
// running process. 0 keeps the historical unlimited behaviour. A limit
// refuses a new game instead of deleting a finished table: its private
// join URL, feedback capture and replay routes remain valid until the
// process restarts, when OnDemand's documented process-scoped cleanup
// removes its config.
MaxOnDemandTables int
// SearchSlots bounds how many searched decisions run CONCURRENTLY across
// the whole registry (BP-10, spec 2026-09-28-hosted-bot-packages §7): a
// bot seat whose table-policy entry has Search set takes one slot around
// its DecideEnv call (server/host/match.go parkSeat), and a decision that arrives
// while every slot is held QUEUES — the host never degrades to another
// policy, never shortens a search and never times one out, so intents
// stay a pure function of the seed and the intent stream; only latency
// varies with load. The queue is FIFO (a mutex plus a slice of waiter
// channels — Go does not specify FIFO for a buffered-channel semaphore),
// and the wait selects on the table's context, so Close turns a queued
// decision into a match abort, not a crash. 0 — the zero value — means
// unbounded, so embedders and tests that never set the field keep exactly
// today's behaviour. gorged sets it from -bot-search-slots.
SearchSlots int
// MaxSearchTables bounds the LIVE on-demand tables whose policy entry has
// Search set: AddTable refuses a new on-demand search table when that
// many are already playing (live, not finished — a table that reached its
// end frees its capacity), with the explicit refusal "search bots are at
// capacity; choose a non-search policy". It mirrors the MaxOnDemandTables
// check above and bounds the second memory term of §7 — each concurrent
// search holds its honest root for the length of a decision. 0 means
// unbounded. gorged sets it from -max-search-tables.
MaxSearchTables int
// BotSearchParallelism is passed into bots.Options.SearchParallelism for
// every seat the registry builds from a table's named policy (§7): a
// search entry's inner worlds fold in sequential order either way, so the
// flag changes latency only, never intents. 0 leaves the entry's own
// default. gorged sets it from -bot-search-parallelism.
BotSearchParallelism int
// BotDeps is the card dependency every hosted bot factory receives
// (BP-13, spec §3.2): it rides bots.Options.Deps into every bots.New the
// registry's table path makes — defaultSeatsWithAutoPayMana and the
// caretaker (bot_policy.go) — so a policy whose factory reads printed
// card facts (sb-tactical) can build. A zero Deps keeps every policy
// already hosted before BP-13 unchanged and makes sb-tactical refuse at
// seat-build time with its own error. gorged sets Cards to the opened
// corpus registry.
BotDeps bots.Deps
// BotUnboundedDecisions, when true, builds every table bot with a zero
// per-decision budget (bots.Options.DecisionDeadlineMS 0 = unbounded, the
// bench/training posture) instead of the served default
// hostedDecisionDeadlineMS. A determinism harness that compares two runs
// of one seed must never arm a wall clock: a deadline bail-out answers
// from the non-searched fallback and the two runs diverge (BP-07 §7
// rejected a wall-clock budget for exactly this reason). False — every
// existing caller, gorged included — leaves served behaviour
// byte-identical.
BotUnboundedDecisions bool
// OnBurst, when non-nil, is invoked after every recorded burst of every
// match created by this registry, including the genesis burst, so an
// embedder sees the whole chain from its first event (Task M2c-1). It is
// the SQLite-persistence hook for mtgserve. See OnBurstFunc for the
// contract — most importantly, the callback runs on the match goroutine
// holding the match lock and must not re-enter the Registry.
OnBurst OnBurstFunc
// OnMatchEnd, when non-nil, is invoked once per match once it reaches a
// terminal state (finished, aborted or crashed), with that match's final
// MatchInfo (Task M2c-1). See OnMatchEndFunc.
OnMatchEnd OnMatchEndFunc
// Series plans the matches of every table whose TableConfig.Series is
// set (E3-2): before match k of a series table, run calls this hook with
// the table's id and k (1 included) and plays the SeriesGame it returns —
// the deck each SEAT plays (never rotated by k) and the seat posed CR
// 103.1's starting-player choice (rules.Config.StartChooser; -1 keeps
// the seeded toss). The hook runs on the table's own goroutine holding
// no Registry or table lock and may block for minutes between the games
// of a series — the players are sideboarding — so run takes no lock
// around the call and a Registry.Close unblocks it through the hook's
// ctx. ok=false ends the table (state idle) without playing match k; an
// error halts it. The hook must return promptly once ctx is done and
// must not call Registry.Close or Registry.Wait. Nil — every caller
// before E3 — means no table may set TableConfig.Series (AddTable
// refuses one).
Series SeriesFunc
// OnRewind, when non-nil, is invoked exactly once per undo an undo-capable
// match performs, with the truncation point the match rewound to. See
// OnRewindFunc (server/host/undo.go) — a sink that persists bursts through OnBurst
// MUST implement it, or its stored chain keeps events the live match has
// undone while later bursts re-use the same seq numbers for different
// events.
OnRewind OnRewindFunc
// contains filtered or unexported fields
}
Options configures a Registry. LoadDeck is required so a caller can never forget that the host reads no files for decks itself.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry owns the tables and the sessions watching them.
func New ¶
New validates Options and, when Dir is set, reads the registry back from disk (Task 12). Nothing starts running until Start/StartAll.
func (*Registry) AddTable ¶
func (r *Registry) AddTable(c TableConfig) error
AddTable registers (and persists) a table without starting it.
func (*Registry) AllMatches ¶
AllMatches lists every table's matches, tables in sorted id order (no map order), each table's matches in its own ascending order.
func (*Registry) Close ¶
Close stops every table, aborts in-progress matches, closes sessions and waits for the goroutines. Idempotent.
func (*Registry) CloseSession ¶
CloseSession drops the session's subscriptions and closes Out.
func (*Registry) Done ¶
func (r *Registry) Done() <-chan struct{}
Done is closed once Close has signalled every table to stop — before Close waits for them — so a Sleep hook that triggers Close can wait for the signal without deadlocking on its own goroutine.
func (*Registry) Events ¶
Events is every redacted, described event from since (inclusive) to the head, in chain order. Redaction is against the state at head (PL-15), which holds because g is cloned at the tail's own head — RedactEventsFor and Describe read "the state as of the last event in evs".
since is caller-controlled and since=0 on a long match is the whole log, so (fix round 1, FL-42) this takes the read lock only long enough to clone g (state.Game.Clone) and copy the events tail, then formats outside the lock — the same discipline as ViewAt, now that eventBodies (server/host/fanout.go) takes a state/events pair instead of a live match.
func (*Registry) EventsSeat ¶
func (r *Registry) EventsSeat(id TableID, k int, since uint64, player state.PlayerID) ([]protocol.EventBody, error)
EventsSeat is every event from since (inclusive) to the head, redacted for player as a seat (Task M2b-4): their own secret draws keep their cards, a card that entered any other seat's hidden zone never surfaces. Mirrors Events — the only difference is the redaction mode, Seat instead of the table's spectator visibility, i.e. only the viewer/visibility pair handed to the common eventBodiesFor. While the CR 103.1 starting-player choice is pending (E3-fx3, CR 103.5) the seat's own Secret events are redacted to the opponent shape instead: no opening-hand id leaves the server during the window.
func (*Registry) Hello ¶
Hello is the stream's first frame: the session id and the table list. It carries no frame id (the http layer sends it before Last-Event-ID resumption applies).
func (*Registry) LobbyTables ¶
LobbyTables lists the long-lived tables that belong in the public lobby. A play-vs-bot table is entered only through the unguessable join URL returned by POST /api/games; advertising it here would leak an abandoned private game into every visitor's lobby (and used to render one card for every game restored from old persistence). It remains addressable through its table-scoped routes for the lifetime of this process.
func (*Registry) Matches ¶
Matches lists a table's matches in ascending order; the live one last. Finished matches known only from disk (archived sidecars) come first, then in-memory history entries whose match index is not already archived (memory mode only: persistence mode retains no history), then the live match.
func (*Registry) OpenSession ¶
OpenSession registers a new session with an unguessable identifier issued only in its hello frame. A seat-aware subscription can bind private frames to this session, so a sequential id would let another seat claim it before its owner subscribes. serial preserves deterministic fan-out order without making the public identifier predictable.
func (*Registry) Pending ¶
Pending returns a copy of the decision currently being asked of the human seat for player on match k of table id, or an error when the match/seat cannot answer (missing table, non-live match, out-of-range player, a bot seat, or no decision parked). The Options slice is copied so a caller can never mutate the seat's own pending decision through it.
func (*Registry) Provenance ¶
Provenance returns a copy of match k of table id's provenance ledger, indexed by intent (see the package ledger doc). It is the read path HD-3's ShadowScan and the ledger tests use; the returned slice is a copy, so a caller can never mutate the match's own bytes. A finished non-perpetual match is read through the table's history tail once run() has retired it (t.cur nil) — the same current-or-last match a feedback snapshot picks — so the ledger stays readable after the match ends.
func (*Registry) Publish ¶
Publish pushes one embedder-built frame (eventhost's draft-event frames) to session s with the contract table frames have: it never blocks, the frame gets the session's next id and is kept in its resume ring, and a session whose channel is full overflows, is closed and is unregistered. It reports whether the frame was queued — false once s is closed or overflowed, which tells the caller to forget the subscriber.
func (*Registry) ShadowScan ¶
func (r *Registry) ShadowScan(id TableID, k int, seat state.PlayerID, policy string) ([]divergence.Divergence, error)
ShadowScan is HD-3 of the human-divergence design (docs/design/2026-10-10-human-divergence-design.md §5.3-§5.5): it replays match k of table id once and, at every decision the seat answered deliberately, asks a fresh shadow instance of policy what it would have answered (shadow.Answer). Every material difference (divergence.Material) is returned as a divergence-v1 record, sorted by intent.
A decision is scanned only when the seat owned it, its intent index is inside the log, and the provenance ledger marks it neither caretaker nor client-auto (HD-2): those were not a human's choice. The scan copies (cfg, log, ledger) under the match lock exactly as SnapshotForFeedback does and runs the walk outside it, so it never drives the live engine and blocks a Submit only for the copies.
When policy is an EnvSeat the scan keeps one searchseat.Feed for seat, observing at every boundary and recording the seat's logged answers (the RebuildFeed loop), so the shadow's honest root is built from the same observation the live host would have given that seat. For a search policy each shadow decision runs under the registry's search-slot gate, so the scan never competes with live tables beyond Options.SearchSlots.
The shadow seed of intent n is divergence.ShadowSeed(match seed, seat, n) and is recorded on the divergence, so triage can recompute B. Nothing is submitted anywhere: the result is host-side metadata, never replay input.
func (*Registry) SnapshotForFeedback ¶
SnapshotForFeedback captures the state of table id's CURRENT match — the live one, or, when the table is between matches, the one it just finished, so a report filed moments after game over still snapshots — as of one consistent instant. The match's read lock is held exactly long enough to copy what the snapshot needs (the log via Log.Clone, the sidecar, the deck contents, the game facts, the pending decision), and the seat's view is projected OUTSIDE the lock from those copies, on the replayed engine, exactly as ViewAtSeat does — a snapshot therefore never blocks a concurrent Submit for longer than the copies take, and never drives the live engine.
seat is the reporting seat's own seat, or nil when the report named none. A named seat out of range is an error, not a nil view: a feedback caller should learn it asked for a seat the table does not have.
The returned snapshot is a stable copy: later bursts append to the live match but never to it, so marshalling it needs no lock.
func (*Registry) SnapshotMatch ¶
SnapshotMatch is SnapshotForFeedback for match k of table id, named explicitly through lookup (HD-3, design §5.3): the current match, a retired one in the table's history, or an archived one rebuilt from disk. SnapshotForFeedback snapshots whichever match is current, so a caller that runs after the table has moved on to match k+1 (the divergence worker runs at match end, on its own goroutine) would capture the wrong one; this entry point removes that race for every table shape. Both go through snapshotOf, so the two cannot drift. seat nil skips the view.json half, which the divergence capture never wants (the end-of-game view is useless there and the review UI asks for historical views by seq).
func (*Registry) SubmitIntent ¶
SubmitIntent answers the decision pending on the human seat for player on match k of table id. It never drives the engine: it validates the intent exactly as decision.Decision.Validate does (stale Seq, wrong player, wrong option indices, wrong min-max, duplicates are all rejected — D2) and hands it to the parked seat, whose submit wakes the blocked Decide. The actual engine Submit still runs inside play() on the match goroutine (D7), so a caller who sends on SubmitIntent can never race the log. A rejected intent leaves the seat parked and the game exactly where it was.
func (*Registry) SubmitIntentFrom ¶
func (r *Registry) SubmitIntentFrom(id TableID, k int, player state.PlayerID, in decision.Intent, auto bool) error
SubmitIntentFrom is SubmitIntent with HD-2's client-autopilot bit: auto marks the answer as produced by the client's autopilot rather than a deliberate human choice (docs/design/2026-10-10-human-divergence-design.md §5.2). The flag travels to the parked slot — never into decision.Intent, whose values are replay input — and the play loop records it in the match's provenance ledger (server/host/divergence_ledger.go), so the divergence capture can exclude it. SubmitIntent is the auto=false form.
func (*Registry) Subscribe ¶
Subscribe adds a table (or every table, overview only) to the session. A focus subscription on a live table pushes a snapshot at once so the client has a board before the first event frame arrives.
func (*Registry) Undo ¶
Undo requests the in-place rollback for the live match k of table id, as held by the human seat player. The table must have at least one human seat and EVERY human seat must be the requester — a vs-bot or solo table — because a rewind discards actions other people took and no consent flow exists (out of scope; a table with two or more distinct human seats is refused with that reason). The seat must be a real human seat of the live match, and the requester must have an unreserved logged intent. Each accepted request reserves one, so rapid repeated requests are all serviced and an excess request is explicitly rejected rather than silently dropped.
204 means the request was queued for the live loop; the rewind itself is observed on the stream (the rewind frame) or through Pending/ViewAt.
func (*Registry) Unsubscribe ¶
Unsubscribe removes one table (or the wildcard). Unsubscribing the wildcard clears every cached widget, not just a "*" entry (which never existed as a widget key), unless a specific per-table subscription still justifies keeping them.
func (*Registry) ViewAt ¶
ViewAt is the board as of event seq (inclusive) in the table's visibility. For a live match it uses the turn-start snapshots; a finished match (Task 12) replays from its files.
The replay viewAt does (up to a turn's worth of Submit calls) can run long enough to matter, so this takes the read lock only to copy what it needs — the log (m.e.L.Events/Intents are live, appended-to slices the match loop still owns, so a bare pointer would race; Clone gives an independent copy) and the (append-only, never-mutated-in-place) snapshot list — then computes outside the lock, never blocking a concurrent Submit for longer than those two copies take.
func (*Registry) ViewAtSeat ¶
func (r *Registry) ViewAtSeat(id TableID, k int, seq uint64, player state.PlayerID) (view.View, error)
ViewAtSeat is one seat's own view of the board as of event seq (inclusive): their hand and mana pool — every other seat's hidden zones are counts only — and, when the projection is exactly at the live head and the engine is parked on that seat, the decision asked of them (Task M2b-4, FL-73). It shares viewAt with ViewAt; the two differ only in the (viewer, vis, decision) triple they hand it, i.e. only in how the common replay calls view.ProjectFor.
Same lock discipline as ViewAt: read the shared fields under the match lock — the log, the snapshots, the config, and a COPY of the pending decision (never the engine's own pointer, which play replaces between bursts) — then project outside it.
type SearchProbe ¶
type SearchProbe struct {
// Mark returns the number of diagnostics observed so far.
Mark func() int
// Searched reports whether diagnostics [from, to) record a decision the
// seat really searched.
Searched func(from, to int) bool
}
SearchProbe reads a policy's own search-diagnostic stream (azmcts.Watch, sbsearch.Watch, ...), which the caller installs for the row's duration.
type SeriesFunc ¶
SeriesFunc plans match k (k = 1 included) of a Series table. It runs on the table's own goroutine holding no Registry or table lock, may block — between the games of a series it waits for both players to sideboard — and must return promptly once ctx is done (Registry.Close). ok=false ends the table (state idle) without playing match k; an error halts it. It must not call Registry.Close or Registry.Wait.
type SeriesGame ¶
SeriesGame is a series table's plan for one match: the deck each table seat plays, indexed by SEAT (never rotated by k, unlike TableConfig. Decks), and the seat posed CR 103.1's starting-player choice in place of the toss winner (rules.Config.StartChooser), or -1 to keep the seeded toss (E3-2).
type Session ¶
type Session struct {
ID string
// contains filtered or unexported fields
}
Session is one client connection's worth of subscriptions: a bounded outbound channel, a ring of the frames it has sent (for Last-Event-ID resume), and the latest widget per table (PL-5: widgets are coalesced, never ring-buffered, never given an id).
func (*Session) Out ¶
Out is the resumable frame stream. It is closed after an overflow (the http layer then sends the overflow frame itself) or CloseSession.
func (*Session) Overflowed ¶
Overflowed reports whether the channel ever filled and how many frames were dropped before Out was closed.
func (*Session) Since ¶
Since returns every ring frame with ID > id, in order. ok is false when id is older than the oldest frame still in the ring (or the ring is empty and id is not the current head), in which case the caller must start the client over with a fresh hello and snapshots.
func (*Session) TakeWidgets ¶
TakeWidgets returns and clears the latest widget per table, in table-id order. The SSE writer calls it on every tick.
type TableConfig ¶
type TableConfig struct {
ID TableID `json:"id"`
Name string `json:"name"`
Seats int `json:"seats"`
Decks []string `json:"decks"` // deck names for Options.LoadDeck; seat i of match k plays Decks[(i+k)%len]
Seed uint64 `json:"seed"`
// BotPolicy selects the deterministic policy for every non-human seat and
// human-seat caretaker. Empty is normalized to BotPolicy at registration,
// so tables written before named policy selection retain production bot
// behavior when restored.
BotPolicy string `json:"bot_policy,omitempty"`
// BotAutoPayMana makes every hosted bot and human-seat caretaker use
// offered payment plans rather than manually tapping mana. It is part of
// persisted table configuration so a restart preserves bot behaviour.
BotAutoPayMana bool `json:"bot_auto_pay_mana"`
// BotCommonSense opts every hosted bot and human-seat caretaker into the
// bots/sanity CommonSense guard (bots.Options.CommonSense): hostile
// removal the engine says can only hit the seat's own permanents or
// player is dropped from the option list, and the London mulligan ask is
// answered by botpolicy.MulliganConstructed. The guard changes the
// intents a seat answers, so it is part of persisted table configuration
// exactly like BotAutoPayMana: a table resumed after a restart must
// rebuild the same seats or its replay diverges from the recorded log.
// Default false, and a false seat is byte-identical to the unwrapped
// policy, so a table written before the field existed replays unchanged.
// Serialized explicitly (no omitempty) so the restore-time migration can
// tell a pre-field file from a deliberate false the same way
// bot_auto_pay_mana does.
BotCommonSense bool `json:"bot_common_sense"`
// AutoMana enables payment-plan publication and the Auto Mana controls for
// human seats. Disabled is the legacy human path: plans are never sent to
// a human client and only ordinary casts/manual mana are available.
AutoMana bool `json:"auto_mana"`
// WindowDiagnostics opts priority views into first-gate withholding reasons.
WindowDiagnostics bool `json:"window_diagnostics"`
// OnDemand marks a browser-created play-vs-bot table. These tables are
// deliberately process-scoped: their seat credentials live only in the
// gorged process, and a restart already aborts any game in progress. The
// registry therefore drops them while loading a new process rather than
// growing tables.json forever with abandoned one-shot games. Their match
// logs are not the feedback archive: a submitted feedback report captures
// its own replayable snapshot before this disposable table is removed.
OnDemand bool `json:"on_demand,omitempty"`
// PlayerNames names each seat, independently of its deck, for the wire's
// player box (view.PlayerView.Name). Seat i of match k uses
// PlayerNames[i]; a missing or short list falls back to the deterministic
// "Player 1".."Player N" (seat-indexed, stable under replay). The deck
// identity stays on Deck and on the engine's own Names.
PlayerNames []string `json:"player_names,omitempty"`
// Pace is the sleep after every decision; 0 plays as fast as possible.
Pace time.Duration `json:"pace"`
Spectator view.Visibility `json:"spectator"` // Public or Omniscient
Perpetual bool `json:"perpetual"`
// Humans is the set of slot indices that are real people: a non-nil,
// non-empty value makes the table single-shot and seats every listed slot
// with a HumanSeat (driven from outside via Pending/SubmitIntent) instead
// of a bot. nil (the default) means all bots — byte-identical to today.
// The bot slots are the complement: every index in [0, Seats) that Humans
// does not list, so a human index is disjoint from the bots by
// construction once it is in range. A table with Humans must not also be
// Perpetual (validation rejects that combination); a human-seated table
// always ends at game over.
Humans []int `json:"humans,omitempty"`
// Mulligans is the London mulligan allowance: each player may take up to
// this many mulligans in the pre-game round between the opening deal and
// turn 1 (rules.Config.Mulligans). 0 — the zero value, and what every
// table before M2e-5 played with — skips the round entirely, so a table
// that never sets it behaves byte-identically to today. It travels on the
// same TableConfig that seed/decks/names travel on, so both the live
// match and a restart's replays rebuild the same rules.Config (R-8.4: the
// replay's Config must carry it too, or a match played with a round stops
// replaying). Negative values are rejected by validate.
Mulligans int `json:"mulligans,omitempty"`
// Format names the table's construction format: constructed (the zero
// value, and every table before m39) or commander. A commander table
// plays the CR 903 rules — 40 life (CR 903.6), a command zone built
// from each seat's deck's commanders, the CR 903.8 tax and the CR
// 903.10 clock — so every deck it deals must itself name a commander
// (validate enforces it through the loaded Deck). Named on the wire
// like Spectator; a tables.json written before the field existed loads
// as constructed.
Format Format `json:"format,omitempty"`
// StartingLife overrides a game's opening life total; 0 (the zero
// value) means the format's default — 20 constructed, 40 for a
// commander table (CR 903.6) — so a commander table that never sets it
// still plays 40. newMatch resolves the default once, on the copy it
// builds the match from, and the resolved value travels on every
// rules.Config and on its sidecar, so a replay rebuilds the life the
// match actually played with.
StartingLife int32 `json:"starting_life,omitempty"`
// AllowDecklistBots opts a BOT-ONLY table into a bot policy that reads
// every seat's real decklist (operator ruling 2026-10-13). An Env policy
// (bots.Info.Env) builds its search root from every seat's true 40/60 —
// server/host/botenv.go's envData hands searchseat.HonestRoot the table's
// m.cfg.Decks — so "honest" redeals the hidden cards but the deck LIST
// it redeals from is the opponent's real one. Such a policy is refused
// at any table with a human, always, even with this opt-in; the bit only
// ever matters on a table whose seats are all bots (a test, bench or
// spectator game). A table written by an older binary without the field
// loads as false, and restart skips a persisted table the new rule
// refuses rather than failing the whole process.
AllowDecklistBots bool `json:"allow_decklist_bots,omitempty"`
// SeatBotPolicies names a bot policy per seat index: seat i of every match
// is played by SeatBotPolicies[i] when that entry is non-empty, else by
// BotPolicy. Like PlayerNames it never rotates with the decks. An entry
// may not name a human seat, and may not name a policy that reads
// opponents' decklists (bots.Info.Env). nil — every table before E1 —
// builds every bot seat from BotPolicy exactly as before. Human-seat
// caretakers always use BotPolicy.
SeatBotPolicies []string `json:"seat_bot_policies,omitempty"`
// Event names the draft event (eventhost) that owns this table. An event
// table must be OnDemand and not Perpetual; it is exempt from
// Options.MaxOnDemandTables because eventhost bounds its own events.
// "" — every other table — changes nothing.
Event string `json:"event,omitempty"`
// Series hands the choice of every match's decks and starting-player
// chooser to Options.Series; the table plays match after match until the
// hook says stop. Requires Event. false — every other table — changes
// nothing.
Series bool `json:"series,omitempty"`
}
TableConfig is everything a table needs; it is persisted verbatim in tables.json, so a table's whole history is reproducible from it.
type TableID ¶
type TableID string
TableID is stable for the life of a registry directory; match indices under it increment from 1.
func NextGameID ¶
NextGameID returns an unused "g<N>" table id. On-demand table records are intentionally removed on restart because their credentials are process scoped, but their replay logs stay under Dir/gN. Those directories are part of a feedback/replay trail, so their IDs are reserved too: reusing g1 after restart would overwrite match 1's events and sidecar. Exported for the game creator (cmd/gorged), which allocates ids when a browser asks for a fresh game.
Source Files
¶
- action.go
- bot_policy.go
- botenv.go
- closedlist.go
- contract.go
- contract_caretaker.go
- contract_envleak.go
- contract_privacy.go
- contract_shadow.go
- crash.go
- decktally.go
- divergence_ledger.go
- doc.go
- fanout.go
- feedback.go
- humanseat.go
- leakscan.go
- match.go
- persist.go
- publish.go
- registry.go
- restart.go
- seatpolicy.go
- seed.go
- series.go
- session.go
- shadowscan.go
- snapshot.go
- table.go
- undo.go
- viewat.go
Directories
¶
| Path | Synopsis |
|---|---|
|
Package httpapi serves a host.Registry over plain net/http: JSON GETs for tables, matches, views and events; POSTs for subscriptions; one SSE stream per client; and the embedded web client.
|
Package httpapi serves a host.Registry over plain net/http: JSON GETs for tables, matches, views and events; POSTs for subscriptions; one SSE stream per client; and the embedded web client. |
|
Package manabrewhttp is MB-10 of the ManaBrew protocol scoping spec (archived 2026-09-28-manabrew-protocol-scope.md (docs/design/ARCHIVE.md) §5.2): the wire transport that carries server/manabrew's translation over HTTP.
|
Package manabrewhttp is MB-10 of the ManaBrew protocol scoping spec (archived 2026-09-28-manabrew-protocol-scope.md (docs/design/ARCHIVE.md) §5.2): the wire transport that carries server/manabrew's translation over HTTP. |