Proving a gig can be finished before any merc jacks in
Open Quest Format keeps its condition language to eleven operators and zero scripts, so that the validator can sniff out unreachable steps, dead ends and gigs with no reachable ending before a single merc jacks in. This post walks through how the scan runs and how many eddies that limit costs, choom.
There is a file in the Open Quest Format repo called unreachable-step.oqf that parses without a squeak and passes as valid OQF, choom, but one of its three steps is a ghost that no run will ever reach, a gig on the board that no merc can ever take. Here it is, stripped down to the cells that matter:
greet start step
forgotten_errand unlock: step.greet and step.greet == "failed"
leave unlock: step.greet outcome: finished
In a condition, a bare step.greet means that greet is done. So the errand only unlocks when greet is done and failed at the same time, which no timeline in Night City is ever going to deliver. The problem with a step like this is that no merc will stumble on it by playing. A playtester never sees the step, so there is nothing to take back to the fixer, and the gig still pays out normally through leave, eddies in hand and nobody the wiser. The errand just sits in the file forever like a flatlined body in a back alley that nobody has called in, dead chrome that nobody knows is dead.
The validator does catch it, though, running the same kind of sweep a netrunner runs on unfamiliar ICE, and it reports both that the errand leads nowhere and that no path ever reaches it:
$ oqf validate unreachable-step.oqf
unreachable-step.oqf: warning dead-end: step "forgotten_errand" has no outcome, no rewards and nothing depends on it (quest unreachable_step, step forgotten_errand)
unreachable-step.oqf: warning unreachable-step: no path from a start step reaches "forgotten_errand" (quest unreachable_step, step forgotten_errand)
0 errors, 2 warnings
It can only pull that off because of a call I made before writing a single line of code. In OQF, conditions are data rather than chrome with a brain of its own, and I deliberately kept the language they are written in very limited so that a program can reason about every condition in a gig. A netrunner can only map ICE that plays by known rules, and I wanted the validator to be that netrunner for every quest file that crosses its deck.
The condition chrome: eleven operators, six namespaces, no backdoors for a netrunner
Under the hood, a condition is stored as a JSON Logic tree, but only a whitelisted slice of JSON Logic makes it past the ICE. The whole whitelist lives in model.ts and has eleven entries: and, or, !, ==, !=, <, <=, >, >=, in and var. If a condition tries to smuggle in anything else, the validator flags it as an error at the door like a bouncer at the Afterlife turning away a gonk with no street cred. That means there are no function calls, no arithmetic, no assignment and no loops, and in can only check whether a value sits in a list literal, so a merc hunting for a backdoor in there walks away with empty pockets. What I wanted was for every condition to be something the validator can evaluate completely on its own rig, and each of those missing features would have been a black box of foreign chrome that made that impossible.
A condition jacks its variables in from six namespaces: flag, count, step, outcome, quest and var. The first five are runtime turf, and no corpo gets a say in them. The runtime keeps the books on every flag, every step state and every step counter like a fixer who knows every deal on the block, so the validator knows in advance what values they can take. var.* is different because it belongs to the game: season, time of day, or whatever else the engine decides to resolve on its own side of the wall. The whole validator runs on that split, since it can see straight through the five runtime namespaces but has to treat anything under var as unknown, a corpo vault sitting behind somebody else’s ICE.
Authors never have to hand-solder the tree like a ripperdoc wiring chrome at 4am. The compact format lets them write a condition as an infix string, like count.catch_rare >= 5 or step.steal == "done" or step.trade == "done", and a hand-written precedence climbing parser turns that string into the tree. The parser has no dependencies, and neither does the evaluator, so there is no third-party chrome bolted into either one. JSON Logic libraries exist in most languages and the tree stays compatible with them, but I still wrote a strict evaluator for the TypeScript side, my own chrome from the ground up. That way @oqf/core owes no corpo package a single import, and == means strict equality instead of the loose street version JSON Logic uses.
The reason for keeping the language this lean is that a validator has no way to read a Lua snippet and tell you whether it will ever return true, any more than a netrunner can promise what a rogue AI will do behind the Blackwall. As soon as a single condition in a gig is a script, the question “can this quest be finished” no longer has an answer any fixer could sell, neither for the validator nor for the merc reading the file at 3am with the synth-coffee going cold. Restricting conditions to comparisons over known variables is what keeps that question answerable, and every other check in this post runs on that deal.
How the reachability run sweeps the gig graph
Reachability is computed in graph.ts as a fixpoint. Start steps, meaning steps flagged s or steps with no unlock at all, are on the crew from the jump, the mercs the fixer already trusts. The validator then works in rounds, and in each round it runs down every other step on the gig and asks whether any assignment of the runtime-owned variables makes that step’s unlock true. If one does, the step gets recruited into the reachable set, which gives the next round more states to work with, and the run stops when a round recruits nobody new, the way a fixer stops dialing once every merc worth calling has picked up.
Trying “any assignment” sounds like it would fry a deck and melt a netrunner’s wetware, but because the language is so limited, the set of values worth trying turns out to be small enough to enumerate. A step.x read for a step that is already known to be reachable can sit in any of its six states: locked, available, active, done, failed or skipped. For a step that is not reachable yet, the state is pinned to locked and its counter zeroed, benched until someone vouches for it, since nothing could have moved it. Flags and outcomes are either true or false, no street-level grey area. Numbers get probed at 0, at every literal that appears in the condition, and at every literal plus one, which plants a boot on both sides of any comparison the language can express, the way a merc covers both doors on a run. Every combination then runs through the real evaluator, the same function the runtime calls during play, so the scan cannot drift away from what the game will actually do, the way two fixers drift apart when they tell different stories about the same job.
In the errand fixture, greet is reachable, so the validator runs all six states for step.greet through the evaluator like a netrunner cycling every key on the ring. None of them is both done and failed, which means no assignment satisfies the unlock, and the validator can call the errand unreachable with full street cred because it has checked every case.
There is a cap on this search. Past 20,000 combinations, the validator stops burning cycles and treats the condition as satisfiable. I pointed it that way on purpose, because I want the validator to call a step unreachable only when it has really checked every case, the way a samurai only draws when the cut is certain. If it raised false alarms, authors would learn to tune the warning out like a street preacher on the corner, and I think that would cost more eddies than occasionally letting a real problem slip past the ICE.
Flags follow the same principle, because the game is allowed to set any flag it likes whenever its own chrome decides to, and no merc can outguess a fixer who writes his own rules, so the validator never treats a step as unreachable because of a flag on its own. If you swap the impossible half of the errand’s unlock for flag.errand_posted, the unreachable-step warning deltas out and the errand is back on the street. The dead-end warning stays, though, because the errand is still a gig with no payout and no crew waiting on it: it has no outcome, no rewards, and nothing depends on it.
The rest of the fixer’s hit list for finishing a gig
The validator carries 25 finding codes in total, and each one is a stable kebab-case string so that any tool on the rig can switch on the code directly, no netrunner needed to decode it. One of them, lore-node-effect, is reserved for dialogue, and the check that emits it is still in the ripperdoc’s chair. For deciding whether a gig can be finished and the merc gets paid, the codes with teeth are cycle-without-repeat, outcome-with-dependents, dead-end, unreachable-step, no-ending-reachable and ending-needs-var. The other codes handle the petty stuff a street cop would write up: duplicate ids, references to steps, quests, rewards or outcomes that don’t exist, and malformed conditions.
Cycles get dug out with Tarjan’s algorithm over the step dependency graph, a netrunner’s trace that follows every loop back to where it started. A loop is legal as long as every step in it is marked repeat, because that is how an author tells the runtime a step is a milk run meant to come around again. The ferry fixture has a loop between two steps running without that paperwork, so the validator writes up both of those mercs:
cycle-without-repeat.oqf: error cycle-without-repeat: step "ferry_out" can reach itself through "ferry_out", "ferry_back" and is not marked repeat (quest cycle_without_repeat, step ferry_out)
cycle-without-repeat.oqf: error cycle-without-repeat: step "ferry_back" can reach itself through "ferry_out", "ferry_back" and is not marked repeat (quest cycle_without_repeat, step ferry_back)
2 errors, 0 warnings
A single mistake often sets off several findings at once, and I find that chain reaction more useful than it first looks, choom. In duplicate-step-id.oqf, the second greet unlocks on step.greet and also carries the ending. That one copy-paste slip from a tired choom produces three errors: the duplicate id, a step that depends on itself, and an ending that something depends on. Each of those errors describes a real problem in the file, so reading them together tells you exactly what the slip broke, the way a good ripperdoc traces three symptoms back to one bad implant.
The broken fixtures also keep a street record of where the tooling does not behave the way their comments expect. There are ten files in packages/examples/quests/broken/, and each one opens with a comment naming the error it should produce, like a toe tag written before the run. Six of them never reach the validator, because the compact parser flatlines them first and the CLI reports a parse-error. For three of those six, the comment names a validator check instead, so the toe tag and the body disagree, and rather than quietly scrubbing those fixtures to match, the CLI test suite logs each mismatch so that it stays out in the open where every netrunner on the project can see it. My favourite is unknown-objective-kind.oqf. Its step says gather fish.rare 5, and since gather is not an objective kind, the parser reads the cell as a condition and reports unknown variable namespace "gather". That error is correct, but it reads a little gonk if you don’t know how the parser reads a cell.
What the validator can’t prove: game variables on the engine’s corpo turf
The guarantee gets thinner as soon as a gig reads var.*, and the validator tells you to your face when that happens instead of letting you find out mid-run. Here is a gig I wrote for this post to show it, choom, a night run for eels. It is not in the repo:
dig_bait start step complete: collect bait 3
wait_dark unlock: step.dig_bait complete: var.time.isNight
land_eel unlock: step.wait_dark complete: collect eel outcome: landed
night-eel.oqf: warning ending-needs-var: every path to an outcome of "night_eel" passes through a var.* read, so completability cannot be proven (quest night_eel)
0 errors, 1 warnings
To land that warning, the validator runs reachability a second time. In this pass it pins every var.* to null, which is what a game that promises nothing would hand over, like a fixer who won’t commit to a date, and it requires each step along the way to be completable as well as unlockable, so every merc on the route has to actually finish the job. If no ending survives that second pass, the gig gets flagged. In the night eel gig the only way to land_eel goes through wait_dark, which completes on var.time.isNight, so the ending hangs entirely on the game’s say-so. If you add a second route, a lantern step that also unlocks from dig_bait, and make land_eel unlock on step.wait_dark or step.lantern, the warning goes quiet and the validator prints 0 errors, 0 warnings. The reference quest in the repo validates clean for the same reason, because its trade route reaches an ending without touching a game variable, a clean exfil with no corpo checkpoint on the way out.
Two checks are in the ripperdoc’s chair getting fresh chrome while I work on this part. Today condition-constant fires only on a literal true or false, and an upgrade that catches any condition that is always true or always false given the owned namespaces is incoming. The second pass is also getting a sharper voice, like a netrunner who finally names the right daemon. As a test, I gave a step complete: count.mend_rod >= 3 and count.mend_rod < 2, which can never hold and does not read any game variable at all. The validator already flags that gig, but it does so as ending-needs-var, which pins the rap sheet on the wrong suspect, so a fix is incoming that points the message at the impossible condition itself.
Logic the condition chrome can’t run, choom
Leaving out arithmetic means you cannot write something like gold >= level * 10. Timers and time-of-day conditions are incoming in v2, but until that chrome lands, conditions have no clock on the wall. There are also no dice rolls, no inventory lookups and no way to say “three eels during a storm since Tuesday”, and plenty of quest logic in real games looks exactly like that, so any honest merc should know this is where the deal pinches and where the eddies get spent.
The way OQF handles this is to leave that logic in the engine, where it already lives, and have the gig count what the engine reports back, with the engine playing fixer and the quest playing the merc who only gets paid on confirmed intel. Objectives are events with some syntax sugar on top: collect eel 3 counts item.collected events for that item, and event <name> [n] counts any event by name. So if the game knows when a storm catch happens, it can emit storm.eel_landed, and the step says event storm.eel_landed 3. For logic that doesn’t fit a counter, there is custom, the fixer’s off-the-books line. The engine-hooks template has a step whose complete cell is custom cozycoast.ritual moon candles. The runtime never closes out a step like that on its own, it waits for the fixer’s word. Instead, the engine calls it in by emitting quest.<questId>.step.<stepId>.custom with { done: true }.
The validator treats an objective as something the street can report, and it does not try to prove that the storm will ever roll in. In practice, the logic you would have written as a script still exists, but it lives in engine code the validator cannot jack into, while the gig graph around it stays checkable. The price tag on that is easy to read: if the game never emits storm.eel_landed, that step waits forever like a merc on a call that never comes, and the validator has nothing to say about it.
Diffing two versions of a gig without a netrunner
Diffing two versions of a quest is on the way, and what 0.1.0 ships today is the groundwork, with the finished rig still on the ripperdoc’s bench. Every condition has one canonical spelling (lowercase keywords, single spaces, and the fewest parentheses that reproduce the tree), and every serializer reproduces the fixture files byte for byte, with no glitch in the round trip. Together, those two properties mean that changing an unlock shows up in git as one changed cell on one line, not a smear of noise a netrunner has to decrypt. On top of that, oqf graph prints the step graph as a mermaid flowchart that you can paste into a pull request, a map of the whole gig any choom can read. A dedicated oqf diff is incoming, and it will join a command list that today is validate, convert, import, graph, schema, extract and inject. Because both versions of a gig are plain data, a diff that tells you “this change made forgotten_errand unreachable” can start by running the validator on each version and comparing the findings, two scans of the same ICE laid side by side.
Incoming, choom. Several pieces of chrome mentioned in this post are on the way: a dedicated
oqf diff, the widercondition-constantcheck and the more preciseending-needs-varmessage. Timers are planned for v2, and Cozy Coast is lined up as the first shipping game built on OQF.
All the broken fixtures are in the repo if you want to jack in, run the validator on them and watch them flatline.