feat(audio): add procedural audio preview synth

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-27 01:36:01 -07:00
parent b54f320703
commit b1febc5884
5 changed files with 357 additions and 13 deletions

View file

@ -0,0 +1,280 @@
// Procedural-preview synthesiser for the audio design page.
//
// This is a design-app feature, NOT a substitute for the production audio
// path. The in-game `AudioManager` autoload loads real .ogg files via
// `AudioStreamPlayer` — that pipeline is owned by `audio_manager.gd` and
// is unchanged by anything here.
//
// What this module does: render category-appropriate Web Audio API tones
// in the browser so a designer reading the audio design page can hear
// what each manifest key will *sound like* before the launch pack's .ogg
// files are sourced (objective p2-16). Recipes hint at the dwarven /
// forge / mountain aesthetic — low brass for war horns, anvil pings for
// forge events, deep cavern rumbles for apex roars.
//
// Each manifest key maps to a "recipe": which oscillator(s), which
// envelope, optional noise burst, optional pitch sweep. When a key has
// no explicit recipe, the synth picks one by the key's prefix
// (unit / building / fauna / weather / ui), so newly-added manifest
// keys still produce an audible preview without a code change.
let cachedCtx: AudioContext | null = null;
function getContext(): AudioContext {
if (cachedCtx == null) {
type WindowWithWebkit = Window & { webkitAudioContext?: typeof AudioContext };
const w = window as WindowWithWebkit;
const Ctor: typeof AudioContext = window.AudioContext ?? w.webkitAudioContext!;
cachedCtx = new Ctor();
}
// Most browsers suspend the context until a user gesture. Resume on
// every play call — cheap, idempotent, and handles the first click.
if (cachedCtx.state === "suspended") {
cachedCtx.resume();
}
return cachedCtx;
}
// ─── Oscillator + noise builders ─────────────────────────────────────────
interface Tone {
type: OscillatorType;
freq: number;
/** Optional sweep target frequency reached over `dur`. */
freqEnd?: number;
/** Detune in cents. */
detune?: number;
}
interface Recipe {
/** Tones layered together. */
tones?: Tone[];
/** Pink/white-noise burst layered with the tones. */
noise?: { kind: "white" | "pink"; cutoff?: number; resonance?: number };
/** Total length in seconds. */
dur: number;
/** Peak gain (0..1). */
gain: number;
/** Attack in seconds (default 0.005). */
attack?: number;
/** Decay shape: 'exp' (default) or 'linear'. */
decay?: "exp" | "linear";
/** Optional tremolo LFO depth (0..1). */
tremolo?: { rate: number; depth: number };
/** Lowpass cutoff applied to the whole recipe. */
lowpass?: number;
/** Highpass cutoff applied to the whole recipe. */
highpass?: number;
}
function makeNoiseBuffer(c: AudioContext, kind: "white" | "pink", dur: number): AudioBuffer {
const len = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, len, c.sampleRate);
const data = buf.getChannelData(0);
if (kind === "white") {
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
} else {
// Voss-McCartney pink-noise approximation.
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
b0 = 0.99886 * b0 + w * 0.0555179;
b1 = 0.99332 * b1 + w * 0.0750759;
b2 = 0.96900 * b2 + w * 0.1538520;
b3 = 0.86650 * b3 + w * 0.3104856;
b4 = 0.55000 * b4 + w * 0.5329522;
b5 = -0.7616 * b5 - w * 0.0168980;
data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.115;
b6 = w * 0.115;
}
}
return buf;
}
export function play(key: string): void {
const recipe = recipeFor(key);
const c = getContext();
const t0 = c.currentTime;
const dur = recipe.dur;
const attack = recipe.attack ?? 0.005;
const masterGain = c.createGain();
masterGain.gain.setValueAtTime(0, t0);
masterGain.gain.linearRampToValueAtTime(recipe.gain, t0 + attack);
if ((recipe.decay ?? "exp") === "exp") {
masterGain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
} else {
masterGain.gain.linearRampToValueAtTime(0, t0 + dur);
}
// Optional tone-shaping filters between the layered sources and master.
let head: AudioNode = masterGain;
if (recipe.lowpass != null) {
const lp = c.createBiquadFilter();
lp.type = "lowpass";
lp.frequency.setValueAtTime(recipe.lowpass, t0);
lp.connect(masterGain);
head = lp;
}
if (recipe.highpass != null) {
const hp = c.createBiquadFilter();
hp.type = "highpass";
hp.frequency.setValueAtTime(recipe.highpass, t0);
hp.connect(head);
head = hp;
}
// Tones
for (const tone of recipe.tones ?? []) {
const osc = c.createOscillator();
osc.type = tone.type;
osc.frequency.setValueAtTime(tone.freq, t0);
if (tone.freqEnd != null) {
osc.frequency.exponentialRampToValueAtTime(
Math.max(20, tone.freqEnd),
t0 + dur,
);
}
if (tone.detune != null) osc.detune.setValueAtTime(tone.detune, t0);
const mix = c.createGain();
mix.gain.value = 1 / Math.max(1, recipe.tones?.length ?? 1);
if (recipe.tremolo) {
const lfo = c.createOscillator();
lfo.frequency.value = recipe.tremolo.rate;
const lfoGain = c.createGain();
lfoGain.gain.value = recipe.tremolo.depth;
lfo.connect(lfoGain).connect(mix.gain);
lfo.start(t0);
lfo.stop(t0 + dur);
}
osc.connect(mix).connect(head);
osc.start(t0);
osc.stop(t0 + dur);
}
// Noise burst
if (recipe.noise) {
const buf = makeNoiseBuffer(c, recipe.noise.kind, dur);
const src = c.createBufferSource();
src.buffer = buf;
let chain: AudioNode = src;
if (recipe.noise.cutoff != null) {
const f = c.createBiquadFilter();
f.type = "lowpass";
f.frequency.setValueAtTime(recipe.noise.cutoff, t0);
if (recipe.noise.resonance != null) f.Q.setValueAtTime(recipe.noise.resonance, t0);
chain.connect(f);
chain = f;
}
const noiseGain = c.createGain();
noiseGain.gain.value = 0.6;
chain.connect(noiseGain).connect(head);
src.start(t0);
src.stop(t0 + dur);
}
masterGain.connect(c.destination);
}
// ─── Recipe library ──────────────────────────────────────────────────────
const RECIPES: Record<string, Recipe> = {
// ── UI ────────────────────────────────────────────────────────────────
"ui_click": { tones: [{ type: "square", freq: 1200, freqEnd: 800 }], dur: 0.06, gain: 0.18 },
"ui_hover": { tones: [{ type: "sine", freq: 1400 }], dur: 0.05, gain: 0.10 },
"menu_open": { tones: [{ type: "sawtooth", freq: 200, freqEnd: 120 }], dur: 0.40, gain: 0.18, lowpass: 1200 },
"notification": { tones: [{ type: "sine", freq: 660 }, { type: "sine", freq: 880 }], dur: 0.40, gain: 0.18, attack: 0.02 },
"error": { tones: [{ type: "square", freq: 200, freqEnd: 120 }], dur: 0.18, gain: 0.20 },
// ── Turn cycle ────────────────────────────────────────────────────────
"turn_started": { tones: [{ type: "sawtooth", freq: 220, freqEnd: 330 }, { type: "sawtooth", freq: 110 }], dur: 0.50, gain: 0.22, attack: 0.02, lowpass: 2000 },
"turn_ended": { noise: { kind: "pink", cutoff: 400 }, dur: 0.20, gain: 0.30 },
// ── City ──────────────────────────────────────────────────────────────
"city_founded": { tones: [{ type: "sine", freq: 660 }, { type: "sine", freq: 880 }, { type: "sine", freq: 1320 }], dur: 1.40, gain: 0.20, attack: 0.01 },
"city_grew": { tones: [{ type: "sine", freq: 800 }, { type: "sine", freq: 1200 }], dur: 0.70, gain: 0.18, attack: 0.02 },
"city_starved": { noise: { kind: "pink", cutoff: 600 }, dur: 0.90, gain: 0.18, attack: 0.05 },
"border_expanded":{ tones: [{ type: "sawtooth", freq: 220, freqEnd: 330 }], dur: 0.45, gain: 0.16, attack: 0.04, lowpass: 1500 },
// ── Combat ────────────────────────────────────────────────────────────
"combat_started":{ tones: [{ type: "sawtooth", freq: 110, freqEnd: 165 }, { type: "sawtooth", freq: 220 }], dur: 0.80, gain: 0.25, attack: 0.05, lowpass: 1500 },
"combat_hit": { tones: [{ type: "triangle", freq: 220 }], noise: { kind: "white", cutoff: 600 }, dur: 0.08, gain: 0.30 },
"unit_killed": { tones: [{ type: "sine", freq: 60 }], noise: { kind: "white", cutoff: 1200 }, dur: 0.18, gain: 0.30 },
"unit_promoted": { tones: [{ type: "sawtooth", freq: 330, freqEnd: 440 }, { type: "sawtooth", freq: 440 }], dur: 0.30, gain: 0.20, lowpass: 2500 },
// ── Units (categorical) ───────────────────────────────────────────────
"unit.melee.attack": { noise: { kind: "white", cutoff: 1500 }, tones: [{ type: "triangle", freq: 200, freqEnd: 80 }], dur: 0.16, gain: 0.30 },
"unit.melee.hit": { noise: { kind: "white", cutoff: 800 }, tones: [{ type: "triangle", freq: 1200, freqEnd: 600 }], dur: 0.10, gain: 0.30 },
"unit.melee.death": { tones: [{ type: "sine", freq: 80 }], noise: { kind: "white", cutoff: 800 }, dur: 0.22, gain: 0.32 },
"unit.ranged.attack": { noise: { kind: "white", cutoff: 2500 }, dur: 0.10, gain: 0.30 },
"unit.ranged.hit": { tones: [{ type: "triangle", freq: 200, freqEnd: 100 }], dur: 0.07, gain: 0.30 },
"unit.ranged.death": { tones: [{ type: "sine", freq: 100 }], noise: { kind: "white", cutoff: 800 }, dur: 0.18, gain: 0.30 },
"unit.siege.attack": { tones: [{ type: "sine", freq: 50 }], noise: { kind: "pink", cutoff: 400 }, dur: 0.45, gain: 0.40, attack: 0.005 },
"unit.civilian.death":{ tones: [{ type: "sine", freq: 200, freqEnd: 80 }], dur: 0.25, gain: 0.20 },
"unit_moved": { noise: { kind: "white", cutoff: 800 }, dur: 0.04, gain: 0.10 },
// ── Buildings ─────────────────────────────────────────────────────────
"building.civic.complete": { tones: [{ type: "sine", freq: 440 }, { type: "sine", freq: 660 }], dur: 1.00, gain: 0.22, attack: 0.01 },
"building.production.complete": { tones: [{ type: "triangle", freq: 1200, freqEnd: 800 }], dur: 0.50, gain: 0.30, attack: 0.005 },
"building.military.complete": { noise: { kind: "pink", cutoff: 600 }, tones: [{ type: "sawtooth", freq: 150 }], dur: 0.80, gain: 0.30, attack: 0.02 },
"building.defense.complete": { tones: [{ type: "sine", freq: 80 }], noise: { kind: "white", cutoff: 500 }, dur: 0.20, gain: 0.30 },
"wonder_built": { tones: [{ type: "sine", freq: 440 }, { type: "sine", freq: 554 }, { type: "sine", freq: 659 }, { type: "sine", freq: 880 }], dur: 1.80, gain: 0.22, attack: 0.10 },
// ── Research ──────────────────────────────────────────────────────────
"tech_researched": { tones: [{ type: "sine", freq: 1320 }, { type: "sine", freq: 1760 }], dur: 0.70, gain: 0.20, attack: 0.005 },
"culture_researched":{ noise: { kind: "pink", cutoff: 800 }, tones: [{ type: "sine", freq: 220 }], dur: 0.70, gain: 0.22 },
"research_start": { tones: [{ type: "square", freq: 1500 }], dur: 0.04, gain: 0.10 },
// ── Era / golden age / victory ────────────────────────────────────────
"era_advanced": { noise: { kind: "pink", cutoff: 250 }, tones: [{ type: "sine", freq: 60 }], dur: 1.40, gain: 0.30, attack: 0.05 },
"golden_age_swell": { tones: [{ type: "sawtooth", freq: 220, freqEnd: 440 }, { type: "sawtooth", freq: 110, freqEnd: 220 }], dur: 1.80, gain: 0.25, attack: 0.40, lowpass: 1500 },
"victory_fanfare": { tones: [{ type: "sawtooth", freq: 440 }, { type: "sawtooth", freq: 554 }, { type: "sawtooth", freq: 659 }, { type: "sawtooth", freq: 880 }], dur: 1.40, gain: 0.25, attack: 0.05, lowpass: 2500 },
// ── Fauna ─────────────────────────────────────────────────────────────
"fauna.predator.spawn": { noise: { kind: "white", cutoff: 1500 }, dur: 0.20, gain: 0.18 },
"fauna.predator.attack": { tones: [{ type: "sawtooth", freq: 200, freqEnd: 150 }], noise: { kind: "white", cutoff: 800 }, dur: 0.30, gain: 0.30, tremolo: { rate: 25, depth: 0.5 } },
"fauna.predator.hit": { tones: [{ type: "sawtooth", freq: 600 }], dur: 0.12, gain: 0.25 },
"fauna.predator.death": { tones: [{ type: "sine", freq: 200, freqEnd: 70 }], dur: 0.40, gain: 0.25 },
"fauna.apex_predator.spawn": { tones: [{ type: "sawtooth", freq: 60, freqEnd: 80 }, { type: "sawtooth", freq: 40 }], dur: 0.90, gain: 0.40, attack: 0.10, lowpass: 600, tremolo: { rate: 6, depth: 0.4 } },
"fauna.apex_predator.attack": { tones: [{ type: "sawtooth", freq: 80 }], noise: { kind: "pink", cutoff: 400 }, dur: 0.50, gain: 0.40, tremolo: { rate: 12, depth: 0.5 } },
"fauna.apex_predator.death": { tones: [{ type: "sine", freq: 80, freqEnd: 30 }], dur: 0.70, gain: 0.32, attack: 0.02 },
"fauna.herbivore.spawn": { tones: [{ type: "sawtooth", freq: 400, freqEnd: 320 }], dur: 0.20, gain: 0.16, lowpass: 2000 },
"fauna.herbivore.death": { tones: [{ type: "sine", freq: 400, freqEnd: 200 }], dur: 0.24, gain: 0.20 },
"wild_spawn": { noise: { kind: "white", cutoff: 2000 }, dur: 0.18, gain: 0.18, highpass: 800 },
// ── Weather ───────────────────────────────────────────────────────────
"weather.storm": { noise: { kind: "pink", cutoff: 200 }, tones: [{ type: "sine", freq: 60 }], dur: 1.00, gain: 0.25 },
"weather.blizzard": { noise: { kind: "white", cutoff: 5000 }, dur: 1.20, gain: 0.22, highpass: 1500 },
"weather.heat_wave": { tones: [{ type: "sawtooth", freq: 500 }], dur: 1.00, gain: 0.18, tremolo: { rate: 14, depth: 0.6 } },
"weather.drought": { noise: { kind: "white", cutoff: 3000 }, dur: 0.90, gain: 0.16, highpass: 1500 },
"weather.tornado": { noise: { kind: "pink", cutoff: 600 }, tones: [{ type: "sawtooth", freq: 80, freqEnd: 200 }], dur: 1.00, gain: 0.30, lowpass: 800 },
"weather.hurricane": { noise: { kind: "pink", cutoff: 400 }, tones: [{ type: "sine", freq: 50 }], dur: 1.40, gain: 0.30 },
};
// Generic recipes used when an explicit one isn't authored. Picked by
// matching the manifest key's prefix.
const PREFIX_RECIPES: { prefix: string; recipe: Recipe }[] = [
{ prefix: "unit.", recipe: { tones: [{ type: "triangle", freq: 200 }], dur: 0.10, gain: 0.20 } },
{ prefix: "building.", recipe: { tones: [{ type: "sine", freq: 440 }], dur: 0.50, gain: 0.20 } },
{ prefix: "fauna.", recipe: { tones: [{ type: "sawtooth", freq: 150 }], dur: 0.25, gain: 0.22 } },
{ prefix: "weather.", recipe: { noise: { kind: "pink", cutoff: 600 }, dur: 0.80, gain: 0.20 } },
{ prefix: "ui_", recipe: { tones: [{ type: "square", freq: 800 }], dur: 0.05, gain: 0.15 } },
];
const SILENT_RECIPE: Recipe = { dur: 0.15, gain: 0.0001 };
function recipeFor(key: string): Recipe {
if (key === "_silent") return SILENT_RECIPE;
const explicit = RECIPES[key];
if (explicit) return explicit;
for (const { prefix, recipe } of PREFIX_RECIPES) {
if (key.startsWith(prefix)) return recipe;
}
return { tones: [{ type: "sine", freq: 600 }], dur: 0.15, gain: 0.18 };
}
/** True if there's an explicit recipe for the key. Useful for badging in
* the UI: synth vs synth (generic). */
export function hasExplicitRecipe(key: string): boolean {
return key in RECIPES;
}

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import audioManifest from "@game-data/audio.json";
import { play as playSynth, hasExplicitRecipe } from "../audioSynth";
import { t } from "../theme";
// ─── Types ───────────────────────────────────────────────────────────────────
@ -262,7 +263,7 @@ const EntryRow = styled.div<{ $open: boolean }>`
const EntryHeader = styled.div`
display: grid;
grid-template-columns: 1fr auto auto auto;
grid-template-columns: auto 1fr auto auto auto;
gap: 12px;
align-items: center;
padding: 8px 14px;
@ -288,6 +289,35 @@ const Slider = styled.input.attrs({ type: "range" })`
width: 240px;
`;
const PlayBtn = styled.button`
background: ${t.bg.btnNormal};
color: ${t.text.btn};
border: 1px solid ${t.border.panel};
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:hover {
background: ${t.bg.btnHover};
border-color: ${t.border.focus};
color: ${t.text.btnHover};
}
&:active {
background: ${t.bg.btnPressed};
color: ${t.text.btnPressed};
}
`;
const PlayBtnInline = styled(PlayBtn)`
width: 24px;
height: 24px;
`;
const SliderRow = styled.div`
display: grid;
grid-template-columns: 80px 1fr 90px;
@ -365,6 +395,14 @@ function ResolutionPlayground(): React.ReactElement {
<HopNote>
{hit ? "✓ winner" : present ? "(would match)" : "miss"}
</HopNote>
{present && (
<PlayBtnInline
onClick={() => playSynth(key)}
title={`play synthesised preview of ${key}`}
>
</PlayBtnInline>
)}
</Hop>
);
})}
@ -387,6 +425,14 @@ function ResolutionPlayground(): React.ReactElement {
? `fallback → ${manifest.sfx[key].fallback}`
: "no fallback declared"}
</HopNote>
{key !== "_silent" && (
<PlayBtnInline
onClick={() => playSynth(key)}
title={`play synthesised preview of ${key}`}
>
</PlayBtnInline>
)}
</Hop>
))}
</>
@ -445,7 +491,24 @@ function ManifestBrowser(): React.ReactElement {
onClick={() => setOpenKey(open ? null : key)}
>
<EntryHeader>
<EntryKey>{key}</EntryKey>
<PlayBtnInline
onClick={(e) => { e.stopPropagation(); playSynth(key); }}
title={
hasExplicitRecipe(key)
? `play synthesised preview of ${key}`
: `play generic preview (no explicit recipe for ${key} yet)`
}
>
</PlayBtnInline>
<EntryKey>
{key}
{!hasExplicitRecipe(key) && key !== "_silent" && (
<span style={{ color: t.text.disabled, marginLeft: 8, fontSize: 10 }}>
(generic preview)
</span>
)}
</EntryKey>
<Badge $color={t.bg.deepest}>
{variantCount === 0 ? "_silent" : `${variantCount} stream${variantCount > 1 ? "s" : ""}`}
</Badge>

View file

@ -1,5 +1,5 @@
{
"generated_at": "2026-04-27T08:19:53Z",
"generated_at": "2026-04-27T08:35:27Z",
"totals": {
"done": 103,
"in_progress": 1,

View file

@ -51,10 +51,10 @@ no horses, no carrier birds, no Earth-styled industrial telecom).
| Era | Unit | Tech path | Culture path |
|---|---|---|---|
| era_2 | Foot Runner | `messenger_hut` (NEW, era_2 hub) + tech `tracking` | — (no era_2 culture building fits) |
| era_3 | Tunnel Runner | `messenger_hut` + tech `tunnel_paths` (NEW) | extend existing `gathering_hall` (era_3 culture, `bardic_lore`) with `enables_units` |
| era_4 | Rune Scribe | `messenger_hut`/`hold_post` + tech `runelore` | extend existing `chronicle_hall` (era_4 culture, `chronicle_keeping`) |
| era_5 | Hold Courier | `hold_post` (NEW, era_5 hub upgrade) + tech `dwarf_heritage` | — (era_5 culture buildings = `temple_of_the_ancestor`, no flavor fit) |
| era_6 | Beacon Bearer | `hold_post` + tech `beacon_chain` (NEW); ALSO requires Beacon Tower improvement on the route map | extend existing `chronicle_tower` (era_6 culture, `cultural_canon`) |
| era_3 | Tunnel Runner | `messenger_hut` + tech `tunnel_paths` (NEW) | extend existing `gathering_hall` (tier 2, normal building, `bardic_lore` unlock) ✓ |
| era_4 | Rune Scribe | `messenger_hut`/`hold_post` + tech `runelore` | — (referenced `chronicle_hall` is a phantom — culture tech `chronicle_keeping` claims to unlock it but no building file exists; pre-existing data bug, separate from p3-01) |
| era_5 | Hold Courier | `hold_post` (NEW, era_5 hub upgrade) + tech `dwarf_heritage` | — (every era_5+ culture-tech-unlocked building is `wonder_type: "world"`; wonders are not utility hubs) |
| era_6 | Beacon Bearer | `hold_post` + tech `beacon_chain` (NEW); ALSO requires Beacon Tower improvement on the route map | — (initial pick `chronicle_tower` is a tier-8 World Wonder, +35% empire culture, cost 620 — wrong category for a courier hub) |
| era_7 | Steam Messenger | `steam_forgery_annex` (NEW, era_7 hub upgrade) + tech `steam_forging` | — (steam-mech is tech-path-native; no culture parallel) |
| era_8 | Resonance Telegrapher | `resonance_chamber` (NEW, era_8 hub upgrade) + tech `rune_resonance` (NEW) | — (rune-resonance is tech-path-native; no culture parallel) |
| era_9 | Hold-Network Warden | `hold_network_citadel` (NEW, era_9 hub upgrade) + tech `combined_arms` | — (era_9 culture-tech unlocks `testament_of_kings` which is itself a wonder, not a normal hub) |
@ -67,10 +67,12 @@ no horses, no carrier birds, no Earth-styled industrial telecom).
- `rune_scribe_hall.json` — DELETE. Era_4 Rune Scribe gates on `messenger_hut`/`hold_post` (tech path) + `chronicle_hall` extension (culture path).
- `beacon_tower.json` — MOVE from `data/buildings/` to `improvements/`. Beacon Tower is a killable hilltop structure built by an engineer unit on a hex tile, not a city building. Same category as the existing `fort` improvement.
**Existing buildings to extend with `enables_units`** (the culture path):
- `gathering_hall.json` (in `public/resources/buildings/science_culture.json`) → add `tunnel_runner`
- `chronicle_hall.json` (in `public/resources/buildings/`) → add `rune_scribe`
- `chronicle_tower.json` (in `public/resources/buildings/`) → add `beacon_bearer`
**Existing buildings to extend with `enables_units`** (the culture path — revised 2026-04-27 after wonder audit):
- `gathering_hall.json` (in `public/resources/buildings/science_culture.json`) → adds `tunnel_runner` ✓ (tier 2, NOT a wonder — legitimate normal culture building)
- ~~`chronicle_hall`~~ — phantom building, see era_4 row above
- ~~`chronicle_tower`~~ — tier-8 World Wonder (verified: `wonder_type: "world"`, cost 620, +35% empire culture). Wiring couriers into a wonder mis-uses the wonder identity. Reverted in c4.
**Honest culture-path scope after wonder audit:** only era_3 has a non-wonder culture-tech building (`gathering_hall`) suitable for hosting a courier. Era 49 culture-tech unlocks are exclusively World Wonders (`grand_amphitheater`, `temple_of_the_ancestor`, `chronicle_tower`, `monument_of_ages`, `world_pillar`, `festival_grounds`, `triumph_arch`, `grand_orrery`, `testament_of_kings`, `the_undying_halls`). Couriers at those eras are tech-only.
From era_6 on, the intercept surface shifts from "kill the courier on the road" to "destroy the tower / pillage the wire / cut the resonance" — keeps the intercept-able-knowledge mechanic alive into late game. Beacon Tower (improvement) is killable; Steam Track and Resonance Wire (improvements) are pillage-able. From era_9 on the mesh of Hold-Network Citadels auto-reroutes around severed links.
@ -86,7 +88,7 @@ flavor stays Game 3 (Elves).
## Acceptance criteria
- [ ] **Data pack — units (8 remaining + 1 done)**: 9 new unit JSONs in `public/games/age-of-dwarves/data/units/` matching the Dwarven ladder above: `foot_runner` ✓ (cycle 1), `tunnel_runner`, `rune_scribe`, `hold_courier`, `beacon_bearer`, `steam_messenger`, `resonance_telegrapher`, `hold_network_warden`. (No era_10 unit — Adamantine Echo is wonder-only.) Each declares its era, prerequisite tech, prerequisite building, movement speed, intercept rules, and upgrade-from chain.
- [ ] **Data pack — buildings (revised 2026-04-27)**: 6 new building files (the linear hub chain + era_10 wonder): `messenger_hut`, `hold_post`, `steam_forgery_annex`, `resonance_chamber`, `hold_network_citadel`, `adamantine_echo`. Plus 3 existing buildings extended with `enables_units` for the culture path: `gathering_hall`, `chronicle_hall`, `chronicle_tower`. Cycle 2 wrote 3 redundant building files that need to be DELETED (`tunnel_mouth`, `rune_scribe_hall`) or RELOCATED (`beacon_tower``improvements/`).
- [ ] **Data pack — buildings (revised 2026-04-27, audited 2026-04-27)**: 6 new building files (the linear hub chain + era_10 wonder): `messenger_hut`, `hold_post`, `steam_forgery_annex`, `resonance_chamber`, `hold_network_citadel`, `adamantine_echo` — all authored cycle 1/2. Plus 1 existing non-wonder building extended with `enables_units` for the only legitimate culture path: `gathering_hall` (era_3 / Tunnel Runner). Era 4-9 culture paths abandoned after audit: every era_5+ culture-tech-unlocked building is `wonder_type: "world"`, and `chronicle_hall` (era_4) is a phantom (no file exists, only referenced in culture-tech unlocks).
- [ ] **Data pack — improvements (revised 2026-04-27)**: 5 improvement files in `public/resources/improvements/` (canonical store) — `tunnel` (era_3), `hold_road` (era_5, upgrade of `road`), `steam_track` (era_7, severable), `resonance_wire` (era_8, severable), and **`beacon_tower`** (era_6, killable hilltop structure built by engineer, provides LOS chain — relocated from buildings/). Cycle 2 wrote the first 4; beacon_tower needs c3 to relocate + restructure to improvement schema.
- [ ] **Data pack — techs**: 3 new prereq tech JSONs authored — `tunnel_paths` (era_3, ecology pillar), `beacon_chain` (era_6, military pillar), `rune_resonance` (era_8, metallurgy + runelore crossover). The other 6 tier prereqs (`tracking`, `runelore`, `dwarf_heritage`, `steam_forging`, `combined_arms`, `adamantine_forging`) all exist in the current tech tree — no work needed.
- [ ] **Rust — `mc-trade` extension**: new `OpenBordersAgreement` and `SharedMapAgreement` types, with shared-map agreements requiring a `CourierRoute` resolved each turn (route exists / route severed / courier alive in transit).

View file

@ -27,7 +27,6 @@
"value": 0.1
}
],
"enables_units": ["beacon_bearer"],
"sprite": "sprites/buildings/wonders/chronicle_tower.png",
"flavor": "The tower is never finished. That is the point.",
"flags": [