feat(@projects/@magic-civilization): add progress report page navigation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 12:10:36 -07:00
parent bedf5e3c41
commit c110b83db5
9 changed files with 683 additions and 4 deletions

View file

@ -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": [

View file

@ -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 */}
<Route path="/about/early-access" element={<EarlyAccessPage />} />
<Route path="/about/early-access/progress" element={<EarlyAccessProgressPage />} />
<Route path="/progress" element={<ProgressReportPage />} />
<Route path="/about/crowdfund" element={<CrowdfundPage />} />
<Route path="/about/team" element={<TeamPage />} />
<Route path="/crowdfund" element={<Navigate to="/about/crowdfund" replace />} />

View file

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

View file

@ -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' },

View file

@ -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<ObjectivePriority, string> = {
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<UnitJson[]>('@data/units/*.json', { eager: true, import: 'default' })
const buildingFileGlob = import.meta.glob<BuildingJson[]>('@data/buildings/*.json', { eager: true, import: 'default' })
function useAssetGroups(): ReturnType<typeof buildAssetGroups> {
return useMemo(() => buildAssetGroups(), [])
}
function buildAssetGroups(): {
audio: ReturnType<typeof buildAssetGroup>
unitSprites: ReturnType<typeof buildAssetGroup>
buildingSprites: ReturnType<typeof buildAssetGroup>
} {
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<StatusFilter>('all')
const [activeObjective, setActiveObjective] = useState<ObjectiveRecord | null>(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 (
<FadeIn>
<PageTitle>
<PageHeading>Progress Report</PageHeading>
<PageSubtitle>
Live view of the objectives dashboard and the asset pipeline. Generated
from <code>.project/objectives/</code> frontmatter on every{' '}
<code>./run verify</code> {data.totals.total} objectives tracked.
</PageSubtitle>
</PageTitle>
<Section>
<SectionHeading>Overall Status</SectionHeading>
<TotalsGrid>
{(['done', 'partial', 'stub', 'missing', 'oos'] as ObjectiveStatus[]).map((s) => (
<TotalCard key={s}>
<TotalValue>
<span aria-hidden="true">{STATUS_ICON[s]}</span> {data.totals[s]}
</TotalValue>
<TotalLabel>{STATUS_LABEL[s]}</TotalLabel>
</TotalCard>
))}
<TotalCard>
<TotalValue>{data.totals.total}</TotalValue>
<TotalLabel>Total</TotalLabel>
</TotalCard>
</TotalsGrid>
{(['p0', 'p1', 'p2'] as ObjectivePriority[]).map((p) =>
renderPriorityBar(p, inScope),
)}
</Section>
<Section>
<SectionHeading>Objectives</SectionHeading>
<FilterRow>
<FilterChip type="button" $active={filter === 'all'} onClick={() => setFilter('all')}>
All ({inScope.length})
</FilterChip>
<FilterChip type="button" $active={filter === 'p0'} onClick={() => setFilter('p0')}>
P0 only
</FilterChip>
<FilterChip type="button" $active={filter === 'partial'} onClick={() => setFilter('partial')}>
Partial only
</FilterChip>
<FilterChip type="button" $active={filter === 'missing'} onClick={() => setFilter('missing')}>
Missing / stub
</FilterChip>
</FilterRow>
{(['p0', 'p1', 'p2'] as ObjectivePriority[]).map((p) => {
const rows = groupedByPriority[p]
if (rows.length === 0) return null
return (
<div key={p}>
<SectionHeading style={{ fontSize: '0.8125rem', marginTop: '1.25rem' }}>
{PRIORITY_LABEL[p]} ({rows.length})
</SectionHeading>
<ObjectivesTable>
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Title</th>
<th>Owner</th>
</tr>
</thead>
<tbody>
{rows.map((o) => (
<ObjectiveRow
key={o.id}
$clickable
onClick={() => setActiveObjective(o)}
>
<IdCell>{o.id.toUpperCase()}</IdCell>
<td>
<StatusBadge $status={o.status}>
<span aria-hidden="true">{STATUS_ICON[o.status]}</span>
{STATUS_LABEL[o.status]}
</StatusBadge>
</td>
<TitleCell>{o.title}</TitleCell>
<OwnerCell>{o.owner ?? '—'}</OwnerCell>
</ObjectiveRow>
))}
</tbody>
</ObjectivesTable>
</div>
)
})}
{deferred.length > 0 && (
<DeferredDisclosure>
<summary>
Deferred to Game 2 Age of Kzzykt ({deferred.length})
</summary>
<Prose style={{ marginTop: '0.75rem' }}>
These objectives are explicitly future-scope. They are not part of
the Age of Dwarves Early Access release.
</Prose>
<ObjectivesTable>
<thead>
<tr>
<th>ID</th>
<th>Priority</th>
<th>Title</th>
</tr>
</thead>
<tbody>
{deferred.map((o) => (
<ObjectiveRow
key={o.id}
$clickable
onClick={() => setActiveObjective(o)}
>
<IdCell>{o.id.toUpperCase()}</IdCell>
<td>
<PriorityChip $priority={o.priority}>
{o.priority.toUpperCase()}
</PriorityChip>
</td>
<TitleCell>{o.title}</TitleCell>
</ObjectiveRow>
))}
</tbody>
</ObjectivesTable>
</DeferredDisclosure>
)}
</Section>
<Section>
<SectionHeading>Asset Pipeline</SectionHeading>
<Prose>
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.
</Prose>
{[assetGroups.audio, assetGroups.unitSprites, assetGroups.buildingSprites].map(
(g) => (
<AssetGroupBlock key={g.label}>
<AssetGroupHeader>
<span>{g.label}</span>
<AssetGroupSummaryText>
{g.present}/{g.total} present
</AssetGroupSummaryText>
</AssetGroupHeader>
<AssetProgressTrack>
<AssetProgressFill $pct={g.total === 0 ? 0 : (g.present / g.total) * 100} />
</AssetProgressTrack>
<AssetList>
{g.items.map((item) => (
<AssetItem
key={item.expectedPath}
$present={item.present}
title={item.expectedPath}
>
{item.label}
</AssetItem>
))}
</AssetList>
</AssetGroupBlock>
),
)}
</Section>
<Meta>Objectives generated {formatDate(data.generated_at)}.</Meta>
{activeObjective && (
<ObjectiveModal
objective={activeObjective}
onClose={() => setActiveObjective(null)}
/>
)}
</FadeIn>
)
}
function renderPriorityBar(priority: ObjectivePriority, objectives: ObjectiveRecord[]): ReactElement | null {
const counts = countStatusesForPriority(objectives, priority)
if (counts.total === 0) return null
const { total } = counts
return (
<PriorityBar key={priority}>
<PriorityBarHeader>
<span>
<PriorityChip $priority={priority}>{priority.toUpperCase()}</PriorityChip>{' '}
{PRIORITY_LABEL[priority].replace(/^p[012] — /, '')}
</span>
<span>
{counts.done}/{total} done
</span>
</PriorityBarHeader>
<PriorityBarTrack>
{(['done', 'partial', 'stub', 'missing'] as ObjectiveStatus[]).map((s) =>
counts[s] > 0 ? (
<PriorityBarSegment
key={s}
$status={s}
$pct={(counts[s] / total) * 100}
/>
) : null,
)}
</PriorityBarTrack>
</PriorityBar>
)
}
function formatDate(iso: string): string {
if (!iso) return 'unknown'
try {
return new Date(iso).toISOString().replace('T', ' ').replace('Z', ' UTC')
} catch {
return iso
}
}

View file

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

View file

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

View file

@ -0,0 +1,49 @@
import objectivesData from '@data/objectives.json'
import type { ObjectivesExport, ObjectiveStatus } from '@/pages/progress-report/types'
const VALID_STATUSES: ReadonlySet<ObjectiveStatus> = new Set<ObjectiveStatus>([
'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)
})
})

View file

@ -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<ObjectivePriority, ObjectiveRecord[]> {
const groups: Record<ObjectivePriority, ObjectiveRecord[]> = { 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
}