The text-adventure engine in Rust: the dice, the geometry, reading a typed
line against the room the player stands in, building the requests the engine
hands a model, the model client that sends them, and a turn loop that plays a
line with no model at all over the game's own SQLite database. The rules take
values and return values; only the turn loop (store, turn, outcome,
engine) touches a database, only the model client (model) touches the
network, and nothing needs an async runtime. Its dependencies are
serde_json, for the System One request and answers and the rows a rule
reads, yaml-rust2, for the engine data it compiles in (data/, see its
README), sha2, for request digests, rusqlite with SQLite bundled, for the
database, regex, for the refusal detector, and ureq with rustls, for
HTTPS.
It was written to reproduce the game's Ruby engine exactly, roll for roll,
and now owns the game's behaviour: it is checked against the golden vectors
(vectors/, see its README), most of which the game still exports from the
rules its Ruby code runs, and against the engine sweep, the game's stored
scripts of typed lines with the state after every step (parity/, see its
README). The goldens of the sweep are this repository's own: a change to a
rule rewrites them here, and the game vendors them at the commit it pins.
| Module | Ruby original | What it answers |
|---|---|---|
random |
Ruby's Random |
MT19937 seeded as Random.new seeds it; bounded draws and Array#shuffle |
roll |
Roll |
the five-part seed, a die, a pool, one of a list, a weighted pick |
stat_block |
Character::StatBlock |
a body's level, hit die and abilities |
spot |
Location::Spot, Location::Placement |
where in a room a thing or a person stands |
population |
Location::Population, WorldSeed.natural_key |
how many people a room holds, seeded from a CRC32 of its name |
text |
Ruby string behaviour | downcase without the final-sigma rule, ASCII strip and \s, inspect, CRC32 |
danger |
Location::Danger (the pure half) |
a new room's danger, monstrous throws, a building's danger and hazards |
parameters |
Location::Parameters |
a building's picks resolved against its tables, kept in key order |
boxes |
Location::Box |
walls, shared ground, bearings and distances between rooms |
interior |
Location::Interior |
a building's rooms and doorways, returned rather than written |
floor |
none: it was written here | a floor's rooms and doorways (a place its world gives a floors row): exactly its room count on one storey, cut into a near-square grid walked as a serpentine, each room's danger and hazard from the floor's own words, its sorts dealt from its own kind, and the way-down room rolled from every room but the entry, and on a floor somebody is posted to (characters.posted_to_id) the room its guards stand in, the way-down room or with no way down a room rolled after it, and last its supplies: its safe rooms (floors.safe_rooms, never the entry, the way down or the guards' room, each laid out safe with no hazard) and the rooms a healing thing is left in (floors.healing_count, never the entry), all out of the place's own generator; returned rather than written, and written once, on the first entry (turn::Turn's layout, which opens the way down onto that room, stands the guards in theirs, marks each safe room sanctuary with nobody born in it, and leaves each healing thing as a world row carrying the supply marker, items.supply) |
kind |
Location::Kind |
what sort of place a room may be and how cluttered, and the sort each room of a building is dealt |
kit |
Item::Kit, Item::Contents |
what stands in a room of a kind and what lies about in it, rolled on the room's name from data/item/kits.yml, and the parts each piece is born with (kit::furnish); and what lies inside a closed piece, drawn on the piece's own row (Roll::CONTENTS) |
material |
none: it was written here | what a thing is made of (items.material), one of ten or unknown: the material a kit writes each name with, what a material settles about a thing's other columns (paper always burns, glass is never sturdy), and the doctor's findings for a thing they contradict. Breaking apart (divide) is the one act that reads it |
divide |
none: it was written here | breaking a thing apart: whether a tear (paper, cloth) or a smash (glass, pottery) is offered on a thing, by its material, where it is and the check it would take; what it leaves, one row of scraps, strips or shards begun where the thing was, which ends divided; and the beat guard, which offers no tear, smash or burn of a thing an open step of the story names |
shuffle_connections |
WorldMechanic::ShuffleConnections |
how a shuffle rearranges the mobile rooms' doorways |
world_mechanic |
WorldMechanic |
the cadence boundaries a mechanic has to run for |
deadline |
Quest::Deadline |
the hops walk, and the way on an overdue step is grown from: the deepest room the party can reach that nobody has written yet, or with none the room whose writing is finishing, never a room already written (way_on); and the game's old anchor room, which its vector still states |
cast |
Character::Registry, Character::Generator |
the seeded race, age, sex and background of new people |
physics |
none: it was written here | a fall through a doorway: the storeys it drops, the dice the world's gravity throws for them, the save that halves them, and what the toll says it was; and whether a thing that came down on a floor broke: its fragility, how it came down and the floor's surface, as a share of one die; and how far a thrown thing carries: its bulk, the thrower's strength and the world's gravity, the paces to what it was thrown at in a laid-out room, and where it comes down when it falls short |
carry |
none: it was written here | whether one more thing fits about a body: the hands its bulk takes, the room it takes in a storage place, and its weight against the load the body's strength bears, with no die; where it would go, and why not; and the doctor's fatal finding for an arc beat that asks the player to hold a thing nothing can pick up, and, where the limits hold, its warning for a person the world starts out holding more than fits (starts_carrying_more_than_fits: nothing is taken from them). Only a world whose universes.carrying is limited asks it: there a take, a throw or a wear of a thing lying here and a break of what would not fit are refused (cannot_carry), and a person is offered no take, and no give to the player, of what would not fit |
room |
Playthrough::Classifier's closed sets, Playthrough::PhysicalAction#choices |
the ways out, the cast, the floor, the hands and the physical attempts of one room |
grammar |
Playthrough::Grammar |
a typed line read without a model: a slashed line claimed, names resolved, refusals written |
intent |
Playthrough::Classifier::Intent, Playthrough::Classifier#build_intent |
what a line was read as, whether it is refused, and a model's answer resolved to records |
refusal |
Playthrough::Refusal (with DeathNotice and StoryOverNotice sentences) |
what the engine says when it will not play a line |
slash_menu |
Playthrough::SlashMenu |
the words offered after a slash and what each completes to |
cascade |
Playthrough::Classifier::Cascade, ::State, ::Request, SystemOneAgent::Answers |
the System One request for a line, and recorded answers composed into an intent or escalated. It sends nothing |
classifier |
Playthrough::Classifier#classify |
a line read over a room's records: System One first where it is on, then the classifier model's call, through whatever answers the two (classifier::Reader) |
records |
the rows a builder reads | every row of every table, handed in as values; a query is a filter over a list |
playthrough |
Playthrough's readers, Playthrough::Vitals::Condition, Playthrough::Toll#to_s |
who is in a room, how much is left of a body, who is fighting the party; the floors a game has reached (playthrough_floors, written by turn the first time the game stands on each), the level its player has risen to on them (on a floor whose row says levels_up, one past the level the game had, healed to full there and then; nobody else, and no world row, is ever raised), the hit points a body can take in this game now or when a blow or toll landed, and what the engine says to the player of a floor that raised a level, which no model is told; and a floor's countdown (floors.minutes from the game's arrival): when it falls in this game, which turn writes (playthrough_floors.fell_at) before a line is read and once its time is spent, killing a player who stands on it; that nobody on a fallen floor is alive in this game and no way out leads onto it, while the world's rows and every other game keep it whole; and what the engine says to the player of the time left and of a floor that gave way under them, which no model is told; and the rests a game has taken (playthrough_rests, written by turn when a wait in a sanctuary with no foe in it mends the player and spends an hour), and what the engine says to the player of each, which no model is told |
ledger |
Playthrough::Ledger |
what one person saw happen in one game, inside both of its bounds |
memory |
Playthrough::Memory |
which earlier exchanges come back into a prompt, ranked with Ruby's float arithmetic |
plan |
Location::Plan |
a room's size, storey and ways out, said in sentences |
moment |
Playthrough::Moment |
the narration context and a character's context for the moment the player stands in |
narration |
Scene::Narrator#prompt_for |
the narrator's request for a typed line, what the engine already did and the moment it happens in |
volition |
Playthrough::Volition#choices, ::State, ::SystemOne#request, ::SystemOne#decisions |
the acts a person may take and the System One request that asks which, and what it answered read back to tokens; what they may say unasked, and the speech die thrown over it |
schemas |
the RubyLLM::Schema classes a request carries |
each schema's to_json_schema output, key for key |
identity |
Eval::RequestIdentity |
the canonical form of a set of requests and its 16-hex digest |
arrival |
Scene::Generator, Scene::ArrivalContext |
the arrival writer's request for walking into a place |
realization |
Location::Generator, Character::Desires.instructions |
the room writer's detail and exits requests |
dialogue |
InteractionAgent, Character#interaction_instructions, Playthrough::NpcAction |
the character pass and narrator pass of one exchange |
clock |
Rails' datetime columns | a stored time as whole seconds since the epoch, and back |
store |
the schema db/schema.rb describes |
the database on a connection of its own: the schema version and the shape of every table it touches checked, every table the loop reads loaded as records, a row inserted or updated |
turn |
Playthrough::Mechanics with model: false, the Playthrough::Turn writers it calls, PhysicalAction, NpcAction, Riposte, Volition, Hazards, Arc, Fight |
one typed line read, refused or played, and the world's answer: who speaks up unasked, foes, volition's die, hazards, the arc and its ending, and the scene that closes a fight |
outcome |
Playthrough::Mechanics::State |
what a turn left behind, read off the records |
turn::chooser |
none: it was written here | the protagonist's act in a game the player narrates: the glance's targets for move, examine (a look around the room among them, in a game that notices), take, drop and use, weighed by the protagonist's own pursuit, boosted toward the arc's next beat, with a line already played on this visit weighing nothing and a thing the arc asks the player to hold never let go of, and one Roll::CHOICE over them |
narrates |
none: it was written here | a game the player narrates: the facts card the player writes from, the scene waiting for their paragraph, the request a narrator would have been sent for the turn, built and never sent, and the paragraph kept beside the scene with that request, never read |
noticed |
none: it was written here | what a game the player narrates has noticed of a room (noticed_at on its copy of each thing and its state for a person), the tiers that notice it (turn::notice), the refusal of a line naming a thing not noticed, and the doctor's findings |
wear |
none: it was written here | where on a body a thing is worn (items.wear, one of data/item/wear.yml's places) and whether it is worn in this game (items.worn, a kind of carried), and the doctor's findings (Engine::wear_findings): a worn thing nobody holds, two worn on one place by one body, a worn thing with no place, a place that is none; and how an engine sentence says where a thing is worn. /wear and /remove put a thing on and take it off (room's wear and remove attempts), reached only through the grammar, and recorded as wear and remove on the scene. No reader is handed either; what the player wears is told to the narrator (moment) and to a person spoken to (dialogue), and only while something is worn |
reach |
none: it was written here | what is within arm's length in a laid-out room (your cell and the eight around it), the nearest free cell beside a thing or person you walk up to, and where a step back of two paces puts you or that there is no room behind you, with no die; and the doctor's warning for a place the party could stand in with no layout (Engine::layout_findings), which changes nothing. No act reads it yet and nothing a model reads names it |
layout |
none: it was written here | a layout for a room that stands on its own (no parent): a box at the origin of its own plane, sized from its sort's band (data/location/size.yml, Roll::ROOM_SIZE on the room's row), written as the room is first written; which side each of its ways out leaves by (Roll::WAY_OUT_SIDE on the doorway's row, worked out on every read and never kept) and the cell beside it, which a throw at that way out is measured to; and every such room of a story with no layout yet, laid out with a spot for each of the world's own loose things and people in it (Engine::lay_out_rooms, which the game runs after it pulls). A storey is compared only within one place (plan::storey). Nothing a model reads names any of it |
revisit |
none: it was written here | the arrival diff: what a game saw of a room when it last left it, kept by the move that walked out, and on a walk back what has changed among it, in a closed list in the engine's words (a thing gone, held by somebody there, moved or broken; a person gone, come or dead; a way out gone or new; an event of the world in the room), kept by the move that walked back. Every game keeps both; a narrated game's arrival writer is told the list under "Since You Were Last Here" and that nothing else changed |
glance |
Playthrough::Glance, Playthrough::Availability, Playthrough::SlashMenu |
what a front end's panels show between turns: the room, its ways out, who and what is here, which verbs are open at what (each target one the turn plays, each closed verb closed in the refusal's words), the slash menu and the next beat; and the floor the player stands on with when it falls (floor), and the sentence for a floor that gave way under them (collapse); and for a safe room, the line saying so on the room (here.safe) |
facts, prompt_version |
Scene::Narrator#prompt_for, Playthrough::Turn's _fact builders, Playthrough::PromptVersion::Scaffold |
what a turn hands the narrator as already done, and that scaffold rendered against fixed placeholders for the game's prompt version to digest |
command |
Playthrough::Command, Playthrough::Command::Journal |
a submitted line and its token, the order lines were accepted in, and the receipts a turn writes as it goes |
turn::Turn |
Playthrough::Turn#play with a request token, Playthrough::Classifier#classify, InteractionAgent, Scene::Narrator, Scene::Generator, Location::Generator#realize! with Item::Kit#furnish!, Item::Registry, Character::Registry, Location::RoomName and Location::Interior.lay_out!, Quest::Binder, Quest::Deadline, Scene::Ending, Item::Inscriber |
a submitted line read, refused or played through the models, told in prose or in the engine's own words, and answered by the world |
engine |
Playthrough::Session's place at the switch |
a line in, the outcome out, one transaction per line, every failure a value |
parity |
EngineSweep::Walk, EngineSweep::Dump, EngineSweep::Parity |
a sweep script played through this engine, dumped step by step and compared |
model |
BaseAgent, BaseAgent::Refusal, SystemOneAgent, RubyLLM's OpenRouter provider and its chats/messages receipts, EngineSweep::BrowserTurn's fixed replies |
where a model call goes, the body it sends, whether an answer is kept, the model rotation, and what a call leaves in the database |
use renderedstep_engine::engine::Engine;
let mut engine = Engine::open(Path::new("storage/development.sqlite3"))?;
let outcome = engine.play(playthrough_id, "take the ward stamp", &mut |chunk| {
print!("{chunk}");
})?;
println!("{:?} {:?}", outcome.report.change, outcome.state.carrying);Engine is the whole surface a host calls, and it is shaped for the switch,
where the Rails app hands each whole turn to this engine in-process:
- Its own connection.
Engine::openopens the database file on a new SQLite connection and checks its schema. A database whose newest migration is older thanstore::SCHEMA_VERSIONis refused withError::SchemaMismatch. One at a newer migration is opened only when every table the engine reads or writes still has the columns, indexes, foreign keys and triggersstore::SHAPErecords, and nothing new points into one of them; otherwise it is refused withError::SchemaChanged, which lists each difference. A migration that only adds a table the engine never touches is therefore accepted; one that changes a table it does needs the change ported before the engine will open that database. - One transaction per line.
playtakes SQLite's write lock (BEGIN IMMEDIATE), plays the line, and commits. The caller must hold no transaction on the same database while it runs. Anything that fails rolls the whole line back. - A line and a chunk callback in, the structured outcome out. The
Outcomeis the report (understood,change,refusal,note,resolved_by) and the records the line left (outcome::State: the room, its exits, what lies here and is carried, who is present and fighting, hit points, the arc, the ending, what the clock owes).on_chunkreceives narrated prose as it is written; this engine plays with no model, so it narrates nothing and never calls it. - Errors as values, never a panic across the boundary. Every public call
returns
Result<_, engine::Error>. A panic inside a call is caught there (catch_unwind), the line's writes are rolled back, and it comes back asError::Panicked. A line that reaches a rule this engine does not play yet comes back asError::Unsupported, also rolled back.
Engine::start begins a playthrough the way the browser does,
Engine::read returns the records with nothing played, Engine::glance
returns what a front end's panels show between turns and writes nothing, and
Engine::play_deciding plays a line that talks to somebody with a fixed
decision standing in for the answer a model would give, which is how the
engine sweep plays its conversations.
A game is told one of two ways, chosen when it starts and kept on it
(playthroughs.mode, playthrough::Mode): narrated, where the player
types each act and the narrator writes what happened, and
player_narrates, where the game picks each act and the player writes the
paragraph. Engine::start_in starts a game in either; Engine::start is
narrated. In a game the player narrates:
Engine::actlets the game act: a submission whose line isturn::chooser::LINE, accepted, queued and journalled like any other, where the turn picks the protagonist's act with one die from what the player's panels offer (turn::chooser) and keeps the pick as itschosenstep before it reads it, so a resumed turn plays the same pick. The scene saysresolved_by = "engine". A game told the other way is refused withError::WrongMode, having written nothing.- No narrator is asked: every scene keeps the engine's own words, which are
not a fallback (
engine_fallbackstays false), and a game on a written world asks no model at all. A pick walks into a room nobody has written only when the caller lets it, which writes the room through the models. - The scene the turn was answered with waits for the player's paragraph
(
Glance::waiting).Engine::facts_cardis what the player writes it from, in the engine's player-facing words, andEngine::write_paragraphkeeps it inplaythrough_paragraphswith its author, beside the scene and never in it. No rule and no prompt reads it: a later prompt's "What just happened" and "Earlier, in order" are told the engine's fact and summary for each turn instead. - The turn builds the request a narrator would have been sent for it, at
the point a narrated game sends it, and sends nothing: the narrator's,
told the engine's act as the line typed, or the arrival writer's on a walk
into a room, then the ending's where the turn closed the story. It keeps
them in its journal (
narrates::WOULD_ASK,narrates::WOULD_END), and the paragraph's row carries them (requests) with the act's prompt digest as the game'sPlaythrough::PromptVersiontakes it (prompt_digest,prompt_version::digest).Engine::would_askreads them for a scene. The row also says what the player let the paragraph be used for (consent,narrates::Consent):noneunless the caller says otherwise withEngine::write_paragraph_with_consent, and a rewrite keeps it. Engine::candidateslists every act the game could choose now and what the die would weigh it at, and throws nothing.- The room is revealed in tiers (
noticed), each stampingnoticed_aton this game's copy of a thing: on the way in, the people, the ways out, every fixed piece and a rolled few loose things, larger bulk first (Roll::NOTICE); on a look (examinewith no target, offered as thelooktarget ofexaminewhile one would show something), the rest in plain sight, up tonoticed::MAX_VISIBLE_PER_ROOM; and on each turn spent in the room, one more (Roll::NOTICE). What was noticed stays noticed.turn::room_ofholds only noticed things, so the closed sets, the glance, the read-out, the chooser and the card follow the record, and a line naming a thing lying here unnoticed is refused in the engine's words. What lies inside a hollow or a closed fixture waits for the search (searchandopen,turn::search), which stamps what it finds. A piece's parts are seen when the piece is, and are in no closed set: only the break attempt reads them (break,turn::snap), and a part that comes away is stamped as it reaches the hand.Engine::noticed_findingsis what the game's doctor reports about it. A narrated game is never stamped and lists everything. - A walk back into a room the game left before puts on the card what has
changed there since, among what the game had noticed as it left
(
revisit,FactsCard.changed), orrevisit::UNCHANGEDwhere nothing has. A thing that came into the room while the game was away is not a change: the record cannot say it was not there, unnoticed.Engine::since_last_visitreads what a move found. A narrated game keeps the same record, and its arrival writer is told the same list under "Since You Were Last Here", with the instruction that nothing else has changed; a return the game kept no record of leaving (arrival_returning_unrecorded) is told to claim no change at all.
Engine::submit_fixed plays a submitted line with a fixed reading
(turn::Fixed: an action and the name of its target) wherever the
classifier would have been asked, and asks its models for everything else:
a bench that measures what a turn does after its reading plays its cases
this way. The request builders take rows rather than a database, so a
caller holding a staged position's rows builds any request the turn
would send, turn::room_of reads the room the grammar and the classifier
read (turn::whole_room_of with every thing lying there, noticed or not),
and classifier::read reads a line as a turn does.
Engine::submit plays a line the way every front end does
(Playthrough::Session#play with a request token), asking a model::Models
for its prose: model::Live over the player's route, or model::Replay.
The line is accepted into the game's submission queue first
(playthrough_commands); lines accepted earlier and still owed a finish
play before it, and a second delivery of the same token and line hands back
what the first produced and plays nothing. There is no transaction around a
submitted line: each effect is committed with its journal receipt as the
turn reaches it, and no transaction is open while a model is asked, so a
failure keeps what was committed before it, as the Ruby engine does. A call
for prose that fails after an effect was written is answered with the
engine's own words, and the turn still finishes. Engine::accept queues a
line without playing it.
The journal is also how an interrupted turn is finished. Each step's
receipt holds the value the step answered, written as the Ruby engine
writes it (Playthrough::Command::Journal#encode), and a step the journal
already holds answers that value again without running: a take committed
before a worker died is not taken twice, a crossing already paid for is not
paid again, and a narration already written is not asked for again. A
submission left running is finished by the next delivery of any later
line, before that line plays. Engine::submit_stopping stops a turn right
after a named step commits, as a killed worker stops, and comes back as
Error::Stopped with the submission still running; the engine sweep plays
its interrupted workers this way.
Reading a readable thing that has no words yet writes them first
(Item::Inscriber): one call, kept on the game's copy and on the world's
own before the narrator is told them, so every later reading, in any game,
reads the same words and asks for nothing. Offering a thing to somebody is
a conversation with one more choice in it: the person may accept what the
player holds out, and only the engine moves it.
A line with no slash, and a slashed line the grammar cannot place, is read
by System One first where it is on (cascade), and by the classifier model
when System One is off, escalates or is unavailable
(Playthrough::Classifier#classify); a reach that found nothing and a line
that named two things are counted as the Ruby engine counts them. Talking
to somebody is InteractionAgent's two calls: the person answers in their
own conversation with this game, picked up again on the next talk, and
picks one action the engine offered; the engine applies it, and the
narrator writes the exchange from the reaction and the engine's receipt.
Where System One is on, it also decides what the people in a room do on
their own (Playthrough::Volition::SystemOne). The speech die still decides
whether somebody speaks up unasked, and System One then chooses which of
the things on offer they say, or that they say nothing after all; everybody
else is asked their act, which replaces the die's only where the answer
reads them pressured enough (volition::PRESSURE_THRESHOLD). The call is
made before its slot's rows are written, one per slot: what the die let be
said, before the paragraph; the acts of the room the line began in, after
it; and on an arrival, both for the people reacting to it. Every row says
who decided it (decided_by), and whatever the call could not answer is
the die's own pick, with the reason on the row (system_one_error).
Walking into a room nobody has written writes it first
(Location::Generator#realize!): one call for its description, lore,
things and people, whom the engine has already drawn, and one for its ways
out, with the first answer kept on the room as a checkpoint so a room whose
second call failed is picked up there, never paid for twice. What the
answers propose is admitted, not obeyed (Item::Registry,
Character::Registry, the exit rules), and a building is laid out into
rooms as it is written and walked into at its entry. A place, a person or a
thing a room's writing admits is taken by any step of the story's arc that
was waiting for its name (Quest::Binder). Once the story has written more
rooms than its grace, a room finishing its writing places the arc's first
waiting step itself (Quest::Deadline), always in ground nobody has
written yet: off the deepest room the party can reach that is still
unwritten, or, with none, off the room whose writing is finishing. There
it lays out a place two levels down, builds a place to keep a person in
(unless a place the party can reach has a room for them, written or not),
or lays a thing. A room already written is never given a doorway or a
thing; with no such ground, nothing is placed until the next room is
written.
On the line that concludes the story's arc, the narrator writes the last
paragraph over the closing scene the arc already wrote (Scene::Ending).
A failed call, and a paragraph that stops mid-sentence, leave the engine's
own sentence standing on that scene.
A Ruby binding (magnus) is the next consumer and is not built yet. It is a
thin layer over this surface: open an Engine on the app's database path,
call play with a block that receives each chunk, turn the Outcome into a
Hash, and raise one Ruby exception class per Error variant.
Every call goes to OpenRouter by one of two routes, and the engine takes the
route as one value, model::Route:
Direct { key }: the player's own OpenRouter key, sent tohttps://openrouter.aiand nowhere else;Relay { base_url, token }: the owner's relay, which mirrors OpenRouter's paths under its base and holds his key under a monthly cap per player, so the request body is the same on both routes and only the base and the bearer differ;None: no model access, and the offline path only.
Route::pick applies the precedence (the player's own key, then a relay
invitation, unless the player asked for one of them) and never falls back
from one to the other. A key or token is a model::Secret, which has no
Display and a redacted Debug; a failure quotes the provider's message,
never the request.
model::Live sends a call under BaseAgent's policy: the models in
REMOTE_MODEL_IDS order, up to MAX_ATTEMPTS; a refusal, an ignored
schema, a provider failure or the caller's own check asks the next model in
the same context; a crisis answer is suppressed and a refused credential
fails, neither rotated past. It writes the conversation to chats and
messages, and a ruby_llm_usages row per attempt, as RubyLLM does, so the
Ruby app reads the receipts of a call this engine made. System One goes over
the same route to OpenRouter's decisions path, or to TypeSafe directly with
a TypeSafe key.
model::Replay answers each call with the next of a list of fixed replies
tagged by purpose, and checks what each prompt includes and leaves out: the
engine sweep's browser steps play this way, and nothing is sent anywhere.
tests/model.rs holds the client to what RubyLLM sent for the same calls
(tests/fixtures/rubyllm_wire.json, captured by a server on the machine
that answered with fixed replies), the refusal detector to the Ruby
engine's flags over its corpus (tests/fixtures/refusal_corpus.json), a
stream to what it shows of that corpus before the answer is judged, and
both routes to the no-leak contract over a real socket on this machine. The
kept requests of vectors/kept_requests.json are sent through the live
client too (tests/vectors/builders.rs), and go out exactly as stored.
fight plays one matchup thousands of times through the engine's own turn
loop and counts what happened. No model is asked, and no rule is copied: it
types fight lines (attack, go, drink, throw) into a throwaway game,
and the turn loop decides every blow. A rule is in the simulator as soon as
it is in the engine.
cargo run --release --bin fight -- sim/matchups/one-l3-d10.yml
cargo run --release --bin fight -- sim/matchups/one-l3-d10.yml --replay 812one-l3-d10: 2000 fights, seeds 1..2000, told driver, policy attack
rate 95% interval
won 0.230 [0.212, 0.249]
died 0.770 [0.751, 0.788]
fled 0.000 [0.000, 0.002]
rounds mean 3.72 2:118 3:748 4:774 5:301 6:54 7:5
...
A matchup file (sim/matchups/*.yml, read by sim::matchup) names the
seeds, the player's stat block and what they carry, the foes, and a way of
playing. Its keys are a world file's, so a stat block can be pasted from one,
and rolled takes a number from the engine's own roll for each seed. Any
other key of a foe, a carried thing or the story is written into that row as
given, so a column a later rule adds needs no simulator change.
- The arena is the sweep's one-room world
(
parity/worlds/a-chamber-of-one-room.sql) with the foes in its first room and its second room written, to run to (sim::arena). It is built once and kept as an image of its database, and each fight opens a fresh copy, so nothing one fight wrote reaches the next. - Seeds. A fight's playthrough id is its seed plus
sim::arena::SEED_BASE, and every blow's die is seeded off the playthrough, so a seed always plays the same fight, on any number of threads.--replay SEEDprints that fight line by line. - Drivers.
told(the default) plays each line withEngine::submitin a game the player narrates, slashed, with amodel::Replaythat holds no replies: the loop the browser plays, which moves the story's clock five minutes a line.mechanicsplaysEngine::play, a little faster, and its clock does not move between fights. Anything that hangs on time is measured ontold. With no move in the fight, the two give the same fight seed for seed. A move does not: the told loop writes its arrival before the parting blows, so they are thrown at a later time, from other dice. - Policies (
sim::policy):attackthe first foe standing every line;flee_below(hp: N) to the next room;drink_below(hp: N) a healing thing while one is carried;throw_first, which tries each carried throwable once;naive, which drinks below 30%; andcareful, which drinks at half and leaves once one more round could kill (hit points at or below the sum of the live foes' hit dice). The options combine:{kind: flee_below, hp: 6, drink_below: 9, throw_first: true}. A refused line is an error, not a turn, because it buys no riposte. - Results (
sim::tally): win, death and flight rates with Wilson 95% intervals, rounds and hit points left (mean and histogram), and damage and blows on each side.--jsonprints the same as JSON.--seeds A..B,--driverand--threadsoverride the file's values. - Sweeps (
sim::sweep): asweep:key makes a grid of matchups from one file, every combination of its values, each played and printed as a row. A key is a dotted path into the file (player.stats.level,foes.0.stats.level,story.lethality,policy.hp), so anything a matchup reads can be swept.sim/matchups/levels-grid.ymlis every player level 3 to 6 against a d10 foe of level 1 to 3. - A real world (
sim::world):--database FILE --story TITLEfights that seeded world's own hostiles, each against its protagonist, at--levels A..B(default: the protagonist's own). The database is copied into memory and the file is never written. Each body's stat block is taken from the world and the fight is played in the arena, so no room writer is needed; a stat the world left empty is rolled for each seed. A matchup file given beside them sets the rest: seeds, carrying, policy. - Exports (
sim::export):--csvprints a row a matchup, with every rate's interval and the means.--fight-table FILE(-for standard output) writes each matchup's P(win) and the share of fights that cost each number of hit points, which withp_diedsums to one. A dungeon-level study reads this table instead of copying the fight rules.
cargo run --release --bin fight -- sim/matchups/levels-grid.yml --csv
cargo run --release --bin fight -- --database dev.sqlite3 --story "The Iron Gate" \
--levels 3..6 --fight-table fights.jsontests/sim.rs holds it to four things: three matchups' exact odds, worked
out from the rules by hand, within four standard errors; per-seed identity
of the two drivers on fights with no move; the same seed playing the same
fight; and a fight's tally agreeing with its playthrough_blows rows. It
also holds a sweep's point to the fights of the matchup it names, and a
world's fights to the file being left as it was.
- Draw only from
random::Random. Any other generator rolls different dice. - Ruby's integer division floors: use
div_euclid, never/, on anything that can be negative. - Tables that Ruby keeps in a
Hashare arrays of pairs here, so their order survives. - A change that moves fight odds re-blesses the fight goldens in the same
PR:
cargo run --release --bin fight -- --write sim, then keep the diff ofsim/goldens/*.txt. CI runsfight --check sim, which fails on any table that moved.
cargo test -- --nocapturetests/vectors/ runs every case of every vector file and prints a pass count
per portion. An answer is compared as written, so key order counts as well as
values. It also checks each file's constants against this crate's
tables, and refuses a file whose format version it does not know. The
portions this crate owns (vectors/ENGINE_OWNED) are blessed rather than
checked when BLESS is set:
BLESS=1 cargo test --test vectors # rewrite the engine-owned portions from this cratetests/parity.rs plays every sweep script in parity/scripts and compares
each step's dump with its golden file, and, for a script with a file in
parity/records, every request it made and every row it wrote with that
file's, printing each other script's first divergence and a pass count. The
binary does the same, and writes the goldens again once a rule has moved
them:
cargo run --release --bin parity -- --check parity
cargo run --release --bin parity -- --write parity # then review the diffand plays one script for the game repository's runner, which diffs it against a directory of goldens:
ENGINE="target/release/parity --worlds $PWD/parity/worlds" GOLDENS=$PWD/parity/goldens bin/rails engine:parity_diffIn the shared-database mode the game repository's runner prepares the world in a
database file of its own, plays each reseed: step itself, and asks for one
typed step at a time:
ENGINE_STEP=3 target/release/parity --database walk.sqlite3 --player first script.ymlIt prints that step's one dump, with shown null for the runner to fill.
Each player's playthrough is the story's playthrough in the order the
players first appear in the script, started when it has none yet.
MIT OR Apache-2.0, at your option, see LICENSE-MIT and LICENSE-APACHE.