Skip to content
renderedstepPublic

About

Rust engine for the text-adventure game

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

renderedstep-engine

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

Playing a turn

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::open opens the database file on a new SQLite connection and checks its schema. A database whose newest migration is older than store::SCHEMA_VERSION is refused with Error::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 triggers store::SHAPE records, and nothing new points into one of them; otherwise it is refused with Error::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. play takes 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 Outcome is 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_chunk receives 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 as Error::Panicked. A line that reaches a rule this engine does not play yet comes back as Error::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::act lets the game act: a submission whose line is turn::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 its chosen step before it reads it, so a resumed turn plays the same pick. The scene says resolved_by = "engine". A game told the other way is refused with Error::WrongMode, having written nothing.
  • No narrator is asked: every scene keeps the engine's own words, which are not a fallback (engine_fallback stays 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_card is what the player writes it from, in the engine's player-facing words, and Engine::write_paragraph keeps it in playthrough_paragraphs with 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's Playthrough::PromptVersion takes it (prompt_digest, prompt_version::digest). Engine::would_ask reads them for a scene. The row also says what the player let the paragraph be used for (consent, narrates::Consent): none unless the caller says otherwise with Engine::write_paragraph_with_consent, and a rewrite keeps it.
  • Engine::candidates lists 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 stamping noticed_at on 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 (examine with no target, offered as the look target of examine while one would show something), the rest in plain sight, up to noticed::MAX_VISIBLE_PER_ROOM; and on each turn spent in the room, one more (Roll::NOTICE). What was noticed stays noticed. turn::room_of holds 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 (search and open, 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_findings is 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), or revisit::UNCHANGED where 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_visit reads 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.

Model calls

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 to https://openrouter.ai and 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.

Measuring fights

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 812
one-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 SEED prints that fight line by line.
  • Drivers. told (the default) plays each line with Engine::submit in a game the player narrates, slashed, with a model::Replay that holds no replies: the loop the browser plays, which moves the story's clock five minutes a line. mechanics plays Engine::play, a little faster, and its clock does not move between fights. Anything that hangs on time is measured on told. 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): attack the 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%; and careful, 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. --json prints the same as JSON. --seeds A..B, --driver and --threads override the file's values.
  • Sweeps (sim::sweep): a sweep: 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.yml is every player level 3 to 6 against a d10 foe of level 1 to 3.
  • A real world (sim::world): --database FILE --story TITLE fights 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): --csv prints 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 with p_died sums 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.json

tests/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.

Rules for changing it

  • 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 Hash are 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 of sim/goldens/*.txt. CI runs fight --check sim, which fails on any table that moved.

Testing

cargo test -- --nocapture

tests/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 crate

tests/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 diff

and 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_diff

In 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.yml

It 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.

Licence

MIT OR Apache-2.0, at your option, see LICENSE-MIT and LICENSE-APACHE.

About

Rust engine for the text-adventure game

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages