feat(@projects/@magic-civilization): add real audio streaming support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-27 02:28:47 -07:00
parent 59dea7f358
commit eaba6b542f
5 changed files with 218 additions and 23 deletions

View file

@ -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<string, string>;
/** Map "audio/sfx/turn_started.ogg" → URL the dev server can play. */
const REAL_BY_REL: Record<string, string> = (() => {
const out: Record<string, string> = {};
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<void> {
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 {
</PlayBtnInline>
)}
{present && (() => {
const realPath = realOggAvailable(manifest.sfx[key]);
return realPath ? (
<PlayRealBtn
onClick={() => { void playReal(realPath); }}
title={`play real .ogg shipped at ${realPath}`}
>
</PlayRealBtn>
) : null;
})()}
</Hop>
);
})}
@ -501,6 +584,17 @@ function ManifestBrowser(): React.ReactElement {
>
</PlayBtnInline>
{(() => {
const realPath = realOggAvailable(entry);
return realPath ? (
<PlayRealBtn
onClick={(e) => { e.stopPropagation(); void playReal(realPath); }}
title={`play real .ogg shipped at ${realPath}`}
>
</PlayRealBtn>
) : null;
})()}
<EntryKey>
{key}
{!hasExplicitRecipe(key) && key !== "_silent" && (
@ -646,23 +740,36 @@ function MusicTrackList(): React.ReactElement {
</SectionBlurb>
<Panel>
<EntryList>
{tracks.map((track) => (
<EntryRow key={track.id} $open={false}>
<EntryHeader style={{ gridTemplateColumns: "200px 1fr auto auto auto" }}>
<EntryKey>{track.id}</EntryKey>
<span style={{ color: t.text.secondary, fontSize: 11 }}>{track.description}</span>
{track.era_range && (
<Badge $color={t.bg.surface}>era {track.era_range.join("")}</Badge>
)}
{track.mood && (
<Badge $color={t.bg.raised}>{track.mood}</Badge>
)}
{track.loop === false && (
<Badge $color={t.bg.btnPressed}>once</Badge>
)}
</EntryHeader>
</EntryRow>
))}
{tracks.map((track) => {
const realPath = REAL_BY_REL[track.stream] ? track.stream : null;
return (
<EntryRow key={track.id} $open={false}>
<EntryHeader style={{ gridTemplateColumns: "auto 200px 1fr auto auto auto" }}>
{realPath ? (
<PlayRealBtn
onClick={() => { void playReal(realPath); }}
title={`play real .ogg shipped at ${realPath}`}
>
</PlayRealBtn>
) : (
<span style={{ width: 24, color: t.text.disabled, fontSize: 14, textAlign: "center" }}></span>
)}
<EntryKey>{track.id}</EntryKey>
<span style={{ color: t.text.secondary, fontSize: 11 }}>{track.description}</span>
{track.era_range && (
<Badge $color={t.bg.surface}>era {track.era_range.join("")}</Badge>
)}
{track.mood && (
<Badge $color={t.bg.raised}>{track.mood}</Badge>
)}
{track.loop === false && (
<Badge $color={t.bg.btnPressed}>once</Badge>
)}
</EntryHeader>
</EntryRow>
);
})}
</EntryList>
</Panel>
</Section>
@ -679,7 +786,12 @@ export function AudioSystemPage(): React.ReactElement {
<Sub>
One engine, many packs · {Object.keys(manifest.sfx).length} SFX +
{" "}{manifest.music.tracks.length} music tracks · live read of{" "}
<Mono>data/audio.json</Mono>
<Mono>data/audio.json</Mono> ·
{" "}<strong style={{ color: t.sem.positive }}>{Object.keys(REAL_BY_REL).length}</strong> real .ogg files on disk
</Sub>
<Sub style={{ marginTop: -16 }}>
<span style={{ color: t.accent.gold }}></span> = synthesised preview (Web Audio API, design-tool only) ·
{" "}<span style={{ color: t.sem.positive }}></span> = real .ogg shipped on disk
</Sub>
<ResolutionPlayground />

View file

@ -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"]

View file

@ -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"),
},
},
});

View file

@ -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

View file

@ -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<ClanId>` 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.