@hearthforge/plugin-reference
v0.2.0
Published
A complete HearthForge plugin for a fake two-container game — the SDK's executable spec
Maintainers
Readme
@hearthforge/plugin-reference
A real plugin for a fake game. It exists so HearthForge's game-agnostic wiring — provision → running, console, live data, backup/restore, self-heal, identity re-stamping — can be proven ONCE against a realm that boots in seconds, instead of once per real game.
It is held to every real-plugin law: all seven adapters implemented honestly (no
return true stubs), 100% i18n copy, config opaque to core, full SDK
compliance. Core must never be able to tell it from a real game. A core
change that needs a reference-plugin special case is a broken seam — fix the
seam.
The game
One image, hearthforge-refgame, three roles selected by REF_ROLE
(packages/plugin-reference/image/, three stdlib-only JS files, no npm deps):
| role | port(s) | what it is |
|---|---|---|
| store | 7801 | the "database": a line protocol (PING/SET/GET/LIST/DEL/SAVE) over /data/store.json, write-through + fsync |
| core | 7777 game, 7787 admin | the "game server": dials the store with retry, simulates a REF_BOOT_MS world load, then binds both ports and logs [core] ready |
| leaderboard | 7811 | module-only: TOP / INIT over /data/board.json |
Everything the platform touches is a real protocol: the admin port demands
AUTH <REF_ADMIN_PASSWORD> before any command, the command-plane probe is an
authenticated info round-trip (steady-scoped, so a dead plane can never wedge
a boot — readiness is the tcp dial), graceful stop is a command the server obeys
(SAVE then exit 0), and crash writes /data/crash/last.txt and exits 3.
The framing rule (one line, two forms)
Every request is ONE newline-terminated line and every reply is exactly one (a
list reply is JSON on that single line). A line carries its ARGUMENTS in one of
two forms, and image/line-protocol.js — required by both server.js and
refctl.js, so writer and parser cannot drift — is the only implementation:
| form | looks like | who writes it |
|---|---|---|
| argv (canonical) | ["stamp-realm","Probe Realm",""] | every programmatic writer: refctl admin <verb> [arg…], the plugin's encodeArgv (src/refnet/line-protocol.ts), the core's own store calls |
| space (legacy/human) | say hello world | the operator console — what a person typed, and commands whose last argument runs to end-of-line (say, HELLO) |
Space framing cannot express a multi-argument command whose arguments contain
spaces, and a realm name is copy: stamp-realm "Probe Realm" <host> arrived as
name="Probe" with publicHost shifted into the tail — silent data corruption,
not a quoting inconvenience. JSON also escapes a newline, so an argv-form request
can never split into two commands. A malformed argv line is answered ERR, never
re-read as the space form: guessing is how a shifted argument becomes invisible.
Compatibility: the image and the plugin version together, so this was a clean
break. The space form survives only because the console genuinely needs it;
refctl admin now takes ARGV (refctl admin stamp-realm 'Probe Realm' host),
never a shell string. AUTH <secret> is compared whole-line and is unaffected.
src/refnet/line-protocol.spec.ts round-trips the plugin's encoder through the
image's actual parser, and src/refnet/admin-protocol.socket.spec.ts runs the
real server.js roles as child processes and stamps a space-bearing realm name
through both writers over real sockets.
The image's own contracts — enforced in server.js, not merely declared
- No secret ⇒ no command plane. With
REF_ADMIN_PASSWORDempty the core logs a refusal and does not bind the admin port. An empty secret would otherwise pre-authenticate every connection (authed = ADMIN_PASSWORD === "") — a wide-open plane that can crash, stop and re-stamp the realm — while the panel's ownAuthAdapterrefuses to use it anyway.validateConfigrejects such a config too: two independent ends of one rule. The game port and the store keep serving. [<role>] readymeans the ports are bound, and it is the bootstrap'swait-log-patterntarget.wait-healthy(container RUNNING) is not a port-bound signal — the core binds only afterREF_BOOT_MS, so every exec against an admin/board port is gated on the ready line.- Crash notes live under the DATA dir (
/data/crash/last.txt), never under the/appWORKDIR — the declared crash glob is absolute for that reason, andReferencePlugin.analyzeCrashparses what it captures.
refctl.js is the in-image CLI the agent execs (the rcon-cli analog):
refctl ping, refctl admin <cmd…>, refctl stop, refctl board-init,
refctl top. It reads its endpoints and the admin secret from its own container
env, so no credential ever appears in a command line.
Building the image
scripts/build-images.sh refgameThat is the ONE command. It tags hearthforge-refgame:latest — the exact
reference the generated compose asks for. refgame is not in the publish matrix,
so unlike the published images it is NOT tagged :ci-local: that build is the
only artifact there is, and any other tag leaves every provision failing "image
not found".
The generated compose sets pull_policy: never — the image exists on no
registry, so a docker compose pull must never reach out for it. A deploy on a
node without the image fails loud ("image not found"), which is the honest
outcome: build it there first.
Single-node test article. A remote agent node would need the image loaded by
hand (docker save | docker load). That is fine and deliberate — this game is
for the local gate, not for operators.
Loading it
It is NOT in the panel's default plugin list. Name it explicitly:
HEARTHFORGE_PLUGINS=@hearthforge/plugin-azerothcore,@hearthforge/plugin-minecraft,@hearthforge/plugin-referenceProduction defaults stay at the two real games.
Concurrent realms: pick a port, or suppress host publishing. The core
publishes its game port 1:1 (gamePort, default 7777 — PortRequirement.configKey,
the allocation model), so two reference realms on ONE panel coexist by taking
different ports: the wizard pre-flight (POST /instances/port-plan) suggests
the next free one and a collision is refused with 409. Realms from DIFFERENT
panels on one daemon are invisible to each other's plan — for that regime (the
gate, parallel dev worktrees) run the panel with
HEARTHFORGE_SUPPRESS_HOST_PORTS=1 (what the gate does) and core strips host
publishing — every realm is then reachable only in-network, which is all the
platform itself needs (the agent execs refctl; probes dial container IPs).
Without it, keep to ONE reference realm per daemon.
Keeping it honest (the anti-drift rule)
Any NEW adapter or manifest capability lands with its reference implementation in the same PR. The reference plugin is the contract's executable spec; a capability no reference implementation exercises is a capability nothing tests generically.
