4.4 KiB
Portrait Metadata Contract
Objective
Define the canonical behavior for portrait metadata embedded in topdata rows, enabling explicit opt-in portrait injection into the portraits.2da table.
Portrait Metadata Shape
Portrait metadata is embedded in row meta.portrait:
{
"meta": {
"portrait": {
"resref": "nw2book1_",
"sex": 4,
"race": 10,
"inanimate_type": 4,
"plot": 0,
"low_gore": "****"
}
}
}
Required Fields
resref— non-empty string identifying the portrait base resource reference
Optional Fields
sex— numeric or string value mapped to portraits.2daSexcolumnrace— numeric or string value mapped to portraits.2daRacecolumninanimate_type— numeric or string value mapped to portraits.2daInanimateTypecolumnplot— numeric or string value mapped to portraits.2daPlotcolumnlow_gore— numeric or string value mapped to portraits.2daLowGorecolumn
Field names are case-insensitive with hyphen/underscore normalization:
inanimate_type=inanimate-type=InanimateType
Merge Behavior
Identity Matching
Portrait entries are matched by resref. Injection is only enabled when all of the following are true:
- The row carries
meta.portrait.resref - The row comes from a JSON module under
topdata/data/<dataset>/modules - The dataset is one of
appearance,genericdoors, orplaceables
When those conditions are met:
- If a portraits.2da row with matching
BaseResRefalready exists → merge into that row - If no matching row exists → create a new row with auto-generated identity
portraits:auto:<resref>
Field Mapping
Portrait metadata fields map to portraits.2da columns:
| Metadata Key | 2DA Column |
|---|---|
resref |
BaseResRef |
sex |
Sex |
race |
Race |
inanimate_type |
InanimateType |
plot |
Plot |
low_gore |
LowGore |
Override Precedence
- First contributor wins for each column
- If a column already has a non-null value, subsequent contributions are rejected if they differ
- Identical values (after normalization) are silently accepted
- Conflicting values produce a build error
Null-Like Values
Missing fields and nullValue ("****") are treated as unset. They do not override existing values and are not considered conflicts.
Lock Data
Auto-generated portrait rows are tracked in lock data with key pattern:
portraits:auto:<resref>
For example: portraits:auto:nw2book1_
Cross-Dataset Injection
Only module-authored rows from appearance, genericdoors, and placeables may contribute portrait metadata. The build pipeline:
- Collects all datasets
- Runs
mergePortraitMetadata()after dataset collection - Injects portrait rows into the portraits.2da dataset
- Updates lock data if new auto-generated rows were created
- Prunes stale
portraits:auto:*lock entries when no active auto-injected portrait row remains
This enables supported non-portrait datasets to define portrait associations without modifying the portraits dataset directly, while making the behavior explicit and removable with the originating module.
Validation Rules
resrefis required — missing resref produces a parse error- Unknown metadata fields in
meta.portraitproduce a parse error - Unknown top-level keys in
metaproduce a parse error (allowed keys:family,wiki,portrait) - Base rows do not inject portraits, even if they carry
meta.portrait - Conflicting portrait field values from different contributors produce a build error
- Portrait metadata is excluded from emitted 2DA output — it exists only at authoring/build time
Acceptance Criteria
Implementation is correct only if all of the following are true:
- Portrait metadata is parsed from
meta.portraitwith case-insensitive field names - Only module-authored rows from
appearance,genericdoors, andplaceablesinject into portraits.2da byresrefmatching - Base rows do not trigger portrait injection
- Auto-generated rows receive
portraits:auto:<resref>lock keys - First-contributor-wins precedence is enforced per column
- Conflicting values produce build errors with clear diagnostic messages
- Portrait metadata does not leak into emitted 2DA column output
- Lock data is persisted when new auto-generated rows are created and stale auto-generated keys are pruned