diff --git a/.project/designs/app/src/pages/AudioSystem.tsx b/.project/designs/app/src/pages/AudioSystem.tsx index c8ad869b..4e019991 100644 --- a/.project/designs/app/src/pages/AudioSystem.tsx +++ b/.project/designs/app/src/pages/AudioSystem.tsx @@ -5,6 +5,53 @@ import audioManifest from "@game-data/audio.json"; import { play as playSynth, hasExplicitRecipe } from "../audioSynth"; import { t } from "../theme"; +// Vite-provided URL map of every .ogg shipped to the audio assets tree. +// `query: "?url"` tells Vite to emit a fetchable URL string per entry +// rather than a parsed module. Keys are absolute glob-resolved paths; +// we normalise them into the manifest's `audio/...` shape below. +const REAL_OGG_URLS = import.meta.glob( + "@game-assets/audio/**/*.ogg", + { eager: true, query: "?url", import: "default" }, +) as Record; + +/** Map "audio/sfx/turn_started.ogg" → URL the dev server can play. */ +const REAL_BY_REL: Record = (() => { + const out: Record = {}; + for (const [absPath, url] of Object.entries(REAL_OGG_URLS)) { + const idx = absPath.indexOf("/audio/"); + if (idx < 0) continue; + out[absPath.slice(idx + 1)] = url; // strip everything before "audio/" + } + return out; +})(); + +let liveAudio: HTMLAudioElement | null = null; + +async function playReal(relPath: string): Promise { + const url = REAL_BY_REL[relPath]; + if (url == null) return; + // Single shared element so successive clicks don't pile up overlapping + // playbacks of the same cue. + if (liveAudio == null) { + liveAudio = new Audio(); + liveAudio.preload = "auto"; + } + if (!liveAudio.paused) liveAudio.pause(); + liveAudio.src = url; + liveAudio.currentTime = 0; + await liveAudio.play(); +} + +/** Return the resolved manifest path whose .ogg actually exists on disk + * (checks every entry in streams[] plus legacy single `stream`). */ +function realOggAvailable(entry: { stream?: string; streams?: string[] }): string | null { + for (const s of entry.streams ?? []) { + if (REAL_BY_REL[s]) return s; + } + if (entry.stream && REAL_BY_REL[entry.stream]) return entry.stream; + return null; +} + // ─── Types ─────────────────────────────────────────────────────────────────── interface SfxEntry { @@ -263,7 +310,7 @@ const EntryRow = styled.div<{ $open: boolean }>` const EntryHeader = styled.div` display: grid; - grid-template-columns: auto 1fr auto auto auto; + grid-template-columns: auto auto 1fr auto auto auto; gap: 12px; align-items: center; padding: 8px 14px; @@ -318,6 +365,31 @@ const PlayBtnInline = styled(PlayBtn)` height: 24px; `; +// Real-OGG play button — distinct accent so the eye can tell synth-preview +// (gold) from real-asset (green) at a glance. +const PlayRealBtn = styled.button` + background: ${t.bg.btnNormal}; + color: ${t.sem.positive}; + border: 1px solid ${t.sem.positive}66; + border-radius: 50%; + width: 24px; + height: 24px; + cursor: pointer; + font-size: 11px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + &:hover { + background: ${t.sem.positive}22; + border-color: ${t.sem.positive}; + color: ${t.sem.positive}; + } + &:active { + background: ${t.sem.positive}44; + } +`; + const SliderRow = styled.div` display: grid; grid-template-columns: 80px 1fr 90px; @@ -403,6 +475,17 @@ function ResolutionPlayground(): React.ReactElement { ▶ )} + {present && (() => { + const realPath = realOggAvailable(manifest.sfx[key]); + return realPath ? ( + { void playReal(realPath); }} + title={`play real .ogg shipped at ${realPath}`} + > + ● + + ) : null; + })()} ); })} @@ -501,6 +584,17 @@ function ManifestBrowser(): React.ReactElement { > ▶ + {(() => { + const realPath = realOggAvailable(entry); + return realPath ? ( + { e.stopPropagation(); void playReal(realPath); }} + title={`play real .ogg shipped at ${realPath}`} + > + ● + + ) : null; + })()} {key} {!hasExplicitRecipe(key) && key !== "_silent" && ( @@ -646,23 +740,36 @@ function MusicTrackList(): React.ReactElement { - {tracks.map((track) => ( - - - {track.id} - {track.description} - {track.era_range && ( - era {track.era_range.join("–")} - )} - {track.mood && ( - {track.mood} - )} - {track.loop === false && ( - once - )} - - - ))} + {tracks.map((track) => { + const realPath = REAL_BY_REL[track.stream] ? track.stream : null; + return ( + + + {realPath ? ( + { void playReal(realPath); }} + title={`play real .ogg shipped at ${realPath}`} + > + ● + + ) : ( + + )} + {track.id} + {track.description} + {track.era_range && ( + era {track.era_range.join("–")} + )} + {track.mood && ( + {track.mood} + )} + {track.loop === false && ( + once + )} + + + ); + })} @@ -679,7 +786,12 @@ export function AudioSystemPage(): React.ReactElement { ♪ One engine, many packs · {Object.keys(manifest.sfx).length} SFX + {" "}{manifest.music.tracks.length} music tracks · live read of{" "} - data/audio.json + data/audio.json · + {" "}{Object.keys(REAL_BY_REL).length} real .ogg files on disk + + + = synthesised preview (Web Audio API, design-tool only) · + {" "} = real .ogg shipped on disk diff --git a/.project/designs/app/tsconfig.json b/.project/designs/app/tsconfig.json index d72b8346..e032bce2 100644 --- a/.project/designs/app/tsconfig.json +++ b/.project/designs/app/tsconfig.json @@ -14,7 +14,8 @@ "resolveJsonModule": true, "baseUrl": ".", "paths": { - "@game-data/*": ["../../../public/games/age-of-dwarves/data/*"] + "@game-data/*": ["../../../public/games/age-of-dwarves/data/*"], + "@game-assets/*": ["../../../public/games/age-of-dwarves/assets/*"] } }, "include": ["src", "../../../public/games/age-of-dwarves/data/audio.json"] diff --git a/.project/designs/app/vite.config.ts b/.project/designs/app/vite.config.ts index 630b27d2..5b6fcf95 100644 --- a/.project/designs/app/vite.config.ts +++ b/.project/designs/app/vite.config.ts @@ -7,7 +7,8 @@ export default defineConfig({ server: { port: 7777 }, resolve: { alias: { - "@game-data": path.resolve(__dirname, "../../../public/games/age-of-dwarves/data"), + "@game-data": path.resolve(__dirname, "../../../public/games/age-of-dwarves/data"), + "@game-assets": path.resolve(__dirname, "../../../public/games/age-of-dwarves/assets"), }, }, }); diff --git a/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md b/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md index 3239644f..57050555 100644 --- a/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md +++ b/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md @@ -17,8 +17,8 @@ evidence: - "Runesmith: mixed runic roster (quarrelman, runesmith → pike_guard, rune_spear, marksman, light_field_gun → machine_gunner, iron_halberd, soulbolt)" - "Acceptance violations check: zero violations — every unit in every clan's build list has that clan in its clan_affinity (or is a generic warrior/spearmen/archer shared by all)" remaining_work: - - "GDScript AI controller (acceptance criterion 3): not yet wired to read clan_affinity at decision time. Status remains partial until ai_player.gd / equivalent is updated to filter unit choices by clan_affinity at build-decision time. Track as follow-up — possibly a new objective scoped to AI controller wiring." - - "Acceptance criterion 5 (10-seed batch on apricot showing distinct unit-mix histograms): blocked on criterion 3. Cannot measure differentiated AI behavior until controller reads clan_affinity." + - "Rust AI controller wiring (acceptance criterion 3): not yet reading clan_affinity at decision time. Per Rail #1 (Rust is simulation source of truth), this lands in src/simulator/crates/mc-ai/ — specifically the build-order / unit-selection decision path inside the MCTS rollout or strategic action generator. Surface to GDScript via api-gdext/src/ai.rs (GdAiController). The existing GDScript AI files (simple_heuristic_ai.gd, ai_tactical.gd, ai_military.gd) are tech-debt tracked by p0-26-ai-tactical-rust-port.md — DO NOT add new clan_affinity logic to GDScript. Track as a new objective scoped to the mc-ai crate." + - "Acceptance criterion 5 (10-seed batch on apricot showing distinct unit-mix histograms): blocked on criterion 3. Cannot measure differentiated AI behavior until mc-ai reads clan_affinity from the JSON pack at decision time." --- ## Summary diff --git a/.project/objectives/p1-37-mc-ai-clan-affinity-routing.md b/.project/objectives/p1-37-mc-ai-clan-affinity-routing.md new file mode 100644 index 00000000..2123696f --- /dev/null +++ b/.project/objectives/p1-37-mc-ai-clan-affinity-routing.md @@ -0,0 +1,81 @@ +--- +id: p1-37 +title: "mc-ai clan_affinity routing — Rust AI reads unit clan_affinity at build-decision time" +priority: p1 +status: missing +scope: game1 +owner: warcouncil +updated_at: 2026-04-27 +assigned_by: shipwright +blockedBy: [p1-34, p1-36] +--- +## Summary + +p1-36 landed the data side: every unit JSON has a `clan_affinity` array (per p1-34's +schema expansion), and `ai_personalities.json` now has tiered build orders (early / +mid / late) per clan that respect clan affinity. But the **decision loop that picks +units doesn't yet read either field** — it still selects from a flat priority list, +so all five clans build similarly-statted armies and the Ironhold-vs-Blackhammer +gameplay difference doesn't surface in actual matches. + +Per **Rail #1** (Rust is the simulation source of truth), this work lands in +`src/simulator/crates/mc-ai/`, NOT in any GDScript file. The completed p0-26 port +established the `GdAiController` bridge; this objective extends the build-order / +unit-selection path inside `mc-ai` to consume `clan_affinity` data. + +## Files likely modified + +- `src/simulator/crates/mc-ai/src/strategic/` — the build-order / production + decision module (whatever file currently picks "build a warrior" vs "build a + forge") +- `src/simulator/crates/mc-ai/src/data/` — if there's a unit-data accessor that + loads from JSON packs, extend it to expose `clan_affinity` + `archetype` +- `src/simulator/crates/mc-tech/` or `mc-core/` — wherever the unit JSON pack is + parsed into a typed struct, add `clan_affinity: Vec` and `archetype: + Archetype` fields +- `src/simulator/crates/api-gdext/src/ai.rs` — no signature change expected; the + bridge already exposes the build-decision path +- Tests: `src/simulator/crates/mc-ai/tests/` — add a test that two AIs with + different `clan_id` produce measurably different unit-mix histograms over N + decision steps on the same starting state + +## Acceptance criteria + +- [ ] mc-ai's build-order decision logic queries each candidate unit's + `clan_affinity` and weights units that include the active clan's ID higher + than units that don't +- [ ] Unit pack parser in Rust populates `clan_affinity` and `archetype` from + the JSON (currently they're not in the Rust struct) +- [ ] Generic units (warrior, spearmen, archer with all-five-clans affinity) + remain neutral fallbacks for any clan +- [ ] When a clan can't build its preferred unit (missing tech / building / + resource), the decision falls through to the next-most-preferred candidate + rather than picking randomly +- [ ] Headless test on apricot: 10-seed T300 batch with 5 distinct clan AIs + shows unit-mix histogram divergence — Blackhammer ≥40% light melee + composition; Deepforge ≥30% siege/walker; Ironhold ≥40% heavy melee +- [ ] Warcouncil quality gates still pass: median tier_peak ≥6, total_combats + ≥50, ≥1 wonder per player ≥5/10, tier_peak_gap ≤2 +- [ ] No new GDScript AI logic added — all clan_affinity routing lives in + Rust per Rail #1 + +## Dispatch hint + +Read `src/simulator/crates/mc-ai/src/strategic/` first to find where build +decisions happen today. Likely a `decide_production(state, clan_id)` function +or similar. Find the unit-candidate iteration loop and inject the +clan_affinity weighting there. The data plumbing — getting clan_affinity from +the JSON pack to the decision function — is the larger half of the work; the +weighting itself is a one-liner. + +If `clan_affinity` isn't currently in any Rust struct, the natural place to +add it is wherever `unit.attack` and `unit.defense` already live. Same for +`archetype`. + +## Notes + +- Builds on p1-34 (data schema) and p1-36 (data in JSON). Don't start until + both are merged. +- Eventual replacement for the GDScript `simple_heuristic_ai.gd` build-order + branch; that file is tech-debt per p0-26 and not where new AI logic should + land.