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 | 8 → 8 |
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-Book → by_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.
amd_render_wikilinks(text, display_of=None)
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).
amd_wikilinks(line)
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).