Stage 6 · Material assignment

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

  1. Compatibility filter. Keep materials whose tags intersect the component's allowedMaterialTags; an empty constraint list admits everything.
  2. Availability filter. Keep materials obtainable in this geology, locally or via a trade flow.
  3. Fallback ladder. If availability empties the pool, fall back to the compatible set; if that's empty, the full catalogue. The function never hands weightedSelect an empty list, so it never throws on a populated catalogue.
  4. 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]
catalogue: 16 materials tag-compatible available here weighted draw empty pool? fall back a level; the draw never starves scarcity weights: abundant 1.0 available 0.6 scarce 0.25 trade-only 0.15
Two filters narrow the pool, a fallback ladder stops it emptying, and scarcity shapes the final draw.
Weights are ratios, and the tests know it No document anywhere specifies "12% bronze". The spec states priorities directionally and explicitly disclaims quota readings; the numbers above are authored, marked MVP-provisional. What stops a typo silently reshaping the world is a calibration test suite that pins the distribution: per-region tag shares, splits within a tag (how metal divides into bronze, iron, gold, silver) and a provenance mix sensitive specifically to the trade-only rung. Before that suite existed, the distribution tests were bare directional inequalities that would pass for any descending set of four numbers; nothing could tell a deliberate recalibration from a slip. That gap, and the fix, are recorded in the doc itself.

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:

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.

AxisWhat it measuresThe values that justify it
hardnessScratch and dent resistance, on the real Mohs scaleFlint 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.
fragilityHow it fails: shatter against deformGlass 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.
rigidityHolds its shape under forceGranite and fired clay 7, oak 5, leather 2, linen 1. Gilding gates here: leaf needs a surface that stays put.
grainFinenessThe precision cap on worked detailGold 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.
porosityAbsorbencyFired clay 6, the physical reason glazing exists as a technique; oak 4 takes stain and paint readily; vitrified glass 1.
combustibilityIgnition behaviourLinen 7 (ignites near 210 °C), oak 5 (around 300 °C), fired clay 1: already fired, inert in this range.
formabilityWhether it can be given a raised form, read at the working stateFired 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.
reactivityChemistry, one value per key; oxidisation so farIron 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.

Where it lives 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