diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 30b35819..a0c930b8 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,11 +1,11 @@ { - "generated_at": "2026-04-17T19:05:25Z", + "generated_at": "2026-04-17T19:07:01Z", "totals": { + "missing": 2, "done": 32, + "partial": 10, "stub": 0, "oos": 4, - "partial": 10, - "missing": 2, "total": 48 }, "objectives": [ diff --git a/public/games/age-of-dwarves/guide/src/App.tsx b/public/games/age-of-dwarves/guide/src/App.tsx index d469ab24..b5bdfe62 100644 --- a/public/games/age-of-dwarves/guide/src/App.tsx +++ b/public/games/age-of-dwarves/guide/src/App.tsx @@ -31,7 +31,7 @@ import { EpisodeDwarvesPage, FullGamePage, ExpansionsPage, ToolsPage, CrowdfundPage, TeamPage, EncyclopediaPage, LensesPage, KhazadPrimePlanetPage, - DevSpritesPage, + DevSpritesPage, ProgressReportPage, } from '@/app/lazy-pages' const DEFAULT_PREFS: PlayerPreferences = { race: 'random', gender: 'random', name: '', colorMode: 'dark', dyslexicFont: false, fontSize: 'md' } @@ -72,6 +72,7 @@ export default function App(): ReactElement { {/* About */} } /> } /> + } /> } /> } /> } /> diff --git a/public/games/age-of-dwarves/guide/src/app/lazy-pages.ts b/public/games/age-of-dwarves/guide/src/app/lazy-pages.ts index 14827464..89c8404a 100644 --- a/public/games/age-of-dwarves/guide/src/app/lazy-pages.ts +++ b/public/games/age-of-dwarves/guide/src/app/lazy-pages.ts @@ -67,6 +67,9 @@ export const CrowdfundPage = lazy(() => import('@/pages/CrowdfundPage') export const TeamPage = lazy(() => import('@/pages/TeamPage')) export const EncyclopediaPage = lazy(() => import('@/pages/EncyclopediaPage')) +// Progress report (auto-generated objectives + asset pipeline status) +export const ProgressReportPage = lazy(() => import('@/pages/ProgressReportPage')) + // Observation export const LensesPage = lazy(() => import('@/pages/LensesPage')) diff --git a/public/games/age-of-dwarves/guide/src/nav.tsx b/public/games/age-of-dwarves/guide/src/nav.tsx index 4b71f629..f14310d6 100644 --- a/public/games/age-of-dwarves/guide/src/nav.tsx +++ b/public/games/age-of-dwarves/guide/src/nav.tsx @@ -21,6 +21,7 @@ export const NAV: NavGroup[] = [ { title: 'About', items: [ + { to: '/progress', icon: '📊', label: 'Progress Report' }, { to: '/about/early-access', icon: '🚀', label: 'Early Access' }, { to: '/about/crowdfund', icon: '🎯', label: 'Crowdfund' }, { to: '/about/team', icon: '👥', label: 'Team' }, diff --git a/public/games/age-of-dwarves/guide/src/pages/ProgressReportPage.tsx b/public/games/age-of-dwarves/guide/src/pages/ProgressReportPage.tsx new file mode 100644 index 00000000..a5352d57 --- /dev/null +++ b/public/games/age-of-dwarves/guide/src/pages/ProgressReportPage.tsx @@ -0,0 +1,353 @@ +import { useMemo, useState, type ReactElement } from 'react' +import { + FadeIn, + PageTitle, + PageHeading, + PageSubtitle, + Section, + SectionHeading, + Prose, + FilterRow, + FilterChip, +} from '@magic-civ/guide-engine' +import objectivesData from '@data/objectives.json' +import audioManifest from '@data/audio.json' +import { ObjectiveModal } from './progress-report/ObjectiveModal' +import { + STATUS_ICON, + STATUS_LABEL, + type ObjectivePriority, + type ObjectiveRecord, + type ObjectivesExport, + type ObjectiveStatus, + type StatusFilter, +} from './progress-report/types' +import { + buildAssetGroup, + collectExpectedAudioPaths, + collectExpectedBuildingSpritePaths, + collectExpectedUnitSpritePaths, + normalisePresentPaths, +} from './progress-report/assets-detection' +import { + countStatusesForPriority, + filterObjectives, + groupByPriority, +} from './progress-report/filter' +import { + TotalsGrid, + TotalCard, + TotalValue, + TotalLabel, + PriorityBar, + PriorityBarHeader, + PriorityBarTrack, + PriorityBarSegment, + StatusBadge, + PriorityChip, + ObjectivesTable, + ObjectiveRow, + IdCell, + TitleCell, + OwnerCell, + DeferredDisclosure, + AssetGroupBlock, + AssetGroupHeader, + AssetGroupSummaryText, + AssetProgressTrack, + AssetProgressFill, + AssetList, + AssetItem, + Meta, +} from './progress-report/styled' + +const PRIORITY_LABEL: Record = { + p0: 'P0 — Blockers for completely playable', + p1: 'P1 — Ship readiness', + p2: 'P2 — Polish', +} + +// ─── Asset-presence detection via Vite glob ───────────────────────────────── +// Glob keys are resolved at build time; values are unused. Presence is purely +// "does this filename exist under the publicDir?". +const audioGlob = import.meta.glob('/audio/**/*.ogg', { eager: true }) +const unitSpriteGlob = import.meta.glob('/sprites/units/*.png', { eager: true }) +const buildingSpriteGlob = import.meta.glob('/sprites/buildings/*.png', { eager: true }) + +interface UnitJson { id?: string; sprite?: string; gender?: { male?: { sprite?: string }; female?: { sprite?: string } } } +interface BuildingJson { id?: string; sprite?: string } +const unitFileGlob = import.meta.glob('@data/units/*.json', { eager: true, import: 'default' }) +const buildingFileGlob = import.meta.glob('@data/buildings/*.json', { eager: true, import: 'default' }) + +function useAssetGroups(): ReturnType { + return useMemo(() => buildAssetGroups(), []) +} + +function buildAssetGroups(): { + audio: ReturnType + unitSprites: ReturnType + buildingSprites: ReturnType +} { + const expectedAudio = collectExpectedAudioPaths(audioManifest) + const audioPresent = normalisePresentPaths(Object.keys(audioGlob)) + + const unitFiles = Object.values(unitFileGlob).filter( + (v): v is UnitJson[] => Array.isArray(v), + ) + const buildingFiles = Object.values(buildingFileGlob).filter( + (v): v is BuildingJson[] => Array.isArray(v), + ) + const expectedUnitSprites = collectExpectedUnitSpritePaths(unitFiles) + const expectedBuildingSprites = collectExpectedBuildingSpritePaths(buildingFiles) + + const unitSpritesPresent = normalisePresentPaths(Object.keys(unitSpriteGlob)) + const buildingSpritesPresent = normalisePresentPaths(Object.keys(buildingSpriteGlob)) + + return { + audio: buildAssetGroup('Audio', expectedAudio, audioPresent), + unitSprites: buildAssetGroup('Unit sprites', expectedUnitSprites, unitSpritesPresent), + buildingSprites: buildAssetGroup( + 'Building sprites', + expectedBuildingSprites, + buildingSpritesPresent, + ), + } +} + +export default function ProgressReportPage(): ReactElement { + const data = objectivesData as ObjectivesExport + const [filter, setFilter] = useState('all') + const [activeObjective, setActiveObjective] = useState(null) + + const inScope = useMemo( + () => data.objectives.filter((o) => o.scope === 'game1'), + [data.objectives], + ) + const deferred = useMemo( + () => data.objectives.filter((o) => o.scope === 'game2'), + [data.objectives], + ) + + const filtered = useMemo(() => filterObjectives(inScope, filter), [inScope, filter]) + + const groupedByPriority = useMemo(() => groupByPriority(filtered), [filtered]) + + const assetGroups = useAssetGroups() + + return ( + + + Progress Report + + Live view of the objectives dashboard and the asset pipeline. Generated + from .project/objectives/ frontmatter on every{' '} + ./run verify — {data.totals.total} objectives tracked. + + + +
+ Overall Status + + {(['done', 'partial', 'stub', 'missing', 'oos'] as ObjectiveStatus[]).map((s) => ( + + + {data.totals[s]} + + {STATUS_LABEL[s]} + + ))} + + {data.totals.total} + Total + + + + {(['p0', 'p1', 'p2'] as ObjectivePriority[]).map((p) => + renderPriorityBar(p, inScope), + )} +
+ +
+ Objectives + + setFilter('all')}> + All ({inScope.length}) + + setFilter('p0')}> + P0 only + + setFilter('partial')}> + Partial only + + setFilter('missing')}> + Missing / stub + + + + {(['p0', 'p1', 'p2'] as ObjectivePriority[]).map((p) => { + const rows = groupedByPriority[p] + if (rows.length === 0) return null + return ( +
+ + {PRIORITY_LABEL[p]} ({rows.length}) + + + + + ID + Status + Title + Owner + + + + {rows.map((o) => ( + setActiveObjective(o)} + > + {o.id.toUpperCase()} + + + + {STATUS_LABEL[o.status]} + + + {o.title} + {o.owner ?? '—'} + + ))} + + +
+ ) + })} + + {deferred.length > 0 && ( + + + Deferred to Game 2 — Age of Kzzykt ({deferred.length}) + + + These objectives are explicitly future-scope. They are not part of + the Age of Dwarves Early Access release. + + + + + ID + Priority + Title + + + + {deferred.map((o) => ( + setActiveObjective(o)} + > + {o.id.toUpperCase()} + + + {o.priority.toUpperCase()} + + + {o.title} + + ))} + + + + )} +
+ +
+ Asset Pipeline + + Audio and sprite files declared in the game data are checked against + what actually ships in the assets tree. Missing files don't block + gameplay — audio falls back to silence and sprites fall back to a + placeholder — but they do need to land before Early Access. + + {[assetGroups.audio, assetGroups.unitSprites, assetGroups.buildingSprites].map( + (g) => ( + + + {g.label} + + {g.present}/{g.total} present + + + + + + + {g.items.map((item) => ( + + {item.label} + + ))} + + + ), + )} +
+ + Objectives generated {formatDate(data.generated_at)}. + + {activeObjective && ( + setActiveObjective(null)} + /> + )} +
+ ) +} + +function renderPriorityBar(priority: ObjectivePriority, objectives: ObjectiveRecord[]): ReactElement | null { + const counts = countStatusesForPriority(objectives, priority) + if (counts.total === 0) return null + const { total } = counts + + return ( + + + + {priority.toUpperCase()}{' '} + {PRIORITY_LABEL[priority].replace(/^p[012] — /, '')} + + + {counts.done}/{total} done + + + + {(['done', 'partial', 'stub', 'missing'] as ObjectiveStatus[]).map((s) => + counts[s] > 0 ? ( + + ) : null, + )} + + + ) +} + +function formatDate(iso: string): string { + if (!iso) return 'unknown' + try { + return new Date(iso).toISOString().replace('T', ' ').replace('Z', ' UTC') + } catch { + return iso + } +} diff --git a/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/assets-detection.test.ts b/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/assets-detection.test.ts new file mode 100644 index 00000000..fc6a9d82 --- /dev/null +++ b/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/assets-detection.test.ts @@ -0,0 +1,149 @@ +import { + buildAssetGroup, + collectExpectedAudioPaths, + collectExpectedBuildingSpritePaths, + collectExpectedUnitSpritePaths, + normalisePresentPaths, +} from '@/pages/progress-report/assets-detection' + +describe('collectExpectedAudioPaths', () => { + it('lists every sfx and music track stream, normalised relative to assets/', () => { + const manifest = { + sfx: { + turn_started: { stream: 'audio/sfx/turn_started.ogg' }, + city_founded: { stream: 'audio/sfx/city_founded.ogg' }, + }, + music: { + tracks: [ + { stream: 'audio/music/overworld_awakening.ogg' }, + { stream: 'audio/music/victory.ogg' }, + ], + }, + } + const paths = collectExpectedAudioPaths(manifest) + expect(paths).toHaveLength(4) + expect(paths).toContain('audio/sfx/turn_started.ogg') + expect(paths).toContain('audio/music/victory.ogg') + }) + + it('skips entries without a stream field', () => { + const manifest = { + sfx: { broken: {}, ok: { stream: 'audio/sfx/ok.ogg' } }, + music: { tracks: [{}, { stream: 'audio/music/x.ogg' }] }, + } + expect(collectExpectedAudioPaths(manifest)).toEqual([ + 'audio/sfx/ok.ogg', + 'audio/music/x.ogg', + ]) + }) +}) + +describe('collectExpectedUnitSpritePaths', () => { + it('prefers per-gender sprite paths when present', () => { + const files = [ + [ + { + id: 'archer', + sprite: 'sprites/units/archer.png', + gender: { + male: { sprite: 'sprites/units/archer_m.png' }, + female: { sprite: 'sprites/units/archer_f.png' }, + }, + }, + ], + ] + const paths = collectExpectedUnitSpritePaths(files) + expect(paths).toEqual([ + 'sprites/units/archer_f.png', + 'sprites/units/archer_m.png', + ]) + }) + + it('falls back to top-level sprite when no gender variants declared', () => { + const files = [[{ id: 'dire_wolf', sprite: 'sprites/units/dire_wolf.png' }]] + expect(collectExpectedUnitSpritePaths(files)).toEqual([ + 'sprites/units/dire_wolf.png', + ]) + }) + + it('deduplicates sprites shared across files and sorts the output', () => { + const files = [ + [{ id: 'a', sprite: 'sprites/units/a.png' }], + [{ id: 'b', sprite: 'sprites/units/b.png' }], + [{ id: 'a', sprite: 'sprites/units/a.png' }], + ] + expect(collectExpectedUnitSpritePaths(files)).toEqual([ + 'sprites/units/a.png', + 'sprites/units/b.png', + ]) + }) +}) + +describe('collectExpectedBuildingSpritePaths', () => { + it('extracts sprite paths per building entry', () => { + const files = [ + [ + { id: 'forge', sprite: 'sprites/buildings/forge.png' }, + { id: 'barracks', sprite: 'sprites/buildings/barracks.png' }, + ], + [{ id: 'granary', sprite: 'sprites/buildings/granary.png' }], + ] + const paths = collectExpectedBuildingSpritePaths(files) + expect(paths).toEqual([ + 'sprites/buildings/barracks.png', + 'sprites/buildings/forge.png', + 'sprites/buildings/granary.png', + ]) + }) + + it('ignores entries without a sprite field', () => { + const files = [[{ id: 'stub' }, { id: 'ok', sprite: 'sprites/buildings/ok.png' }]] + expect(collectExpectedBuildingSpritePaths(files)).toEqual([ + 'sprites/buildings/ok.png', + ]) + }) +}) + +describe('normalisePresentPaths', () => { + it('strips the leading path up to and including /assets/', () => { + const keys = [ + '/opt/repo/public/games/age-of-dwarves/assets/sprites/units/archer_m.png', + '/assets/sprites/buildings/granary.png', + ] + expect(normalisePresentPaths(keys)).toEqual( + new Set(['sprites/units/archer_m.png', 'sprites/buildings/granary.png']), + ) + }) + + it('leaves relative paths alone after stripping leading slashes', () => { + expect(normalisePresentPaths(['sprites/units/x.png'])).toEqual( + new Set(['sprites/units/x.png']), + ) + }) +}) + +describe('buildAssetGroup', () => { + it('marks each expected path present or absent based on the present set', () => { + const group = buildAssetGroup( + 'Unit sprites', + ['sprites/units/archer_m.png', 'sprites/units/worker_m.png'], + new Set(['sprites/units/archer_m.png']), + ) + expect(group.total).toBe(2) + expect(group.present).toBe(1) + expect(group.items.map((i) => [i.label, i.present])).toEqual([ + ['archer_m.png', true], + ['worker_m.png', false], + ]) + }) + + it('reports zero present when nothing matches', () => { + const group = buildAssetGroup( + 'Audio', + ['audio/sfx/a.ogg', 'audio/sfx/b.ogg'], + new Set(), + ) + expect(group.present).toBe(0) + expect(group.items.every((i) => !i.present)).toBe(true) + }) +}) diff --git a/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/filter.test.ts b/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/filter.test.ts new file mode 100644 index 00000000..98b320cf --- /dev/null +++ b/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/filter.test.ts @@ -0,0 +1,76 @@ +import { + countStatusesForPriority, + filterObjectives, + groupByPriority, +} from '@/pages/progress-report/filter' +import type { ObjectiveRecord } from '@/pages/progress-report/types' + +const fixture: ObjectiveRecord[] = [ + mk('p0-01', 'p0', 'done'), + mk('p0-02', 'p0', 'partial'), + mk('p0-03', 'p0', 'missing'), + mk('p1-01', 'p1', 'done'), + mk('p1-02', 'p1', 'stub'), + mk('p2-01', 'p2', 'partial'), + mk('p2-02', 'p2', 'done'), +] + +function mk( + id: string, + priority: ObjectiveRecord['priority'], + status: ObjectiveRecord['status'], +): ObjectiveRecord { + return { + id, priority, status, + title: `Title ${id}`, + scope: 'game1', + owner: null, + updated_at: '2026-04-17', + summary: '', + } +} + +describe('filterObjectives', () => { + it('returns the full list when filter is "all"', () => { + expect(filterObjectives(fixture, 'all')).toHaveLength(fixture.length) + }) + + it('keeps only P0 rows under the "p0" filter', () => { + const out = filterObjectives(fixture, 'p0') + expect(out.map((o) => o.id)).toEqual(['p0-01', 'p0-02', 'p0-03']) + }) + + it('keeps only partial rows under the "partial" filter', () => { + const out = filterObjectives(fixture, 'partial') + expect(out.every((o) => o.status === 'partial')).toBe(true) + expect(out).toHaveLength(2) + }) + + it('keeps missing AND stub rows under the "missing" filter', () => { + const out = filterObjectives(fixture, 'missing') + expect(out.map((o) => o.id).sort()).toEqual(['p0-03', 'p1-02']) + }) +}) + +describe('groupByPriority', () => { + it('partitions by priority and preserves input order within each group', () => { + const groups = groupByPriority(fixture) + expect(groups.p0.map((o) => o.id)).toEqual(['p0-01', 'p0-02', 'p0-03']) + expect(groups.p1.map((o) => o.id)).toEqual(['p1-01', 'p1-02']) + expect(groups.p2.map((o) => o.id)).toEqual(['p2-01', 'p2-02']) + }) +}) + +describe('countStatusesForPriority', () => { + it('counts statuses for a specific priority only', () => { + const counts = countStatusesForPriority(fixture, 'p0') + expect(counts).toEqual({ + done: 1, partial: 1, stub: 0, missing: 1, oos: 0, total: 3, + }) + }) + + it('returns zeros when no objectives match the priority', () => { + const counts = countStatusesForPriority([], 'p1') + expect(counts.total).toBe(0) + }) +}) diff --git a/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/objectives-json.test.ts b/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/objectives-json.test.ts new file mode 100644 index 00000000..0ea5c0d5 --- /dev/null +++ b/public/games/age-of-dwarves/guide/src/pages/progress-report/__tests__/objectives-json.test.ts @@ -0,0 +1,49 @@ +import objectivesData from '@data/objectives.json' +import type { ObjectivesExport, ObjectiveStatus } from '@/pages/progress-report/types' + +const VALID_STATUSES: ReadonlySet = new Set([ + 'done', 'partial', 'stub', 'missing', 'oos', +]) + +const data = objectivesData as ObjectivesExport + +describe('objectives.json (generated by tools/objectives-report.py)', () => { + it('has a generated_at timestamp in the expected ISO-like shape', () => { + expect(typeof data.generated_at).toBe('string') + expect(data.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/) + }) + + it('totals sum matches the objectives array length', () => { + const sum = (['done', 'partial', 'stub', 'missing', 'oos'] as ObjectiveStatus[]).reduce( + (acc, k) => acc + data.totals[k], + 0, + ) + expect(sum).toBe(data.totals.total) + expect(sum).toBe(data.objectives.length) + }) + + it('every objective has the required frontmatter fields', () => { + for (const o of data.objectives) { + expect(typeof o.id).toBe('string') + expect(o.id).toMatch(/^p[012]-\d{2}$/) + expect(typeof o.title).toBe('string') + expect(o.title.length).toBeGreaterThan(0) + expect(['p0', 'p1', 'p2']).toContain(o.priority) + expect(VALID_STATUSES.has(o.status)).toBe(true) + expect(['game1', 'game2']).toContain(o.scope) + expect(typeof o.updated_at).toBe('string') + expect(typeof o.summary).toBe('string') + } + }) + + it('all game2-scoped objectives are out-of-scope', () => { + for (const o of data.objectives) { + if (o.scope === 'game2') expect(o.status).toBe('oos') + } + }) + + it('at least one objective has a non-empty summary (exported from prose body)', () => { + const withSummary = data.objectives.filter((o) => o.summary.length > 0) + expect(withSummary.length).toBeGreaterThan(30) + }) +}) diff --git a/public/games/age-of-dwarves/guide/src/pages/progress-report/filter.ts b/public/games/age-of-dwarves/guide/src/pages/progress-report/filter.ts new file mode 100644 index 00000000..c745ac73 --- /dev/null +++ b/public/games/age-of-dwarves/guide/src/pages/progress-report/filter.ts @@ -0,0 +1,47 @@ +import type { ObjectivePriority, ObjectiveRecord, ObjectiveStatus, StatusFilter } from './types' + +export function filterObjectives( + objectives: ObjectiveRecord[], + filter: StatusFilter, +): ObjectiveRecord[] { + switch (filter) { + case 'all': return objectives + case 'p0': return objectives.filter((o) => o.priority === 'p0') + case 'partial': return objectives.filter((o) => o.status === 'partial') + case 'missing': + return objectives.filter((o) => o.status === 'missing' || o.status === 'stub') + } +} + +export function groupByPriority( + objectives: ObjectiveRecord[], +): Record { + const groups: Record = { p0: [], p1: [], p2: [] } + for (const o of objectives) groups[o.priority].push(o) + return groups +} + +export interface PriorityStatusCounts { + done: number + partial: number + stub: number + missing: number + oos: number + total: number +} + +export function countStatusesForPriority( + objectives: ObjectiveRecord[], + priority: ObjectivePriority, +): PriorityStatusCounts { + const counts: PriorityStatusCounts = { + done: 0, partial: 0, stub: 0, missing: 0, oos: 0, total: 0, + } + for (const o of objectives) { + if (o.priority !== priority) continue + const s: ObjectiveStatus = o.status + counts[s] += 1 + counts.total += 1 + } + return counts +}