Go embedding API¶
This page is for Go developers who import gorge as a library: to play games in their own program, to write a bot policy, or to build their own server around gorge's host. It walks the entry points package by package, with signatures copied from the code, two programs that were compiled and run, and an honest account of which parts of the surface are pinned by tests and which can change in any commit. Terms such as seat, decision, intent, view and chain head are defined in the glossary.
Verified against
Every signature below was checked against the source at commit
513a2a0c3 (2026-10-21), and the first program was re-run there with
Go 1.26.3. The bot example and its contract test were run at ad3777023
(2026-10-20).
gorge has no tagged releases, so pin a commit (see Stability).
The layers you touch¶
An embedder only ever touches a few packages. The arrows mean "imports".
flowchart BT
subgraph engine["engine/ : the rules core"]
direction BT
cards["engine/cards<br/>card registry"]
deck["engine/deck<br/>deck files"]
decision["engine/decision<br/>Decision, Intent"]
rules["engine/rules<br/>Engine, Config"]
view["engine/view<br/>per-seat View"]
seat["engine/seat<br/>Seat contract"]
replay["engine/replay"]
end
bots["bots/<br/>registry, botpolicy, heuristic"]
host["server/host<br/>Registry of tables"]
gorged["server/gorged<br/>Main, Serve"]
main["cmd/gorged<br/>(or your binary)"]
bots -->|imports| engine
host -->|imports| engine
host -->|imports| bots
gorged -->|imports| host
main -->|imports| gorged
main -->|blank-imports bots/all| bots
You can stop at any layer. A batch simulator needs only engine/ (and a bot
from bots/). A custom server builds on server/host. A binary that is
gorged plus one extra bot needs only server/gorged and a bots.Register
call. See Architecture for why the
engine is split this way, The game loop
for what happens between two decisions, and Packages for
every package in the tree.
1. Playing a game: the engine¶
Load the cards: engine/cards¶
Card behaviour comes from Forge card scripts compiled into an IR. The scripts
are GPL-3.0 and gorge never ships them, so you fetch them once into a
directory (make fetch-cards in a gorge checkout writes .cards/; see
Getting started) and open that directory at run time.
| Signature | Purpose |
|---|---|
func SharedCorpus(dir string) (*Registry, error) |
Opens dir's compiled IR cache once per process and returns the same registry on every later call. Use this one. |
func OpenCorpus(dir string) (*Registry, error) |
Uncached open: loads dir/ir-<fingerprint>.gob.gz, or compiles dir/cardsfolder when the cache is missing or older than dir/cards.lock, and writes the fresh cache back. |
func (r *Registry) Lookup(name string) (*Card, bool) |
One card by name (names are normalised). |
func (r *Registry) Universe() *Universe |
Every card, in registry order: the list a "name a card" decision ranges over. |
Registry.Tokens map[string]*Card |
Compiled token scripts, keyed by file stem. |
Why SharedCorpus: the registry is about 400 MB, and the rules layer keys
memo tables on its pointers, so a process that reopens it per game keeps every
copy alive. The cache file name carries the compiler's fingerprint, so a
binary never reads a cache another parser version wrote.
Load a deck: engine/deck¶
| Signature | Purpose |
|---|---|
func Load(r *cards.Registry, path string) (File, []*cards.Card, error) |
Reads, parses and resolves one deck JSON file. The slice is what rules.Config.Decks wants. |
func Parse(raw []byte) (File, error) |
Decodes a deck file and rejects empty or malformed lists. |
func (f File) Resolve(r *cards.Registry) ([]*cards.Card, error) |
Looks every entry up and expands it by its count, in file order; the first unknown card is named in the error. |
The repository's own decks live in
testkit/testutil/decks/.
Build and drive the engine: engine/rules¶
| Signature | Purpose |
|---|---|
func New(cfg Config) *Engine |
Builds a game at genesis. The toss winner takes the first turn without being asked. |
func NewStartingPlayerChoice(cfg Config) *Engine |
The same, but the toss winner may choose who goes first (CR 103.1). The host uses this one. |
func (e *Engine) AskStartingPlayer() *decision.Decision |
With NewStartingPlayerChoice: poses that choice (call before the first Advance), or returns nil when there is nothing to choose. If you never call it, Advance applies the default. |
func (e *Engine) Advance() |
Runs engine work until a decision is needed or the game ends. Call it once after construction. |
func (e *Engine) Pending() *decision.Decision |
The decision currently posed to a seat, or nil. The pointer and its Options are valid only until the next Submit; copy with Decision.Clone() to keep it. |
func (e *Engine) Submit(in decision.Intent) error |
Applies an answer and runs on to the next decision. Anything the engine did not offer is rejected. |
func (e *Engine) EnsurePaymentActions() []decision.PaymentAction |
Lazily builds the mana-payment plans on the pending priority decision, for a seat that wants them (see seat.PaymentPlanConsumer). A pure read: no event, no RNG. |
func (e *Engine) EnsureTargetHints() |
Same pattern for per-option target counts (seat.TargetHintConsumer). |
func (e *Engine) Clone() *Engine |
An independent copy, for look-ahead. |
e.G *state.Game, e.L *events.Log |
The game state (G.Over, G.Draw, G.Winner, G.Turn) and the event log (L.Head() is the chain head). Read them; never write them. |
Config has many fields; each one's zero value is the old behaviour, so a
Config that leaves a field unset plays exactly as it did before that field
existed. The ones an embedder sets:
| Field | Meaning |
|---|---|
Seed uint64 |
The game's only source of randomness. Same seed, same seats, same game. |
Names []string, Decks [][]*cards.Card |
One per seat. Names appear in event text, which is hashed. |
Tokens map[string]*cards.Card |
reg.Tokens. Without it a token-making card has nothing to make. |
NameUniverse *cards.Universe |
reg.Universe(), for "name a card" decisions. |
Mulligans int |
London mulligans allowed; 0 skips the mulligan round. |
Format Format, StartingLife int32, Commanders [][]int |
Commander play. |
PlayerNames []string |
Display names, separate from the hashed Names. |
LoopGuard *LoopGuard |
Livelock-watcher thresholds; nil is the defaults. |
A replay needs the same Config again (the log alone cannot rebuild the
decks), so keep it.
Other exported entry points you may meet: AcceptanceConfig and
PlayAcceptance play the golden acceptance game that TestHeads pins (and
cmd/headdiff replays), and RunOracleScenarioJSON runs one oracle scenario,
which is how gorge-compliance
plays its corpora against the engine.
*rules.Engine exports far more than this
go doc ./engine/rules Engine lists a few hundred methods (Emit*,
Begin*Batch, Chars, LegalTargets, ...). Most exist so engine/effects
can call back into the engine without an import cycle. They are not an
embedding API; a driver needs only the rows above.
Decisions and intents: engine/decision¶
The engine never asks "what do you want to do?". It poses a Decision that
lists every legal option, and the seat answers with an Intent naming
option indices. That is what keeps clients and bots rules-ignorant: a client
cannot make an illegal play, because it can only pick from the list.
| Signature | Purpose |
|---|---|
type Decision struct{ Seq uint64; Player state.PlayerID; Kind Kind; Prompt string; Min, Max int; Options []Option; ... } |
One question to one seat: choose between Min and Max of Options. Further fields carry budgets, payment plans and diagnostics. |
type Option struct{ Index int; Kind string; Label string; Obj state.ObjID; Player state.PlayerID; ... } |
One offered choice. |
type Intent struct{ Seq uint64; Player state.PlayerID; Choices []int; Rest []int; Payment *PaymentSelection; Announce *AnnounceSelection } |
The answer. Seq and Player must match the decision. |
func (d *Decision) Validate(in Intent) error |
Rejects anything the engine did not offer: stale Seq, wrong player, bad indices, wrong count, duplicates. Engine.Submit runs it. |
func (d *Decision) Clone() *Decision |
A deep copy that outlives the next Submit. |
type Kind string, var Kinds = []Kind{...} |
The decision kinds. |
Kinds at this commit, in declaration order:
| Kind | Wire name | Asked when |
|---|---|---|
KPriority |
priority |
A seat holds priority: cast, activate, play a land, pass, concede. |
KTarget |
target |
Choosing targets. |
KAttackers |
attackers |
Declaring attackers. |
KBlockers |
blockers |
Declaring blockers. |
KMulligan |
mulligan |
London mulligan: keep or mulligan, then which cards to bottom. |
KModes |
modes |
A modal choice or an "unless pays" ask. |
KTriggerOrder |
trigger_order |
Ordering simultaneous triggers. |
KTriggerOptional |
trigger_optional |
Whether to use a "may" trigger. |
KCommanderZone |
commander_zone |
A commander's owner chooses the command zone (CR 903.9). |
KChoose |
choose |
X values, cost choices, "as it enters" choices, yes/no. |
KReplacement |
replacement |
Ordering competing replacement effects (CR 616.1). |
KArrange |
arrange |
Scry, surveil and other ordered-subset asks. |
KStartingPlayer |
starting_player |
Who takes the first turn (CR 103.1). |
A seat must be able to answer every kind. The simplest legal answer to any
decision is botpolicy.Clamp(d, decision.Intent{Seq: d.Seq, Player: d.Player})
from bots/botpolicy: it tops the empty answer up to Min and prefers "pass".
The seat contract: engine/seat¶
type Seat interface {
Decide(ctx context.Context, v view.View, d decision.Decision) (decision.Intent, error)
}
A seat is handed a view.View and never a *state.Game or *rules.Engine,
so it sees only what a real player at that seat would. A bot ignores ctx
and never returns an error; a human seat uses ctx to time out. Two optional
interfaces let a seat ask the driver for extra, engine-computed help:
| Interface | Effect |
|---|---|
PaymentPlanConsumer { WantsPaymentActions() bool } |
The driver calls Engine.EnsurePaymentActions before handing this seat a decision, so Decision.PaymentActions carries ready-made mana payments. |
TargetHintConsumer { WantsTargetHints() bool } |
The driver calls Engine.EnsureTargetHints, filling per-option target counts. |
What a seat sees: engine/view¶
| Signature | Purpose |
|---|---|
func Project(g *state.Game, ch Chars, viewer state.PlayerID, d *decision.Decision) View |
One seat's view: hidden zones it does not own are counts only, and d is attached only for the seat it was asked of. *rules.Engine satisfies Chars, so pass the engine. |
func ProjectFor(g *state.Game, ch Chars, viewer state.PlayerID, vis Visibility, d *decision.Decision) View |
The same with an explicit Visibility: Seat (exactly Project), Public (spectator) or Omniscient (every hand). |
Replaying: engine/replay¶
| Signature | Purpose |
|---|---|
func Replay(l *events.Log, cfg rules.Config) (*rules.Engine, error) |
Re-runs a match from its Config and recorded intents and checks every rebuilt event against the log. |
func ReplayTo(l *events.Log, cfg rules.Config, n int) (*rules.Engine, error) |
The same, stopping after n intents. |
The drive loop¶
sequenceDiagram
participant D as your driver
participant E as rules.Engine
participant S as seat.Seat
D->>E: New(cfg), Advance()
loop until e.G.Over
D->>E: Pending()
E-->>D: *Decision for seat p
D->>D: view.Project(e.G, e, p, d)
D->>S: Decide(ctx, view, *d)
S-->>D: Intent
D->>E: Submit(intent)
end
D->>E: e.L.Head()
A complete program¶
This program loads the corpus, loads two of the repository's deck files, seats the default heuristic bot against a seat that always passes, plays to the end, prints the result and the chain head, then replays the log and prints the replayed head.
// Command playtwo plays one 2-player game between two deck files and prints
// the winner and the event log's chain head.
//
// go run . -cards /path/to/gorge/.cards deckA.json deckB.json
package main
import (
"context"
"flag"
"fmt"
"os"
"github.com/adams-shaun/gorge/bots"
_ "github.com/adams-shaun/gorge/bots/all" // links the built-in policies ("bot", ...)
"github.com/adams-shaun/gorge/bots/botpolicy"
"github.com/adams-shaun/gorge/engine/cards"
"github.com/adams-shaun/gorge/engine/decision"
"github.com/adams-shaun/gorge/engine/deck"
"github.com/adams-shaun/gorge/engine/replay"
"github.com/adams-shaun/gorge/engine/rules"
"github.com/adams-shaun/gorge/engine/seat"
"github.com/adams-shaun/gorge/engine/view"
)
// passSeat is the smallest seat that always answers legally: it asks
// botpolicy.Clamp to fill the empty answer up to the decision's Min, which
// passes priority whenever passing is offered.
type passSeat struct{}
func (passSeat) Decide(_ context.Context, _ view.View, d decision.Decision) (decision.Intent, error) {
return botpolicy.Clamp(&d, decision.Intent{Seq: d.Seq, Player: d.Player}), nil
}
func main() {
dir := flag.String("cards", ".cards", "corpus directory (cardsfolder + IR cache)")
seed := flag.Uint64("seed", 1, "game seed")
flag.Parse()
if flag.NArg() != 2 {
fmt.Fprintln(os.Stderr, "usage: playtwo [-cards DIR] [-seed N] deckA.json deckB.json")
os.Exit(2)
}
// 1. The card registry: the compiled IR cache, compiled on first use.
reg, err := cards.SharedCorpus(*dir)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// 2. Two decks, resolved against the registry.
var names []string
var decks [][]*cards.Card
for _, path := range flag.Args() {
f, list, err := deck.Load(reg, path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
names = append(names, f.Name)
decks = append(decks, list)
}
// 3. The engine. Tokens and NameUniverse come from the same registry.
cfg := rules.Config{
Seed: *seed, Names: names, Decks: decks,
Tokens: reg.Tokens, NameUniverse: reg.Universe(),
}
e := rules.New(cfg)
// 4. One seat per player: the default heuristic bot, and passSeat.
bot, err := bots.New("bot", bots.Options{Seed: *seed})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
seats := []seat.Seat{bot, passSeat{}}
// 5. The drive loop: run to the next decision, show the asked seat its
// own view, submit its answer.
ctx := context.Background()
e.Advance()
intents := 0
for !e.G.Over {
d := e.Pending()
if d == nil {
break
}
s := seats[d.Player]
if pc, ok := s.(seat.PaymentPlanConsumer); ok && pc.WantsPaymentActions() {
e.EnsurePaymentActions()
}
v := view.Project(e.G, e, d.Player, d)
in, err := s.Decide(ctx, v, *d)
if err == nil {
err = e.Submit(in)
}
if err != nil {
fmt.Fprintf(os.Stderr, "intent %d: %v\n", intents, err)
os.Exit(1)
}
intents++
}
switch {
case !e.G.Over:
fmt.Println("game did not finish")
case e.G.Draw:
fmt.Println("draw")
default:
fmt.Printf("winner: seat %d (%s)\n", e.G.Winner, names[e.G.Winner])
}
fmt.Printf("turns: %d intents: %d events: %d head: %s\n",
e.G.Turn, intents, len(e.L.Events), e.L.Head())
// 6. Replay: the same Config plus the recorded intents rebuild the game.
r, err := replay.Replay(e.L, cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "replay:", err)
os.Exit(1)
}
fmt.Printf("replay head: %s\n", r.L.Head())
}
It was built as its own module whose go.mod requires
github.com/adams-shaun/gorge with a replace pointing at a local gorge
checkout (an embedder without a checkout would go get
github.com/adams-shaun/gorge@<commit> instead). Run on 2026-10-21 at
513a2a0c3, from the gorge checkout, with $P the program's directory:
$ go -C "$P" run . -cards "$PWD/.cards" \
"$PWD/testkit/testutil/decks/mono-red-goblins.json" \
"$PWD/testkit/testutil/decks/mono-green-stompy.json"
winner: seat 0 (Mono-Red Goblins)
turns: 8 intents: 189 events: 1092 head: fa3bb1cf9402af98
replay head: fa3bb1cf9402af98
A second run printed the same three lines: the game is a pure function of the seed, the decks and the seats. The head will differ at another commit whenever an engine or corpus change alters any event of this game, which is exactly what the chain head is for.
A ready-made loop
testkit/bench.PlayGame
(func PlayGame(cfg rules.Config, seats []seat.Seat, maxTurns, maxIntents int, hooks Hooks) (Outcome, *rules.Engine, error))
is the same loop with turn and intent caps, the match-lifecycle hooks
below, and stall classification. Use it when you want batch games rather
than a loop of your own.
2. Bots¶
The registry: bots¶
This section is the reference. For a guided walkthrough (a real policy, options, playing it in-process, testing it) see Writing a native Go bot.
A bot policy is a named factory in a process-wide registry. Registering is
done from a package's init, so linking the package (usually a blank import)
is what makes the policy exist; bots/all links every built-in one.
| Signature | Purpose |
|---|---|
func Register(e Entry) |
Adds a policy. Panics on a bad name (^[a-z0-9-]+$), a nil factory, a tier other than Production/Experimental, a duplicate, or an invalid option declaration. |
type Entry struct{ Info; New Factory } |
A registration. |
type Factory func(Options) (seat.Seat, error) |
Builds one seat. |
func New(name string, o Options) (seat.Seat, error) |
Builds a seat of the named policy, filling every declared option's default into o.Config first. "" means bots.Default ("bot"). |
func Lookup(name string) (Entry, bool), func Names() []string, func Entries() []Entry |
Inspect the registry. |
Info describes a policy to the host and to the lobby:
| Field | Meaning |
|---|---|
Name, Label, Description string |
Registry key, display name, one-line description. |
Tier Tier |
bots.Production or bots.Experimental. |
Formats []string |
"constructed", "commander"; gorged refuses a play-vs-bot game in a format the policy does not list. |
MaxSeats int |
The most seats the policy plays at; the host refuses a bigger table. 0 means no limit. |
Options []OptionSpec |
Configuration keys the factory reads: {Key, Type, Default, Help, Required}, Type one of string, int, float, bool, path. Read them in the factory with Options.String/Int/Float/Bool. |
NeedsAutoPay bool |
The policy only plays through payment plans; the host refuses a table without them. |
Caretaker string |
The registered policy that answers for this seat when a human seat's think timeout passes; bots.Validate checks it exists and is not an Env policy. |
Env, Search, ClosedLists bool |
For search policies that build an engine from the decklists. A policy with Env and without ClosedLists reads every seat's real decklist, so the host never seats it against a human. |
Strength []Measurement, Cost Cost |
Published claims about strength and per-decision cost, shown by /api/bot-policies. |
Options is what a factory receives: Seed, AutoPayMana (play through
payment plans), SearchParallelism, DecisionDeadlineMS, CommonSense,
Deps (Deps{Cards *cards.Registry}, for a policy that needs the corpus)
and Config (the resolved option values).
Optional seat interfaces, all in package bots, which the host detects with
a type assertion:
| Interface | Called |
|---|---|
MatchSeat { AttachMatch(*MatchContext) error } |
Once per match, after the engine exists and before the first decision; carries game id, seat index, format, own decklist, seed. An error refuses the match. bots.AttachMatch(s, ctx) is the no-op-safe caller. |
MatchEnd { EndMatch(MatchResult) } |
Once after the match ends. |
RefusalAnswerer { AnswerRefused(v view.View, d decision.Decision, refused decision.Intent) decision.Intent } |
When the engine refused this seat's answer: one second attempt before the host's fallbacks. |
EnvSeat |
A search seat that answers some decisions from host-built inputs (bots.Env) richer than a view. |
BudgetedSeat { DecisionBudgetMS() int } |
A per-decision wall-clock budget the host turns into a context deadline. |
Two building blocks most Go bots use:
bots/botpolicy: the shared heuristic policy over aBoard(a compact, bot-oriented reading of the game), plusClamp, which makes any answer legal in count.bots/heuristic: the default bot as aseat.Seat(func NewBot(seed uint64) *Bot, and variants). A seat that also implementsheuristic.BoardSeat(DecideBoard(ctx, b botpolicy.Board, d decision.Decision)) is handed aBoardinstead of aView, which saves the host the projection.
A minimal bot, registered and served¶
// Package passbot registers a minimal bot policy, "pass-bot", with gorge's
// bot registry. Linking the package (a blank import) is what registers it.
package passbot
import (
"context"
"github.com/adams-shaun/gorge/bots"
"github.com/adams-shaun/gorge/bots/botpolicy"
"github.com/adams-shaun/gorge/engine/decision"
"github.com/adams-shaun/gorge/engine/seat"
"github.com/adams-shaun/gorge/engine/view"
)
func init() {
bots.Register(bots.Entry{
Info: bots.Info{
Name: "pass-bot",
Label: "Pass bot",
Description: "Passes whenever it may; otherwise gives the smallest legal answer.",
Tier: bots.Experimental,
Formats: []string{"constructed", "commander"},
Options: []bots.OptionSpec{
{Key: "concede-turn", Type: "int", Default: "0", Help: "unused example option"},
},
},
New: newSeat,
})
}
// newSeat is the bots.Factory: it reads the declared option (bots.New has
// already filled in its default) and returns a seat.
func newSeat(o bots.Options) (seat.Seat, error) {
return &passSeat{concedeTurn: o.Int("concede-turn")}, nil
}
type passSeat struct{ concedeTurn int }
// Decide implements seat.Seat. It sees only the asked seat's view.View and
// the decision; the engine rejects anything it did not offer.
func (s *passSeat) Decide(_ context.Context, _ view.View, d decision.Decision) (decision.Intent, error) {
return botpolicy.Clamp(&d, decision.Intent{Seq: d.Seq, Player: d.Player}), nil
}
// WantsPaymentActions implements seat.PaymentPlanConsumer. This seat never
// reads a payment plan, so it opts out.
func (s *passSeat) WantsPaymentActions() bool { return false }
// Command mygorged is gorged with one extra bot policy linked in.
package main
import (
"os"
_ "example.com/mybot/passbot" // registers "pass-bot"
_ "github.com/adams-shaun/gorge/bots/all" // the built-in policies
"github.com/adams-shaun/gorge/server/gorged"
)
func main() { os.Exit(gorged.Main(os.Args[1:], gorged.Extensions{})) }
That is the whole binary. Built and started on 2026-10-20 at ad3777023
(from the gorge checkout, so the default -cards and -decks paths resolve):
$ ./mygorged -h 2>&1 | tail -3
Bot policy options (set with -bot-opt <policy>.<key>=<value>, -bot-config <policy>=<file.json>, or GORGED_BOT_<POLICY>_<KEY>):
pass-bot (Pass bot):
pass-bot.concede-turn (int; default "0") unused example option
$ ./mygorged -addr 127.0.0.1:8095 -dir /tmp/gorge-mybot -tables 0 -vsbot \
-bot-policy bot -bot-policies bot,pass-bot &
$ curl -s 127.0.0.1:8095/api/bot-policies
{"default":"bot","policies":[{"name":"bot",...},{"name":"pass-bot","label":"Pass bot","description":"Passes whenever it may; otherwise gives the smallest legal answer.","tier":"experimental","strength":[],"mean_ms":0,"p95_ms":0,"cost_scope":"","cost_note":"","search":false,"formats":["constructed","commander"]}]}
How gorged decides what to offer:
-bot-policiesis the offered set. Empty (the default) offers every registered policy that does not read opponents' decklists, so a newly linked bot is offered with no flag at all. A list narrows it; an unknown or duplicated name, or a decklist reader, is a startup error.-bot-policyis the default for a request that names none (lethal-pressureunless set). It must be in the offered set and support bothconstructedandcommander, which is why the run above passes-bot-policy botalongside a narrowed list.- Option values resolve, highest first, from
-bot-opt pass-bot.concede-turn=3, the environment (GORGED_BOT_PASS_BOT_CONCEDE_TURN),-bot-config pass-bot=file.json, then the declared default.
3. The server: server/gorged¶
server/gorged is the whole reference server as a library.
cmd/gorged
is a one-line main over it that links bots/all and passes only the
gorged review loader.
A binary that wants more (a bot, a plugin) is the same one line with
something extra linked in, as above.
| Signature | Purpose |
|---|---|
func Main(args []string, ext Extensions) int |
Runs gorged: parses args (the command line after the program name), binds, serves, handles signals, returns the exit status. |
func Serve(ctx context.Context, args []string, ln net.Listener, ext Extensions) error |
Main without signal handling or the bind: serves on ln until ctx is done. The entry point for a test that drives a real server. |
func ServeReview(ctx context.Context, args []string, ln net.Listener, ext Extensions) error |
gorged review (which Main runs when args[0] is review) without signal handling or the bind: serves saved games read-only on ln until ctx is done. Needs ext.Review. |
func ServeFlagSet() *flag.FlagSet |
A fresh copy of the flag set Main parses (ContinueOnError, no output), so a caller can check an argument list against the real flags. |
func ServeFlagSetFor(ext Extensions) *flag.FlagSet |
The same, including the extensions' own flags. |
func RegisterNetBot(policy string, nb NetBot) |
Links the implementation of a network-backed policy that gorged already names (its flags and deck-list handling live in gorged). Call before Main. |
func RegisterClosedNetBot(policy, netHelp string, enable func(path string, closed *searchprobe.Belief) error) |
Links a network-backed policy gorged does not name; gorged gives it its own -<policy>-net and related flags. Call before Main. |
type Extensions struct {
// Events, when set, serves draft events under /api/events with -events.
// Without it -events is refused at startup.
Events EventsPlugin
// Flags, when set, registers the extensions' own flags on gorged's
// command line; a flag name gorged already defines panics.
Flags func(fs *flag.FlagSet)
// Review, when set, is the loader `gorged review` turns each input path
// into a recorded match with. Without it review mode refuses to start;
// the live server ignores it entirely.
Review ReviewLoader
}
The zero Extensions is the live server without review mode; gorge's own
cmd/gorged sets only Review. EventsPlugin, EventsWiring,
EventsEnv and EventRuntime are the interfaces a draft-event runtime
implements to be served under /api/events. A private companion module of
the maintainers' links its draft events and network-backed policies through
exactly these seams; gorge itself ships neither.
4. The host: server/host¶
server/host runs tables: each table plays matches on its own goroutine,
seats bots or human seats, persists matches, and answers seat-scoped and
spectator reads. It never hands out a *rules.Engine or *state.Game:
everything that leaves it is a view.View, a decision.* value or a
protocol.* frame (pinned by TestNoExportLeaksAnEngineGame, below).
server/host/httpapi puts it on HTTP; gorged wires the two together
(see The server).
| Signature | Purpose |
|---|---|
func New(o Options) (*Registry, error) |
Validates o; when o.Dir is set, reads persisted tables back. Nothing runs until Start. |
func (r *Registry) AddTable(c TableConfig) error |
Registers (and persists) a table without starting it. |
func (r *Registry) Start(id TableID) error, StartAll() error |
Launches the table goroutine(s). |
func (r *Registry) Wait(id TableID) |
Blocks until the table goroutine exits. |
func (r *Registry) Close() error |
Stops every table. |
func (r *Registry) Pending(id TableID, k int, player state.PlayerID) (*decision.Decision, error) |
A copy of the decision parked on a human seat, or an error (no such match, a bot seat, nothing parked). |
func (r *Registry) SubmitIntent(id TableID, k int, player state.PlayerID, in decision.Intent) error |
Answers that decision. It validates exactly as Decision.Validate does and wakes the match goroutine, which runs the engine's Submit; a caller can never race the log. |
func (r *Registry) ViewAtSeat(id TableID, k int, seq uint64, player state.PlayerID) (view.View, error) |
One seat's own view as of event seq; past the head it returns ErrBeyondHead{Head}. |
func (r *Registry) ViewAt(id TableID, k int, seq uint64) (view.View, error) |
The table's spectator view at seq. |
func (r *Registry) Tables() []protocol.TableInfo, Matches(id TableID) ([]protocol.MatchInfo, error) |
Lobby and history reads. |
k is the match number within the table, counting from 1.
Options fields an embedder sets: LoadDeck func(name string) (Deck, error)
(the host never reads deck files itself), Tokens and NameUniverse (as in
rules.Config), Dir (persistence), Sleep (the table's only clock; a test
passes a no-op), ThinkTimeout (after which a human seat's caretaker bot
answers), MaxDecisionsPerTurn (DefaultMaxDecisionsPerTurn is 25000; 0 turns
the stall guard off), BotDeps, and Seats func(names []string, seed uint64)
[]seat.Seat, which replaces the named-policy seats with your own.
TableConfig holds ID, Name, Seats, Decks (names for LoadDeck;
seat i of match k plays Decks[(i+k)%len]), Seed, BotPolicy or
SeatBotPolicies, Humans []int (slots driven through
Pending/SubmitIntent), Spectator, Mulligans, Format and more.
A tested sketch of a human seat answered from outside, the way an HTTP handler would (the full test seats slot 1 as human against the default bot and answered 20 decisions in 0.6 s):
d, err := r.Pending(id, 1, 1) // match 1, seat 1
if err != nil {
// not parked yet: the bot seat is still thinking; poll again
}
in := botpolicy.Clamp(d, decision.Intent{Seq: d.Seq, Player: d.Player})
err = r.SubmitIntent(id, 1, 1, in)
Wait for a new Seq before answering again
Right after SubmitIntent, Pending can still return the decision you
just answered until the match goroutine picks the answer up. A poller
must skip a decision whose Seq it has already answered, or its second
SubmitIntent lands on a slot that is being retired.
The contract harnesses¶
The host exports the gates every hosted bot policy owes it, so a module that
registers its own policies runs exactly the harness gorge runs on its own,
through the real host internals. They are test support: gorged never calls
them, so the linker drops them from every binary. Each takes a ContractT
(Helper, Logf, Errorf, Fatalf, Cleanup), which *testing.T
satisfies, so the host package does not import testing. Call them from your
own _test.go files.
| Harness | Asserts |
|---|---|
func RunContractTable(t ContractT, o Options, cfg TableConfig) ContractRun |
Builds a registry, plays the table's first match to completion, and replays its log under the match's own rules.Config; the replay must reach the recorded head. Two calls with the same inputs are the determinism check. |
func RunEnvLeakContract(t ContractT, c EnvLeakContract) uint64 |
For an EnvSeat policy: swaps the opponent's hidden cards in one of two otherwise identical live matches and requires the policy's answer at that decision to be identical, proving it reads only what the seat may know. Returns the asserted decision's Seq. |
func RunClosedPrivacyContract(t ContractT, c ClosedPrivacyContract) |
For a closed-list policy: plays it against a human seat and checks at every decision that no unrevealed card of the human's reached anything handed to the bot. |
func RunShadowParityContract(t ContractT, o Options, policy string, seed uint64, maxIntents int) |
The host's shadow scan of a policy's own seat reports zero divergences. |
func RunCaretakerLifePlanContract(t ContractT, policy string) |
A human seat's caretaker answers a life-paying payment plan from the ordinary options, while the hosted bot of the same policy submits the plan. |
RunContractTable on the pass-bot above, as an embedder's test (run
2026-10-20 at ad3777023, go test -run TestPassBotHostContract -v):
func TestPassBotHostContract(t *testing.T) {
dir := os.Getenv("GORGE_CARDS_DIR") // a fetched gorge .cards directory
decks := os.Getenv("GORGE_DECKS_DIR")
if dir == "" || decks == "" {
t.Skip("set GORGE_CARDS_DIR and GORGE_DECKS_DIR")
}
reg, err := cards.SharedCorpus(dir)
if err != nil {
t.Fatal(err)
}
load := func(name string) (host.Deck, error) {
f, list, err := deck.Load(reg, filepath.Join(decks, name+".json"))
if err != nil {
return host.Deck{}, err
}
return host.Deck{Name: f.Name, Cards: list}, nil
}
run := host.RunContractTable(t,
host.Options{
LoadDeck: load, Tokens: reg.Tokens, NameUniverse: reg.Universe(),
Sleep: func(time.Duration, <-chan struct{}) {}, // no pacing in a test
},
host.TableConfig{
ID: "t1", Name: "contract", Seats: 2, Seed: 7,
Decks: []string{"mono-red-goblins", "mono-green-stompy"},
BotPolicy: "pass-bot", Spectator: view.Public,
})
t.Logf("state %s, %d events, head %s", run.Info.State, len(run.Log.Events), run.Info.Head)
}
=== RUN TestPassBotHostContract
contract_test.go:36: contract: finished turn 108 intents 1819 events 8856 rootRefusals 0 feeds false reason ""
contract_test.go:46: state finished, 8856 events, head 42b675aee965ed05
--- PASS: TestPassBotHostContract (0.52s)
(Two passing bots play until a library runs out, hence turn 108.)
5. Test support: testkit/¶
These packages are importable and are what gorge's own tests use. The deck
helpers read decks embedded in the package, so they work anywhere; the corpus
helpers find .cards through git rev-parse, so they work inside a gorge
checkout or with GORGE_CARDS_DIR set (otherwise use OpenCorpusRegistry
with an explicit directory).
| Signature | Purpose |
|---|---|
func CorpusRegistry(t testing.TB) *cards.Registry |
The repository's .cards registry, opened once per process. Skips the test when .cards was never fetched; fails when it exists but will not open. |
func OpenCorpusRegistry(dir string) (*cards.Registry, error) |
cards.SharedCorpus for a directory you name. |
func SharedCorpusDir(dir string) (corpus string, ok bool) |
Where the corpus is for code inside the repository rooted at dir (GORGE_CARDS_DIR overrides). |
func RepoDeckNames() []string, func LegacyDeckNames() []string |
The embedded repository decks by file stem; the 12 pinned Legacy decks. |
func LoadRepoDeck(r *cards.Registry, name string) ([]*cards.Card, error), func RepoDeck(t testing.TB, r *cards.Registry, name string) []*cards.Card |
One embedded deck, resolved (the t form fails the test on error). |
func SampleDecks(t testing.TB, n int) (names []string, decks [][]*cards.Card) |
n small decks authored inline: no corpus needed. |
func AcceptanceDecks(r *cards.Registry, seats int) ([]string, [][]*cards.Card, error) |
The seating of the golden acceptance game. |
func CheckInvariants(t testing.TB, g *state.Game, d *decision.Decision, where string) |
The engine's state invariants, for a fuzz or property test. |
feedback.EngineAt(t testing.TB, dir string, n int) *rules.Engine |
Package testkit/testutil/feedback: loads a captured feedback snapshot and replays it to n intents (n < 0: all of them), so a bug report becomes a test at the exact board the reporter saw. go run ./cmd/repro -emit-test <pkg> <dir> writes the skeleton. |
bench.PlayGame(...) |
Package testkit/bench: the batch drive loop described above. |
The server tier is forbidden from importing testkit/testutil (see the
layering table), so these stay out of production binaries.
Stability¶
gorge is a young engine under daily change, and its Go API is not versioned. State the promise exactly:
- No releases. The module has no semver tags (
git tag -l 'v*'is empty, andgo list -m -versions github.com/adams-shaun/gorgelists none, checked 2026-10-21). Depend on a commit's pseudo-version and move it deliberately. gorge's two companion modules do exactly that: they pin gorge by commit SHA. - Go signatures can change in any commit. Nothing checks exported Go
signatures against a previous version. Field additions to
rules.Confighave so far been zero-value compatible (each field's doc says so), but that is a habit, not a test. internal/is not importable outside the module (Go's own rule):internal/archtest,codeshape,corpusheap,testbudget,tsgen.testkit/is test support, shaped by gorge's own tests.
What is pinned, and by which test:
| Surface | What holds | Enforced by |
|---|---|---|
| Wire frames | Every frame type's JSON shape matches a committed golden. protocol.Version (1) "bumps only on a breaking change"; that bump is a convention, not a test. |
TestGoldens, server/protocol/protocol_test.go over server/protocol/testdata/*.json |
| TypeScript wire types | web/src/protocol.ts is exactly what cmd/gentypes generates from server/protocol. |
TestCommittedProtocolTSIsFresh, cmd/gentypes/main_test.go |
| Event log and replay | events.Kind is append-only (documented on the type). Renumbering a kind changes every chain head, so the golden heads catch it; the acceptance games must also replay byte for byte. |
TestHeads, engine/rules/heads_test.go against engine/rules/testdata/heads/{2,4,6,8}.txt; TestRepoDeckGamesReplayExactly, engine/rules/acceptance_test.go; TestEveryKindHasADescriptor, engine/events/kindinfo_test.go |
| Decision kinds | decision.Kinds lists every declared kind exactly once. Adding a kind is a breaking change for every client, but no test forbids it. |
TestKindsListsEveryKindOnce, engine/decision/decision_test.go |
| Hosted-policy gates | The Run*Contract harnesses above are the same functions gorge's own host tests call. |
server/host/hosted_policies_test.go, hosted_closed_test.go, shadowscan_test.go, caretaker_life_plan_test.go |
| Import layering | The rows below. | internal/archtest |
The layering rules internal/archtest enforces (non-test imports; "reach"
means directly or transitively):
| Rule | Test |
|---|---|
engine/effects, engine/view and engine/deck never reach engine/rules; engine/cards never reaches engine/state |
TestDependencyOrderHolds, arch_test.go |
bots/botpolicy never reaches engine/view, engine/rules or engine/seat |
TestDependencyOrderHolds |
No bots/... package reaches server/host or testkit/testutil |
TestDependencyOrderHolds |
server/protocol never reaches engine/rules or bots |
TestDependencyOrderHolds |
server/host, server/host/httpapi and server/gorged never reach testkit/testutil |
TestDependencyOrderHolds |
The rules sub-packages (engine/rules/cost, combat, trigmatch, scriptfacts, pay, chars, resolve) stay below engine/rules |
TestDependencyOrderHolds and the layering_*_test.go files |
Only server/host, server/host/httpapi, server/host/manabrewhttp, server/gorged and three developer tools (cmd/cardfuzz, cmd/paymirror, cmd/enginebench) import time |
TestTimeIsImportedOnlyByTheHost, arch_test.go |
No exported function of server/host or server/host/httpapi has *state.Game or *rules.Engine in its signature |
TestNoExportLeaksAnEngineGame, arch_test.go |
Nothing imports math/rand (v1); randomness is math/rand/v2 with a seeded source |
TestNoLegacyMathRand, arch_test.go |
The root go.mod has no require: the whole module builds from the standard library |
TestRootModuleHasNoRequires, gomod_test.go |
The module builds for a 32-bit word (GOARCH=386) |
TestEngineCompilesFor32Bit, arch_test.go |
One rule that holds but is not a test row: engine/ imports nothing outside
engine/ (ADR 0001). At 513a2a0c3, go list -deps ./engine/... names no
gorge package outside engine/, but no single archtest test asserts it
as a blanket rule; it follows from the rows above plus review.
See Invariants for the design rules behind these tests and Repository layout for where each kind of change goes.