Skip to content

AMD fact-sheet reader

Coerce a friendly Label: value fenced block into a dict — a companion parser for AMD documents.

Overview

AMD documents are #/## headings with --- fences (the same shape quests, clans, and universes are authored in). document_get_amd_file already parses the headings and fences; this module is the companion data_parser that turns a single fence's Label: value lines into a dict, with light value coercion:

Helper Coerces Example
amd_num int → float → str 88
amd_pct percent or number 40%0.4
amd_list comma list a, b["a", "b"]
amd_weighted name N weights by-the-book 40{"by_the_book": 40}
amd_makeup N% name / list / scalar 60% X, 40% Y{"X": 60, "Y": 40}
amd_coords first N ints 6, 4[6, 4]
amd_norm canonicalize a token By-The-Bookby_the_book

A fence that uses YAML flow ({ or [) is parsed as YAML instead, so YAML fences keep working through the same reader.

The domain interpretation — what "Yields" or "Values" means — is the caller's job, supplied via a handler(data, label, value) callback; this module stays content-agnostic (just parsing + coercion). See the Open Universe's universe_amd.py for a worked example.

Quick example

from sbs_utils.procedural.amd import amd_parse_facts, amd_list, amd_num

def handler(data, label, value):
    if label == "yields":
        data["yields"] = amd_list(value)
        return True          # consumed
    return False             # fall through to the default coercion

# fence_text is one `---` block: "Yields: ore 8\nReserve: 4000"
facts = amd_parse_facts(fence_text, handler=handler, default=amd_num)
# -> {"yields": ["ore 8"], "reserve": 4000}

handler receives the mutable data dict, so it can setdefault / nest / append freely; return truthy to consume a label, falsy to let default(value) handle it under amd_norm(label).

API

Friendly AMD fact-sheet fence reader.

A companion data_parser for procedural.quest.document_get_amd_file (which already parses the #/## headings and --- fences): this turns a single Label: value fenced block into a dict, with light value coercion - comma lists, "name N" weights, "name N%" makeup, coord pairs, percentages. A block that uses YAML flow ({ or [) is parsed as YAML instead, so YAML fences keep working through the same reader.

Domain label->key interpretation (what "flies" or "yields" means) is the CALLER's job, supplied via a handler callback - see the Open Universe's universe_amd.py for a worked example. This module stays content-agnostic: just parsing + coercion.

Dependency-light (only load_yaml_string) so it imports cleanly and is unit-testable outside the engine.

AmdErrors

Bases: list

Collected parse problems that remember WHERE they were.

Behaves as a list of "line N: message" strings (so existing callers are unaffected), while .items keeps (line, message) pairs. line_offset maps the fence-relative line the parser sees onto the real file line - without it a diagnostic would point at line 3 of the block instead of line 147 of the file.

BoneyardScanner

Tracks /* ... */ cut text - Fountain's boneyard.

A writer cuts a scene far more often than a line, and wants it back next week. // handles a line; this handles a block, and it works INSIDE a fence as well as in a body because a cut scene usually takes its data with it - so it runs as a pre-pass, before the fence scanner sees anything.

The opener must start a line (indent allowed) so a /* inside a sentence is still a sentence. Nothing is silently eaten: text after the closing */ on the same line is handed back as the surviving line, and an unclosed block at EOF is reported rather than swallowing the rest of the file.

feed(line, lineno=0)

Classify one raw line -> (dropped, surviving_text).

dropped True means the line is entirely commented out. Otherwise surviving_text is the line to go on processing - normally the line itself, or its tail when a */ ended a block partway through.

finish()

Call at EOF. True when a block was left open.

FenceScanner

Tracks whether we are inside a --- data block, WITHOUT toggling.

A --- used to flip a boolean, so one stray rule inverted data-and-body for the rest of the file. The rules now:

  • --- OPENS only immediately after a heading (or at the very top of the file, which is the document-level fence)
  • --- CLOSES only while a block is open
  • a heading always closes an open block
  • anywhere else --- is just prose

unterminated reports a block still open at EOF, so the caller can say so.

feed(line, lineno=0)

Classify one line: 'open' | 'close' | 'data' | 'heading' | 'body'.

finish()

Call at EOF. True when a block was left open.

amd_body_synopsis(line)

The text of a = synopsis line, or None when this is not one.

amd_body_transclude(line)

The record a ![[key]] line pulls in, or None.

amd_body_transition(line)

The transition a body line names (CUT TO:), or None.

Either Fountain's forced form (> CUT TO:) or one of the bare spellings every screenwriter already types. Returned uppercase so a renderer never has to care which was written.

amd_body_variant(line)

A % speech-variant line -> (text, gate), or None when it is not one.

gate is the condition in %{...} / {...}, or None. The leading % is optional in a dialogue body, so this returns a pair for ANY line once the caller has decided it is in speech position - it is the shared stripping and gate rule, not the decision that a line is speech. Callers that require the sigil test line.startswith("%") themselves.

amd_chain(*handlers)

Compose several amd_parse_facts handlers into one. Each label is offered to the handlers in order; the first that consumes it (returns truthy) wins, otherwise it falls through to the default coercion. Lets a single parser understand SEVERAL vocabularies at once - e.g. quests + science scans + landmarks - so a mission can author all its content sections in ONE .amd file (parsed by document_get_amd_file with the chained parser) and hand each section to its own loader. Ordering matters only where two handlers claim the same label; keep the most specific first.

amd_choice(line)

A - [label](target) if guard ; outcomes line -> dict, or None.

Returns {"label", "target", "guard", "outcomes"}. guard is None when the choice is unconditional; outcomes is amd_outcomes' list of tuples.

The ; outcomes tail splits FIRST, before the if guard is read, because a guard is free text and would otherwise swallow the whole tail - if standing > 10 ; earns kind 5 would become the guard standing > 10 ; earns kind 5, which no evaluator can answer and which loses the outcome without a word.

amd_coords(s, n=2)

'6, 4' -> [6, 4] (the first n signed-integer tokens).

amd_counted(s)

'bio_sample x1, salvage x5' -> {'bio_sample': 1, 'salvage': 5}; a bare key -> 1.

The shopping-list shape an author writes for costs and contents. Promoted here from LegendaryMissions' recipes.py:_parse_inputs so the fabrication recipe fence reads through the SAME declared type as everything else, instead of a private loader.

amd_drop_keys(s)

Just the item keys a drop table names, in written order - what a reference extractor and a linter need, without the counts.

amd_drop_table(s)

'salvage x2-4, contraband 20%' -> [{key, low, high, chance}, ...].

What a kill leaves behind. A richer shopping list than amd_counted, because loot has a RANGE and a CHANCE as well as a name:

key            one, always
key xN         N, always
key xN-M       between N and M
key P%         one, P of the time
key xN-M P%    both
none           nothing at all - an EMPTY table, which is NOT the same as having
               no table (see `amd_drops.drops_table_for`)

Lives here rather than in amd_drops so the stdlib-only half of the toolchain can read it too: the parser turns these keys into references and the linter checks them, and neither may import the runtime module. An already-parsed list passes through, so parsing twice is harmless.

amd_duration_parts(value)

6 minutes -> (6, "minutes"), 90 seconds -> (90, "seconds"), 2 -> (2, "minutes"). (None, unit) when there's no number.

The unit is MINUTES unless the text says "second" - the rule Fail after: and Complete after: have always used. Shared so a view can't disagree with the clock the engine actually runs. Returns the AUTHORED unit (not just seconds) because the quest data keeps what was written.

The COMPACT form parses too - 20m, 30s, 2h. It reads naturally and everyone writes it, but the digit-token scan never saw it: 20m is not isdigit(), so Fails when: after 20m came back (None, "minutes") -> {minutes: 0} -> secs <= 0 -> the watcher skipped the quest and the deadline silently never fired. An unrecognized suffix still falls through to minutes, as before.

amd_duration_seconds(value)

amd_duration_parts collapsed to seconds, or None if there's no number.

amd_fact_lines(text)

Yield (label, value) per Label: value line - label lowercased, both stripped. Skips blanks, // comments, and lines without a colon.

Kept for callers that want the flat view; amd_parse_facts no longer uses it.

amd_is_yaml_flow(text)

True when a VALUE should be parsed as YAML flow - it starts with { or [.

This used to scan the whole fence, so one prose value carrying a brace (Intel: Captain {name}) silently reparsed every other line under YAML rules, where Color: #07F becomes None and Reveals: Survey logged: 3 raises. The flip is now per-value, which is strictly more permissive: nothing that parsed before stops parsing, and # colours survive in the same fence as a flow value.

amd_kind_line(text)

The fence's bare-noun kind line (Characters) if it has one, else None.

Must be the FIRST meaningful line - blanks and // comments may precede it, so a section can be commented without breaking. Singular or plural both work; the caller resolves the noun against the section-name table.

amd_kv(s)

'kind=bio, range=medium' -> {'kind': 'bio', 'range': 'medium'}.

Promoted from recipes.py:_parse_program. Parts without an = are skipped.

amd_list(s)

Comma-split, trimmed, empties dropped.

amd_makeup(s)

'60% X, 40% Y' -> {X:60, Y:40}; 'X, Y' -> list; 'X' -> str. (Three shapes; the percent form keeps the original display casing of the key.)

amd_norm(name)

Canonicalize a token: lowercase, hyphens/spaces -> underscores.

amd_num(s)

int -> float -> the trimmed string, whichever parses first.

amd_outcomes(s)

'costs 200 credits, earns vex kind 5, signal paid' -> [(verb, *tokens), ...].

Tokens are interpreted by the mission's registered outcome handler (only signal is built in), so the grammar of costs/earns/etc. lives with the mission rather than here.

amd_parse_facts(text, handler=None, default=amd_num, archetype=None, errors=None)

Parse one fact-sheet fence into a dict.

Per label, in order: the caller's handler gets first refusal (returns truthy to consume it); then the FIELD REGISTRY, when the field is declared for archetype - which resolves the alias, coerces by the declared type and stores under the runtime key; then default (historically amd_num) for anything undeclared, so an unknown field behaves exactly as it does today.

errors may be a list - parse problems are appended to it in a writer's terms rather than raised, so a typo never takes a mission down; the linter is what makes them loud. Returns data, carrying the kind line (when present) under KIND_KEY.

amd_parse_url(text)

key?scale=0.5&align=center -> {"url": "key", "scale": "0.5", ...}.

Values stay STRINGS; every caller coerces to what it needs. Malformed pairs are skipped rather than raising - a mistyped option should cost the option, not the image.

amd_pct(s)

'40%' -> 0.4; '0.4' -> 0.4; a bare number -> float; else the string.

amd_read_text(path)

The text of one .amd (or any AMD-adjacent source), decoded the same way a mastlib read decodes it.

UTF-8 first (with a BOM tolerated, since editors add one), falling back to cp1252 for a legacy file that predates that convention, and finally to a replacing UTF-8 decode - because a file that cannot be decoded should still parse into something an author can look at and fix, not vanish.

Replace every [[key]] / [[key|words]] with what a PLAYER should read.

[[key|words]] renders words. A bare [[key]] renders the target record's display text when display_of(key) finds one, and otherwise the key itself - so a draft that links ahead to a scene nobody has written yet still reads as a sentence instead of showing brackets. The linter reports the same unresolved target as dangling-link; rendering never fails on it.

amd_signal_name(value)

A signal name, lowercased with spaces -> underscores (matched exactly).

Lives here, not in a caller, because it IS the matching contract: the quest driver matches on it at runtime and the editor's signal join matches on it statically. Two copies held in agreement by a comment would silently stop agreeing the first time the rule widened.

amd_table_rows(raw_rows)

Raw |a|b| lines -> (rows, aligns).

aligns is one of l/c/r per column, taken from the |:--|--:| separator row, which is dropped from the data. A table with no separator renders all-left with row 0 as the header, so the separator is optional.

amd_table_scan(lines, i)

A GFM pipe table starting at lines[i] -> (rows, next_index), else None.

A table is 2 or more consecutive lines starting with |. The pair is required deliberately: a lone | line is prose (a table drawn in words, an ASCII diagram, a sentence about a pipe) and must stay prose.

amd_variant_pool(text)

A record body -> its list of ungated variants, one per line.

Each non-empty, non-comment line is one variant with a leading % stripped: one line is a fixed string, several are pick-one-at-random at use time. This is the pool a scan tab, a chatter bark or any other "say one of these" field reads, and it is deliberately NOT amd_body_variant: there are two variant rules in AMD and conflating them would be a silent behavior change.

  • this one - strip the sigil. A {...} prefix is ordinary text.
  • amd_body_variant - strip the sigil AND read a %{gate} condition.

Only DIALOGUE evaluates gates, because only dialogue has a speaker whose standing can be tested. A scan line reading {lifesigns} faint is a sentence about lifesigns, and must stay one.

(amd_urge reads a third rule off the same sigil - it COUNTS % to number a stage, so %% is stage 2 - and cannot share this one.)

amd_weighted(s)

'by-the-book 40, fearsome 30' -> {by_the_book: 40, fearsome: 30} (trailing integer is the weight; a bare name gets weight 0).

Every [[target]] / [[target|words]] in line as (target, alias, start, end).

Columns are 0-based into line and cover the whole [[...]] token, so a caller can both point at it (spans, diagnostics) and replace it (rendering).