Fake Relics Obey Real Geology
Materials are assigned per component, and assignment is the first stage that consults the world beyond the object: what the region's geology offers, what the culture prefers, what its technology can work at this point in its history.
The catalogue
Sixteen materials, each a definition with tags, availability data and physical properties: bronze, iron, gold, silver, obsidian, flint, granite, jade, oak, ash, bone, antler, fired clay, glass, linen, leather. Tags group them (metal, stone, wood and so on) so that component compatibility can be expressed against kinds while selection lands on specifics.
Selection: two filters, a ladder, one product
- Compatibility filter. Keep materials whose tags intersect the component's
allowedMaterialTags; an empty constraint list admits everything. - Availability filter. Keep materials obtainable in this geology, locally or via a trade flow.
- Fallback ladder. If availability empties the pool, fall back to the compatible set; if that's empty, the full catalogue. The function never hands
weightedSelectan empty list, so it never throws on a populated catalogue. - Weighted draw. Survivors are weighted by the product
culturalAffinity × phaseTechnology × scarcity.
Availability is a five-rung ladder per material per region: abundant, available, scarce, trade-only, absent. A trade-only material is rescued by a matching MaterialFlow in an active relationship window; an absent one is excluded outright. Scarcity then weights the survivors: 1.0, 0.6, 0.25, 0.15 down the rungs. A scarce material still appears, just rarely, and a trade material arrives at the doc's "present but uncommon" level.
// materials.ts: pinned by the calibration suite as ratios, not absolutes
const SCARCITY_WEIGHT: Record<string, number> = {
'abundant': 1.0,
'available': 0.6,
'scarce': 0.25,
'trade-only': 0.15, // "present but uncommon" (doc 05 §7)
'absent': 0, // never reached: isAvailable excludes it first
};
// per surviving material:
// weight = culturalAffinity × phaseTechnology × SCARCITY_WEIGHT[level]
Provenance: the engine remembers, the player infers
Provenance answers a different question from findspot. Where an artefact was dug up belongs to stage 2 (the site, the layer, the deposition); where its raw material came from is decided here, at assignment time, because the answer depends on world state no later stage can reconstruct.
Trade windows close; a query run centuries later, against the world as it ended up, would get a different answer from the one that was true when the object was made. So the engine records the answer at the moment it's determined:
- Source: local, regional, trade or unknown. A mechanism, never an intent.
- Likely origin region: where the raw material plausibly came out of the ground.
- Trade-path reference: which relationship window carried it, when trade did.
All of it is hidden, and the hiding is the game design. A traded ingot, a looted trophy and a diplomatic gift can leave identical material signatures, so the player who sees a copper object from a culture with no copper deposits has to construct the explanation themselves; trade, migration, conquest, a stolen trophy and a misattributed culture are all live hypotheses, and the relationship data upstream genuinely supports several of them.
The engine's record exists so that a player's provenance claim can eventually be marked right or wrong against something.
Physical properties feed the next stage
Each material definition carries seven authored physical axes plus a keyed reactivity object, and each axis earns its slot by splitting a pair of materials that some downstream check must treat differently. An earlier model with a vague workable axis was retired for conflating brittleness, pliability and grain coarseness; these are the axes that replaced it, with the catalogue's own values.
| Axis | What it measures | The values that justify it |
|---|---|---|
hardness | Scratch and dent resistance, on the real Mohs scale | Flint 7, granite 6, bronze 4, gold 3, leather 1. The real scale keeps authored data auditable: granite below gold would be catchable with a geology book. |
fragility | How it fails: shatter against deform | Glass 7 (the value the axis was introduced to capture), obsidian 6, jade 2, gold 1. Jade is harder than obsidian and far tougher; hardness can't predict shattering, so it can't stand in for this. |
rigidity | Holds its shape under force | Granite and fired clay 7, oak 5, leather 2, linen 1. Gilding gates here: leaf needs a surface that stays put. |
grainFineness | The precision cap on worked detail | Gold 7 (why chasing reaches such fine detail), obsidian 7 (a true glass), granite 3: coarse visible crystals cap fineness however carefully the mason works. The granite case is the one that motivated the axis. |
porosity | Absorbency | Fired clay 6, the physical reason glazing exists as a technique; oak 4 takes stain and paint readily; vitrified glass 1. |
combustibility | Ignition behaviour | Linen 7 (ignites near 210 °C), oak 5 (around 300 °C), fired clay 1: already fired, inert in this range. |
formability | Whether it can be given a raised form, read at the working state | Fired clay 6 (read wet, a true plastic state), the metals 5 (cast or forged), granite 4 (pecked in controlled increments), leather 3 (wet-moulded or tooled), obsidian and flint 1: knapping is controlled fracture, unsteerable into relief. Splitting granite from obsidian is the job the axis exists to do. |
reactivity | Chemistry, one value per key; oxidisation so far | Iron 7 (rust), bronze 6 (verdigris), silver 3 (tarnish), gold 0, stone and glass −1. Gold's 0 is deliberate against the minerals' −1: gold has oxidation chemistry and famously resists it; a mineral has none to speak of. |
One real entry, so the shape is concrete:
// data/materials.ts (comments abridged)
{
id: 'obsidian', tags: ['stone'], craftDomain: 'stoneWorking',
physicalProperties: {
hardness: 5, // Mohs 5–5.5: genuinely softer than flint, despite feeling sharper
fragility: 6, // textbook conchoidal fracture; flakes under a mis-strike
rigidity: 7,
grainFineness: 7, // a true volcanic glass: knapped edges sharper than steel
porosity: 1,
combustibility: 1,
formability: 1, // removal can't be steered into a raised form
},
reactivity: { oxidisation: -1 },
decorability: { engravable: true, paintable: false, glazeable: false },
}
Obsidian's row is the axes doing their job in one material: its edge comes from fracture behaviour and grain, its hardness is unremarkable, and no single "workable" number could carry those three facts at once. Formability is the one axis read at the working state where the other six describe the finished object; fired clay is modelled wet there (6) and shatter-prone everywhere else (fragility 6), and the docs record that single-state inconsistency with the task id that owns resolving it.
Scaffolding, labelled
Milestone 2 runs this stage against mock world fixtures, and the module JSDoc records the consequences: there's no culture-to-region mapping yet, so availability answers "obtainable anywhere in this geology" rather than gating on the artefact's eventual findspot region, and the trade-path id is a synthesised string because relationships don't carry stable ids yet. Each shortcut names the roadmap task that retires it.
src/lib/engine/generation/materials.ts (assignMaterial, isAvailable, computeMaterialWeight) · catalogue: src/lib/data/materials.ts · distribution pins: src/lib/data/materials.calibration.test.ts · Spec: doc 05 §7