From b1febc5884d4474925722bb6cde12acaa664af59 Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 27 Apr 2026 01:36:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(audio):=20=E2=9C=A8=20add=20procedural=20a?= =?UTF-8?q?udio=20preview=20synth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/designs/app/src/audioSynth.ts | 280 ++++++++++++++++++ .../designs/app/src/pages/AudioSystem.tsx | 67 ++++- .project/objectives/objectives.json | 2 +- .../objectives/p3-01-courier-diplomacy.md | 20 +- .../resources/buildings/chronicle_tower.json | 1 - 5 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 .project/designs/app/src/audioSynth.ts diff --git a/.project/designs/app/src/audioSynth.ts b/.project/designs/app/src/audioSynth.ts new file mode 100644 index 00000000..6ad7b2b3 --- /dev/null +++ b/.project/designs/app/src/audioSynth.ts @@ -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 = { + // ── 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; +} diff --git a/.project/designs/app/src/pages/AudioSystem.tsx b/.project/designs/app/src/pages/AudioSystem.tsx index 5a01c2bb..c8ad869b 100644 --- a/.project/designs/app/src/pages/AudioSystem.tsx +++ b/.project/designs/app/src/pages/AudioSystem.tsx @@ -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 { {hit ? "✓ winner" : present ? "(would match)" : "miss"} + {present && ( + playSynth(key)} + title={`play synthesised preview of ${key}`} + > + ▶ + + )} ); })} @@ -387,6 +425,14 @@ function ResolutionPlayground(): React.ReactElement { ? `fallback → ${manifest.sfx[key].fallback}` : "no fallback declared"} + {key !== "_silent" && ( + playSynth(key)} + title={`play synthesised preview of ${key}`} + > + ▶ + + )} ))} @@ -445,7 +491,24 @@ function ManifestBrowser(): React.ReactElement { onClick={() => setOpenKey(open ? null : key)} > - {key} + { e.stopPropagation(); playSynth(key); }} + title={ + hasExplicitRecipe(key) + ? `play synthesised preview of ${key}` + : `play generic preview (no explicit recipe for ${key} yet)` + } + > + ▶ + + + {key} + {!hasExplicitRecipe(key) && key !== "_silent" && ( + + (generic preview) + + )} + {variantCount === 0 ? "_silent" : `${variantCount} stream${variantCount > 1 ? "s" : ""}`} diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json index d6e4bd9d..4423e9a1 100644 --- a/.project/objectives/objectives.json +++ b/.project/objectives/objectives.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-04-27T08:19:53Z", + "generated_at": "2026-04-27T08:35:27Z", "totals": { "done": 103, "in_progress": 1, diff --git a/.project/objectives/p3-01-courier-diplomacy.md b/.project/objectives/p3-01-courier-diplomacy.md index ffeb0454..b0b2bcdc 100644 --- a/.project/objectives/p3-01-courier-diplomacy.md +++ b/.project/objectives/p3-01-courier-diplomacy.md @@ -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 4–9 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). diff --git a/public/resources/buildings/chronicle_tower.json b/public/resources/buildings/chronicle_tower.json index 11c73c98..a928d220 100644 --- a/public/resources/buildings/chronicle_tower.json +++ b/public/resources/buildings/chronicle_tower.json @@ -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": [