---
name: ib-testing
description: Use when testing Immortal Barons behavior — verifying a mechanic's numbers, reproducing something seen in play, exercising inter-BBS features, or changing game state outside the menus. Covers which of the three test surfaces to reach for, how to set config without the editor, and the traps that cost hours. Triggers on "test this", "verify the balance", "reproduce what I saw", "set up a league", driving `-local` from a script, or any question answered by running the game rather than reading it.
---

# Testing Immortal Barons

**`-data` defaults to `./data`, and the repo root holds a real `data/world.json`.**
So `immortal-barons -local` run from the checkout plays a live world, not a
scratch one. Pass `-data` explicitly at every invocation, pointed somewhere
under `/tmp`.

**There is no seed control from the command line.** `store.Load` builds worlds
through `game.NewWorld` (time-seeded) and the RNG is unexported and never
serialized, so two `-reset-from-config` runs differ. Anything that must be
reproducible is a Go harness with `NewWorldSeed`, optionally unmarshalling a
saved `world.json` on top — do not burn time hunting for a `-seed` flag.

Three surfaces answer different questions, and picking the wrong one is where the
time goes.

| Surface | Answers | Costs |
|---|---|---|
| `internal/game` harness | "is this number right", "what does the engine do to a world" | milliseconds |
| `internal/menu` scripted session | "what does a PLAYER get" — every gate, prompt and refusal | milliseconds |
| Scripted two-board league | "does this inter-BBS feature move a packet" | a minute |
| Live BBS boards | "does a real sysop's setup work end to end" | tens of minutes, and a configured rig |

**Pick between the first two by where the rule lives, and the engine enforces
very little.** New-realm protection is the worked example: `World.Attack` has no
protection check at all and will happily fight a protected defender. The gate is
`game.Targets` plus `menu.blockedByProtection`
(`internal/menu/actions_attack.go:175`). A `game` harness therefore answers
"can a protected realm be attacked?" with a confident **yes**, which is wrong for
every question a player or sysop is actually asking. Ask "would a player ever
reach this?" — if yes, script the menu.

**Two facts the engine harness needs, both of which cost an hour when missed:**

- **`NewWorldSeed(DefaultConfig(), seed)` builds a world with NO empires.**
  Nothing seeds one: computer barons were retired in v0.2.0 and the `AICount`
  setting went with them. Call `w.AddAIEmpires(n)` or `w.AddHuman(...)` after
  the world exists.
- **`PlayTurn` does not collect income.** `CollectIncome`, `Manufacture` and
  `GrowFood` are separate turn-start calls the caller makes
  (`internal/game/turn.go`, `internal/menu/gameflow.go:57-59`). A naive
  `PlayTurn` loop leaves gold unchanged every day and reads as a broken
  economy. Drive days with `DailyMaintenance` instead.

Delete the throwaway file afterward.

The recurring failure is not reaching for a harness at all: fidelity work
(reading the binary, parsing captures) puts you in a mode where the data source
feels fixed and external, and simulation stops feeling available. "I haven't verified
this" is a trigger to build a sandbox, not a disclaimer to ship.

**A question about game behavior belongs in a throwaway world even when a live
rig is sitting there** — a fresh world is faster, isolated and repeatable, and it
cannot leave a test's leftovers behind to confuse the next session.

**`go build ./cmd/a ./cmd/b` builds BOTH and writes NEITHER.** Go discards the
binaries when given more than one main package, so a rebuild that prints no
error can leave yesterday's binary in place — and the next verification run
then tests the old code and "proves" the new code does not work. It cost a
wrong conclusion here on 2026-09-06: a `-league-check` change was reported as
not taking effect when the binary was 17 hours old. Build them one at a time,
or pass `-o` for each, and check the mtime before believing a negative result.

**`World.Today` is a PLAY-SESSION field and is empty in a planetary run.** It is
set in `internal/play` and nowhere else, so anything in `internal/store` or the
engine that dates something off it silently gets `""` on the `-planetary` path —
and a unit test that builds its world with `NewWorldSeed` sees the same empty
value, so the test agrees with the bug. Date something by the wall clock
(`RecordedTimeFormat`, as `LastPacketFrom` does) when the question is real
elapsed time, and by `LastMaintDate` when it is the game clock. Caught 2026-09-09
on a grace window that would never have opened on a real board.

**Per-run state that depends on the world being RELOADED is per-process state.**
The once-per-run hold notices deduplicate through an unexported map on the World,
which a door clears for free by loading the world afresh every run. Two
`RunPlanetary` calls sharing one World in a test reported the fault once and then
went silent, which reads as a broken notice rather than a test artefact. If a
run has "once per run" bookkeeping, clear it at the top of the run
(`World.BeginRun`), not by trusting the reload.

**Testing a MIXED-VERSION league costs one worktree.** To prove a change does not
force every board to upgrade, build the released tag beside the working tree and
run one board on it:

    git worktree add /tmp/scratch/v0.0.10 v0.0.10
    (cd /tmp/scratch/v0.0.10 && go build -o /tmp/scratch/ib-v0010 ./cmd/immortal-barons)

Then drive that board's steps with the old binary and the rest with the new one,
and check BOTH directions apply — a packet the old board accepts proves nothing
about the packet it sends back. `git worktree remove --force` afterward.

**The rig finds a class of defect the unit tests cannot: what the run SAYS.** One
session's rig run turned up a ruleset hold reported as "held for a protocol this
build does not read" (wrong fault, wrong fix), a release line claiming a format
change that had not happened, and one notice per held file where the sysop needed
one per fault. Every unit test passed throughout — they assert counters and
state, and nobody had read a whole run's output. Read the output, not just the
assertions.

## Fixed seeds

A fixed-seed test may only assert what holds on OTHER seeds. A macro outcome
("nobody is eliminated") is a property of the whole simulation, and one seed is
one trajectory. Run several and assert the property, or assert an exact computed
figure — those stay deterministic. Run `GOARCH=386 go test ./...` when the change
touches money, to catch the 32-bit overflows the 64-bit build hides.

## Moving the clock: `IB_CLOCK_OFFSET`

Anything the game schedules in real time — a Gooie Kablooie's three-day build and
two-day flight, a group attack's departure, the Travel Times probe — is read from
one clock, and `IB_CLOCK_OFFSET` shifts it. It takes a Go duration, and the game's
date string comes off the same clock, so the instants and the day counter cannot
disagree:

```
IB_CLOCK_OFFSET=72h ./immortal-barons -data ./data -maint
IB_CLOCK_OFFSET=120h ./immortal-barons -data ./data -planetary
```

`faketime` does NOT work here, whatever its man page suggests: Go reads the clock
without going through libc, so an `LD_PRELOAD` shim never sees it. This was
checked, not assumed. `IB_GAME_DATE=YYYY-MM-DD` still works and is translated
into the equivalent offset.

## Seeding computer barons: `IB_ADD_AI`

Barons were retired as a game feature in v0.2.0 — a computer realm cannot answer
a treaty offer or a message, so the diplomacy the game is built on had nothing to
work against. The code that plays them is kept, because a populated planet is
what a screen with rivals on it, a multi-day economy run and the score table have
to be checked against. `IB_ADD_AI=N` is the only way in:

```
IB_ADD_AI=3 ./immortal-barons -data ./data
```

It adds N barons to the world and exits, printing what it is about to do. It
refuses on a league board, it takes 1 to 25, and it exits 2 on anything else
rather than rounding the value.

**It writes.** The barons are in `world.json` afterwards, holding realm slots and
taking a turn every daily maintenance until the game is reset. That is the
difference from `IB_CLOCK_OFFSET`, which colors one run and is gone.

**Loading a world saved before v0.2.0 removes its barons** and records that it
has done so, which is why a baron seeded here survives the next load
(`internal/game/retire_ai.go`, deleted a few releases on). A rig carried over
from an older build loses the barons it had; seed them again here.

**The offset only ever goes forward, and the game enforces it.** A board writes
its shifted instants into `world.json` and into the packets it sends, and those
outlive the run: from a smaller shift they are in the future, so a weapon never
lands and a probe never returns. Loading a world written under a LARGER shift is
refused, naming both figures and `IB_CLOCK_REWIND=1`, which overrides it. So the
rule for a rig is: once a board has been run shifted, every later run of that
board uses a shift at least as large, until its data directory is reset.

**The offset never goes backwards either.** A shift that would move the clock
back is refused for the same reason, since every interval the game measures goes
negative at once and the result reads as the game losing things rather than as a
clock problem. `IB_CLOCK_REWIND=1` overrides both refusals.

**Shift every board on the rig by the same amount, or Travel Times lies.** A
round trip measured between two boards on the same offset is still real, because
both stamps move together. Mixed offsets produce a figure that looks plausible
and is not.

Two nodes sharing one data directory under DIFFERENT offsets is the same
misconfiguration, and it shows up as the smaller one failing every transaction
with a store error rather than as anything that mentions clocks. If a rig board
starts refusing to load, check the offsets before anything else.

**Every tool that touches the world needs the offset, not just the game.** A
throwaway Go helper that loads and saves through `internal/store` is a second
node as far as the guard is concerned, so running it unshifted beside a shifted
board is refused — correctly, and with the right message. Have such a helper read
`IB_CLOCK_OFFSET` and call `game.SetClockOffset` before it loads anything.

**A staged realm is idle-removed across a date jump.** Advancing several days to
reach a weapon's arrival runs several days of maintenance, and a realm that was
written straight into `world.json` and never played is swept as a husk on the
way — so the board arrives at the moment under test with no barons and nothing
to drive the screen with. Stage the realm, jump the clock, then re-add the realm
before driving a turn.

A banner prints on stderr on every run while a shift is in force. That is
deliberate: a shifted board is fine on a rig and an accident anywhere else, and
the banner is the only thing between them.

## Changing game state without the menus

`config.json` holds the game rules, and `store.repair` overwrites
`World.Config` from it on every load (`internal/store/store.go:55-58`), so
editing the world's copy achieves nothing.

**It is not the only file, and it does not win.** Per-board settings —
`BoardID`, `LeagueNumber`, `GameInbound`, `GameOutbound`, and the FTN
transport's lines (`IncomingFileDir`, `OutgoingNetmailDir`, `Mailer`, `Link`, …) — live
in `bbs.cfg`,
and `LoadBoardConfig` runs *after* the JSON (`internal/store/config.go:70`), so
bbs.cfg overrides it. Editing `LeagueNumber` in `config.json` on a rig that has
a `bbs.cfg` silently does nothing. Dropfile settings are in `door.json` and
`MinBoardVersion` has its own file.

The flags that exist for testing, none of which need the editor:

| flag | does |
|---|---|
| `-dump` | print the normalized world as JSON after load-time migration — the inspection tool for "reproduce what I saw" |
| `-spectate N` | play N days of computer-baron turns and print standings, the built-in balance probe. Needs `IB_ADD_AI` first, or it has nothing to watch. **Advances and saves**, so never point it at a rig you care about |
| `-reset-from-config` | rebuild the world from the current `config.json`, no editor |
| `-league-check` | report roster, board name, packet directories and keys at once — run this before blaming a league test |
| `-league-routes` | print which board each planet's packets are handed to |
| `-full`, `-detailed` | one full inbound/turn/outbound cycle, and per-packet tracing |
| `-dupe-check on\|off` | force the league lockout for one run; never written to `config.json` |

Prefer a CLI flag over the Configuration Editor when one exists. The editor is
driven by keystrokes, which means **the change leaves no searchable trace**: a
later session cannot tell how the state was reached, because the field name was
never typed anywhere. That is why `-dupe-check` exists.

    immortal-barons -local -dupe-check off     # this run only; `on` to force it on

**A testing override must not reach disk.** `-dupe-check` is a per-invocation
switch, not a setting: it changes behavior for one run and leaves `config.json`
untouched, so a test session cannot leave a league rule changed behind it.

The obvious implementation is wrong, and the reason generalises. Overriding the
in-memory `game.Config` after `store.LoadConfig` *appears* correct — `Load` and
`FileStore` both take `cfg` as a parameter and `repair` does `w.Config = cfg`, so
the value propagates everywhere. But four call sites write that struct back out
(`config_editor.go`, `config_editor_tui.go`, `store/ibbs.go` applying a league
broadcast, and `main.go`), so opening the Configuration Editor mid-session would
silently persist the override.

**Override at the READ sites instead, behind one accessor**, so a later read site
cannot be added that ignores the switch. Before adding any override of this kind,
grep for every reader of the field AND every writer of the struct that holds it —
the writers are what turn a temporary switch into a permanent change.

A flag that genuinely *is* a persisted setting is a different shape: take the
world lock, re-read `config.json` under it, set the field, `store.SaveConfig`,
print what changed, exit. Re-reading under the lock matters because a
Coordinator's `-planetary` run rewrites the same file.

**A league-wide rule does not stay where you put it.** Anything carried in
`LeagueConfig` (`internal/game/ibbs.go`) is overwritten on member boards by the
Coordinator's next broadcast. A setting changed on a member board is temporary by
construction; one changed on the Coordinator propagates whether or not that was
the intent. When a test needs a rule to hold, set it on the Coordinator and
re-broadcast, then check it survived.

## Driving `-local` from a script

Two traps, both of which fail silently:

- **A line of input ends with `\r`, not `\n`.** The session reads CR; a `\n`
  never terminates the line and the run desyncs with no error.
- **A league board takes no computer barons.** `AddAIEmpires` refuses while IBBS
  is on, so create a human realm on each side first, or a packet arrives with
  nobody to receive it.

**The language picker is still there**, and it is the third trap. A first run
needs a prelude before any game key:

    ' 1\r<RealmName>\ry\r'

— space to clear the splash pause, `1\r` to pick English, the realm name, then
`y\r` to confirm it. Omit it and the realm gets named from your first keystroke
and every key after that lands one screen early.

**A single `printf` of the whole sequence can outrun the prompts.** Piping the
lot at once works for short scripts and then stops working as the flow grows: the
onboarding reads some answers with `ReadLine` and some as single keys, and a
burst that arrives before a prompt is drawn is consumed by the prompt before it.
The symptom is the run reaching a prompt and ending there, with the realm never
created and no error — the same silent shape as the traps above. Pace it instead,
which costs a few seconds and is reliable:

    { printf ' '; sleep 1; printf '\r'; sleep 1; printf 'TesterRealm\r'; sleep 2
      for i in $(seq 1 25); do printf '\r'; sleep 0.3; done
      printf '0\ry\r'; } | immortal-barons -local -name Tester -data "$dir"

Found 2026-09-05 verifying the Slackware package: the same key sequence failed
three times unpaced and worked first try with the sleeps, creating the realm and
reaching the Spending menu.

**A scripted key sequence must assert it REACHED the screen it tests.** When the
script runs dry the session ends *cleanly*, so any flow change upstream — a new
prompt, a re-mapped hotkey — leaves the test green while it never reaches the
code it covers. Two tests rotted this way, one for weeks, after that same
language picker ate a key. Assert a marker unique to the target screen plus a
state effect (`TurnsPlayed` rose, the treaty formed).

## A menu-level test, in full

The form the `internal/menu` tests use — this is what to copy when the answer
lives behind a gate rather than in the engine:

```go
menus := BuildMenus()
f, w, err := run(t, "g  0", menus.Game)   // helper in menu_test.go
// then assert on f.out.String() and on w
```

`run` builds the fake session and world and calls `Run(f, w, root)` for you.
The roots are **fields on `BuildMenus()`** — `.Game`, `.Attack`, `.System` —
not paths walked from `Game`, which is why grepping the menu tree for them
finds nothing. Hotkey dispatch is case-folded, so a lowercase letter is the
honest key to script.

## Gates that block a test before it starts

Check these first when a mechanic appears not to work:

- **New realm protection** gates trading and the market on both sides. End it
  from the System menu rather than playing turns to burn it off.
- **An interplanetary or WMD item that is simply absent is a config switch**,
  not a bug: `BombingOps`, `MissileOps`, `IPTrading`, `GooieKablooie` and
  `InterBBSEnabled` hide their items with no message
  (`internal/menu/tree.go:37-44,544`).
- **BRE gates most InterPlanetary options behind a turn played this entry, and
  IB does not — that is a known fidelity gap, not a settled difference.**
  BRE's `enforce_interbbs_turn_requirement` (`BRE.OVR 0x020a12`) prints "You
  must play at least one turn per entry in the game to access this option." and
  is called from **four** sites in `run_interbbs_menu` (`0x020caf`). Banking
  and IP messages are reachable at the entry menu without a turn. IB has no
  such check anywhere in `internal/game` or `internal/menu`. So when a test
  reaches an IP op without playing a turn, **that is the bug, not the
  baseline** — do not write the permissive behavior into an expectation.
  Issue #162.
- **A `LeagueNumber` of 0 fails `-league-check` on a league board, and the
  FTN transport refuses to run** (#227). `ReadInbound` skips a packet just when
  reader and packet numbers are both set and differ, so a board left at 0 takes
  every league's packets as its own — which is why a test rig needs a real
  number on every board, not just the ones sharing an inbound directory.

---

# Running a local BBS rig

Everything below concerns a rig of real BBS software on one machine. It is the
slowest surface and the only one that answers "does a sysop's setup work".

**Paths here are written as placeholders.** Substitute your own:

| Placeholder | What it is |
|---|---|
| `$MYSTIC` | a Mystic BBS install |
| `$SBBS` | a Synchronet install |
| `$SBBSSRC` | a Synchronet source clone used only for building |

## Choosing board software by what the transport needs

**Mystic is reported not to test an FTN netmail path at all** — keeping its own
message bases with no `*.msg` netmail directory for the file-attach convention
to write into, where Synchronet has one through SBBSecho. Unverified against
Mystic's own source or docs; confirm before concluding a handoff is impossible
rather than misconfigured.
Check for the directory the transport actually watches before concluding a
handoff is broken.

A useful pairing is one board of each, but the difference is no longer which
software runs the transport: every board has it built in. What the pairing
exercises is the two handoff MODES — the Mystic side takes bundles
through a plain file box (`Link N Obox`), the Synchronet side through
stored-message attach and SBBSecho — over one binkp link.

## Building Synchronet

- **Build from a clone, not from a reference checkout.** The install makefile
  builds in `REPODIR`, so pointing it at a tree you also grep for Synchronet
  source scatters `gcc.linux.x64.*` output through it. Clone to `$SBBSSRC` and
  install to `$SBBS`.
- **Unset `MAKEFLAGS` first** if your environment exports one. Parallel make
  breaks cryptlib's 3rdp extraction: the same archive is unzipped several times
  at once, directories vanish mid-unpack, and the build dies with
  `cryptlib.h: No such file or directory` — which reads like a missing dependency
  and is not one. `install-sbbs.mk` clears `MAKEFLAGS` for its sub-makes but
  cannot clear its own. Unset `CC`/`CXX` too if they carry a wrapper such as
  `ccache`:

      env -u CC -u CXX -u MAKEFLAGS make -f $SBBSSRC/install/install-sbbs.mk \
          SBBSDIR=$SBBS REPODIR=$SBBSSRC NOCAP=1 SYMLINK=1

- **`SBBSCTRL` decides where every binary looks**, defaulting to `/sbbs/ctrl`.
  Export it from your shell profile for interactive use. Profiles are not sourced
  for NON-interactive shells, so scripts, `cron` jobs, systemd units and tool-run
  commands must still set it themselves: `SBBSCTRL=$SBBS/ctrl <command>`.
- `SYMLINK=1` points `$SBBS/exec/*` back into the clone, so **the clone must not
  be cleaned away** afterward.

## Configuring Synchronet

- **Nothing may bind below 1024** after a `NOCAP=1` build. Move the terminal
  ports (`sbbs.ini`: Telnet, SSH, RLogin), turn off the Mail/FTP/Web servers, and
  disable every `services.ini` entry with a privileged port — NNTP 119, Finger
  79, Gopher 70, MSP 18, ActiveUser 11. NNTP retrying port 119 five times is what
  delays the whole Services startup. `[Services]` itself must stay enabled:
  BinkIT lives there.
- **`scfg` and `echocfg` render under tmux with `-iA -k`** (ANSI mode, no mouse)
  and can be driven with `send-keys` + `capture-pane`, exactly like the BRE
  harness in the `bre-gather` skill. Without `-iA` the banner draws and nothing
  else. To confirm which row the cursor is on before typing into it,
  `capture-pane -pe | cat -v` and look for the `^[[47m` background — guessing the
  row count is how the wrong field gets set. `INS` adds a list entry; ESC backs
  out and offers to save.
- **The system's FTN address is not in any file until `scfg` writes it.** It
  lands in `msgs.ini` as `[FidoNet] addr_list=`. Linked nodes are `echocfg`, and
  land in `sbbsecho.ini` as `[node:ADDR@domain]` with `BinkpHost`, `BinkpPort`
  and `BinkpPoll`.

## Linking two boards over binkp

- **Poll by NODE ADDRESS, not by domain.** `mis poll 99:1/2` works; `mis poll
  <domain>` reports "Polled 0 systems" and looks like a connection failure when
  nothing was ever attempted.
- **Mystic offers plain-text binkp auth; Synchronet demands CRAM-MD5 by
  default.** The symptom is `Authorization failed` on the Mystic side and
  `CRAM-MD5 required (and not provided)` on the Synchronet side. Setting
  `BinkpAllowPlainAuth = true` on that node fixes it — acceptable on a loopback
  test rig, and not something to carry into advice for a real board.
- **A private FTN domain needs its own zone, or BinkIT ignores the host you
  configured.** Outbound, BinkIT maps zone to domain through the `[domain:*]`
  sections, so with the stock `[domain:fidonet] Zones = 1,…` an address like
  `1:1/1@mynet` resolves as `…@fidonet`, misses the `[node:1:1/1@mynet]` section
  entirely, and falls back to `f1.n1.z1.binkp.net` — a DNS failure that looks
  nothing like a config problem. Inbound keeps working throughout, because that
  matches on address, which is what makes it confusing. Retagging the node
  instead makes it authenticate as the wrong domain and the far side answers
  `Bad address or password`.
- **Give the private net a zone nobody else claims, rather than taking zone 1
  off `fidonet`.** Both make BinkIT resolve the domain correctly, and the rig
  here ran on the second one until 2026-08-21. It is the worse of the two: the
  moment that board carries a real FidoNet feed its zone 1 is gone, and a
  `DNSSuffix` left empty resolves to the literal `example.com`, which
  Synchronet substitutes as the default (`exec/load/fido_syscfg.js`). Stock
  `sbbsecho.ini` claims 1–6, 8–11, 18, 21, 24 and a long tail of higher
  numbers; 99 is free and is what the rig and `docs/inter-bbs.md` now use.

## The FTN handoff chain, end to end

Each step has its own failure mode, so check them in order rather than guessing
where a packet stalled:

    immortal-barons -planetary          # unwraps what the mailer delivered into GameInbound,
                                        # applies it, writes replies into GameOutbound, then
                                        # bundles those: N.msg (attach), the peer's obox, or BSO
    $SBBS/exec/sbbsecho                 # packs the .msg into the BSO .flo, deletes it
    $SBBS/exec/jsexec -c ctrl exec/binkit.js   # OUTBOUND session, actually sends

**Run the whole chain; do not wait to be polled.** binkp is bidirectional and
an inbound session *does* hand over what the BSO holds
(`~/src/sbbs/exec/binkit.js:1008`), so the reason is not that BinkIT withholds
the queue. It is that the first two steps have to run before there is
anything in the BSO to collect: until `-planetary` and `sbbsecho` have run, a
poll from the other board finds an empty outbound and both logs look healthy.
See the `ftn` skill's Myths table.

**The chain is safe to run with callers online.** The transport takes its own
`barons-ftn.lock`, never while holding the world lock, and takes `game.lock`
only briefly to claim or deliver packets, so it never blocks a node mid-turn.
`-full` does not even wait for the transport lock: when another run holds it,
that half is skipped and said so on stderr.
It reads only complete packets, because `WriteOutbox` publishes each one under a
temporary name and renames it into place.

The `.msg` numbering restarts from 1 each run. SBBSecho deletes the message once
it is packed (kill-sent), so seeing `1.msg` again is the previous one having been
carried, not the same one stuck.

**On Synchronet, keep the outbound path short.** The Type-2 subject holds 71
bytes for the whole attachment path, 70 with `Mailer Binkley`'s `^`. `bbs.cfg`
can spend fewer — `SubjectPath Basename` writes the filename alone, a prefix is
resolved against the mailer's working directory (`subjectPath` in
`internal/ftn/config.go`) — but
**SBBSecho does not search for a bare name**, so a Synchronet board keeps
`Absolute` and needs a short data directory (`docs/inter-bbs.md`). `AttachDir`
moves the file itself off the `fido/` child. The preflight refuses the run and
moves nothing, so this is loud rather than subtle.

**The packet name does not grow** — `packetFilename` zero-pads the sequence to a
fixed width (`internal/store/ibbs.go`), so a path that fits keeps fitting. Issue
#156 removed the opposite claim from the code and the docs; this file had it
too. What does consume a thin margin is a longer directory or a board joining on
a longer node number.

The transport warns below 8 bytes spare (`internal/ftn/message.go:24`,
`handler.go:75-78`). Treat that warning as real headroom advice, and read a hard
failure — `attachment subject %q is %d bytes; FTN Type-2 permits at most …`
(`message.go:95-96`), which names the `SubjectPath` fix — as a path or config
change, not as drift.

## Converting a board to the bundled transport

The transport is optional. The handoff sends plain packets unless the board
sets `Bundled Yes` (or a `Link` line says `Bundled`), so a board whose
`GameInbound` reads `.brp` straight out of its mailer's directory keeps working
with no transport lines at all — which is how several league boards run. PR
#226 made the handoff always bundle, and a board on the file-drop path then quarantined every bundle
it received; #230 made plain the default so that upgrading one board cannot
break another. Converting a board to bundles is still worth doing once the
whole league can unwrap one, and it taught five things, each of which cost a
run to find:

- **The game's directories become private, and `IncomingFileDir` names the
  mailer's.** `GameInbound`/`GameOutbound` move under the data directory;
  `IncomingFileDir` points at where binkp actually drops. They must be
  different directories — set to the same one, the transport has nothing to
  publish into and delivers zero without saying why.
- **A file-box peer is an `Obox` link.** With no `Link` line the mode defaults
  to attach, so a routing board's unwrap step tries to write netmail on a board that
  has no netmail directory. The symptom was `open : no such file or directory`
  with a blank filename, which names neither the setting nor the file.
- **Fixing `bbs.cfg` does not repair a receipt already in the spool.** A
  journaled receipt replays the targets it was PLANNED with, so it keeps
  failing in the old mode. Clear `ftn-spool/in/<id>` and let the next run rebuild
  from the source bundle, which is still there because cleanup only runs on
  success.
- **Synchronet needs a short `AttachDir`.** The default attach spool under the
  data directory is long enough to blow the 70-byte Binkley subject budget on a
  board that fitted before the upgrade.
- **The order is built in now.** `-maint`/`-planetary`/`-full` unwrap before
  the planetary step and hand off after it; handing off first would send the
  previous cycle's outbox and look like a transport one cycle behind.

`immortal-barons -ftn-status` reads both spools and changes nothing, so it is
the first thing to reach for when a board goes quiet: it names each unfinished
target by peer, how long it has waited, and the failure it recorded. It also
works while the transport lines are invalid, which the unwrap and handoff
deliberately do not.

It also lists packets **no run has claimed** — in neither spool, so no other
count reaches them (#236). Two shapes, and the report tells them apart: a file
in an `IncomingFileDir` itself is usually an attach bundle whose envelope will
never arrive, and a file in a SUBDIRECTORY of it is one the mailer set aside
because the session was not authenticated. Mystic uses `unsecure`, and the
unwrap step reads each `IncomingFileDir` and nothing below it, so waiting never
helps — give that directory its own `IncomingFileDir` line and move the waiting
files up. Both are reported only
after an hour, so a file that just landed is never called a fault.

## Scheduling the exchange

Nothing in the game moves a file between boards and no mailer knows about the
game, so a league only advances when something runs both halves on a schedule. A
timer per board, every 15 minutes, is enough for a test rig.

**EVERY board needs its own timer, including the one that gets polled.** This is
the same "BinkIT does not push its queue to a caller" fact seen from the
scheduling side, and it is easy to get wrong because the league does not look
dead: packets keep arriving at the polling board, its journal keeps reporting
`Applied N packets`, and only the far side's outbound quietly grows without
bound. Confirm both directions by looking at BOTH boards' inbound, never one.

**`-planetary` does not run daily maintenance, and some mechanics are keyed to
the GAME day rather than the wall clock.** Maintenance advances the game day at
most once per REAL day (`internal/game/turn.go`), and an exchange script that
runs only `-planetary` never advances it. Before concluding an inter-BBS
mechanic is broken, check whether maintenance has run today; if it has not, run
`-maint` on each board and try again. (Travel-time probes used to be the case
that bit: they fired once per game day, and a rig went 7 and 12 days between
pings with healthy timers. Since `e69a33a0` they go out on every planetary run.)

**A deficit is permanent, and that is the design, not a fault to repair.**
`DailyMaintenance` refuses a second advance on the same real date (`if
w.LastMaintRun == today || w.LastMaintDate >= today`), so a board days behind
cannot be caught up — not in one pass and not in ten. Both clocks then move at
one day per real day, so the gap an idle period opened stays open. Do not loop
`-maint` trying to close it, and do not forge it by editing `world.json`: those
game days did not happen, and inventing them invents income, AI turns and
events nobody played. (This paragraph previously said a board "cannot be
fast-forwarded in a single pass", which reads as though several passes would
do it. They will not — 2026-08-27.)

**So the health question is whether maintenance RAN today, not whether the game
clock reads today.** `LastMaintRun` is the real date of the last run;
`LastMaintDate` is the game clock, permanently behind on any board that once
sat idle. Comparing the clock to the calendar reports every such board as
broken forever, which teaches whoever reads the report to ignore it. `-dump`
shows both fields.

**A board's timer must be retired with the board.** A timer left enabled for a
retired board keeps running `-planetary` on its data and keeps polling, so a
dead board goes on writing packets into a live one — which reads as ordinary
league traffic in the journal. Check the enabled timers against the boards that
actually exist whenever the rig changes shape.

The unit is a `oneshot` service with a templated instance per board, so one
script and one timer file cover the whole rig. Give the timer `Persistent=true`
(a workstation is not up at every scheduled time) and a `RandomizedDelaySec`, so
two boards polling each other do not open binkp sessions at the same instant.

Shape of the per-board script, which has to branch because the two board
softwares agree on nothing:

    for each game data dir on this board:
        immortal-barons -planetary -data <dir>   # runs the FTN transport too, where bbs.cfg sets it up
    then ONE mailer run for the whole board:
        Mystic:      cd $MYSTIC && ./mis poll <peer node address>
        Synchronet:  cd $SBBS && ./exec/sbbsecho && ./exec/jsexec -c ctrl exec/binkit.js

One mailer run per board rather than per game: several leagues share the link,
and running it last means a packet written this cycle leaves this cycle.

- **The data directory is not in the same place on both.** Mystic's is under the
  BBS `data/`; a Synchronet door's lives under `xtrn/<door>/data`. Do not derive
  it from the BBS root with one rule.
- **`SBBSCTRL` must be set inside the script.** systemd runs no profile, so
  `sbbsecho` and `jsexec` otherwise look in `/sbbs/ctrl` and do nothing useful.
- **Skip a missing data directory instead of failing.** Adding or retiring a
  league then needs no edit, and a half-set-up board does not spam the journal.
- `binkit.js` printing `We got an M_EOB, but there are still N files pending
  M_GOT` is normal on a rig like this — check the BSO outbound is empty and the
  file landed on the far side rather than reading it as a failure.

## Working on someone's rig without breaking it

A test rig is not production — nobody dials it, and experimenting on it is what
it is for, so do not stall to ask before playing a turn or running a step. What
it *is* is usually a working setup that took real effort to reach: two boards,
binkp both ways, keys, a roster. The care it needs is about not silently breaking
the parts that already work.

- **Check what a step does to the transport before running it.** If a board's
  `GameOutbound` directory IS its mailer's file box, anything that moves packets
  elsewhere — into a `fido/` subdirectory, say — stops delivery with no error.
  The league looks alive and quietly carries nothing.
- **Deliver before you tidy.** Files waiting in an outbound directory are usually
  pending mail, not litter. Poll first, then clean up what remains.
- **Say what you changed**, so a later session does not spend an hour on a
  symptom that was a leftover from a test.

## Machine-specific state is not in this file

This directory is tracked in git, so board paths, ports, FTN addresses, realm
names and league numbers do not belong here — they differ per machine and would
be wrong for everyone but their author. Keep them wherever you keep host notes.

What a rig needs recorded somewhere, for whoever sets one up: each board's
software and install path, its planet name and node number, its FTN address and
binkp port, its game data directory, its inbound and outbound directories, the
league number, and which board holds the Coordinator key.

## Keep this skill current

When a testing session teaches you something about *how to test* — a gate that
wasted an hour, a state change that left no trace, a harness worth reusing — add
it here in the same pass, without being asked. Prefer editing an existing section
over appending a near-duplicate. A lesson that exists only in a finished
conversation is lost, which is the exact failure that created this file.
