feat(@projects/@magic-civilization): ✨ add real audio streaming support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
59dea7f358
commit
eaba6b542f
5 changed files with 218 additions and 23 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
81
.project/objectives/p1-37-mc-ai-clan-affinity-routing.md
Normal file
81
.project/objectives/p1-37-mc-ai-clan-affinity-routing.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue