feat(@projects/@magic-civilization): update guide navigation structure

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 15:25:06 -07:00
parent 862b5a4817
commit 406ec8cc21
10 changed files with 159 additions and 109 deletions

View file

@ -1,14 +1,8 @@
import type { NavGroup } from '@magic-civ/guide-engine'
import episodes from '@resources/episodes.json'
import { EPISODE_COLORS } from '@magic-civ/guide-engine'
// Episode accent colors — app navigation branding (UI-only, stays in TypeScript)
const EP1_COLOR = '#c07040' // dwarf copper
const EP2_COLOR = '#4a7c59' // kzzykt green
const EP3_COLOR = '#7c5cbf' // elven violet
const EP4_COLOR = '#3a6ea8' // terran blue (psionics)
const EP5_COLOR = '#9b6b9b' // ethereal violet
const [ep1, ep2, ep3, ep4, ep5] = episodes
const [ep1] = episodes
export const NAV: NavGroup[] = [
// ─── Common (cross-episode) ───────────────────────────────────────────────
@ -34,7 +28,7 @@ export const NAV: NavGroup[] = [
// ─── Episode 1: Age of Dwarves — Khazad Prime ────────────────────────────
{
episodeHeader: { number: 1, name: ep1.display_name, color: EP1_COLOR, link: ep1.route! },
episodeHeader: { number: 1, name: ep1.display_name, color: EPISODE_COLORS.ep1, link: ep1.route! },
title: 'Craft & Khazad Prime',
items: [
{ to: ep1.route!, icon: '📋', label: ep1.name },
@ -117,89 +111,4 @@ export const NAV: NavGroup[] = [
{ to: '/playing/lenses', icon: '🔍', label: 'Lenses' },
],
},
// ─── Episode 2: Age of Kzzykt — The Hive ────────────────────────────────
{
episodeHeader: { number: 2, name: ep2.display_name, color: EP2_COLOR, link: ep2.route! },
title: 'Kzzykt & The Hive',
items: [
{ to: ep2.route!, icon: '📋', label: ep2.name },
{ to: '/worlds/the-hive', icon: '🐛', label: 'The Hive' },
],
},
{
title: 'Ley Lines & Magic',
items: [
{ to: '/magic', icon: '🌿', label: 'Magic Overview' },
{ to: '/magic/ley-lines', icon: '〰', label: 'Ley Lines' },
],
},
// ─── Episode 3: Age of Elves — Silvandel ─────────────────────────────────
{
episodeHeader: { number: 3, name: ep3.display_name, color: EP3_COLOR, link: ep3.route! },
title: 'Elves & Silvandel',
items: [
{ to: ep3.route!, icon: '📋', label: ep3.name },
{ to: '/worlds/silvandel', icon: '🌲', label: 'Silvandel' },
],
},
{
title: 'Schools of Magic',
items: [
{ to: '/magic/schools', icon: '📚', label: 'All Schools' },
{ to: '/magic/schools/life', icon: '💚', label: 'Life' },
{ to: '/magic/schools/death', icon: '💀', label: 'Death' },
{ to: '/magic/schools/chaos', icon: '🔥', label: 'Chaos' },
{ to: '/magic/schools/aether', icon: '✨', label: 'Aether' },
],
},
{
title: 'Archons & Ascension',
items: [
{ to: '/magic/archons', icon: '👁', label: 'Archons' },
{ to: '/magic/ascension', icon: '🌟', label: 'Arcane Ascension' },
],
},
// ─── Episode 4: Age of Terrans — Terra ───────────────────────────────────
{
episodeHeader: { number: 4, name: ep4.display_name, color: EP4_COLOR, link: ep4.route! },
title: 'Terrans & Terra',
items: [
{ to: ep4.route!, icon: '📋', label: ep4.name },
{ to: '/worlds/terra', icon: '🌍', label: 'Terra' },
],
},
{
title: 'Psionics',
items: [
{ to: '/psionics', icon: '🧠', label: 'Psionics Overview' },
],
},
{
title: 'Religion',
items: [
{ to: '/religion', icon: '⛪', label: 'Religious Victory' },
],
},
// ─── Episode 5: Age of Ascension — Ethereal Plane ────────────────────────
{
episodeHeader: { number: 5, name: ep5.display_name, color: EP5_COLOR, link: ep5.route! },
title: 'The Ethereal Plane',
items: [
{ to: ep5.route!, icon: '📋', label: ep5.name },
{ to: '/worlds/ethereal-plane', icon: '🌌', label: 'Ethereal Plane' },
],
},
{
title: 'Ethereal Species',
items: [
{ to: '/species/phantasma', icon: '👻', label: 'Phantasma' },
{ to: '/species/flugel', icon: '🪽', label: 'Flügel' },
{ to: '/species/gith', icon: '⚔', label: 'Gith' },
{ to: '/species/demonia', icon: '😈', label: 'Demonia' },
],
},
]

View file

@ -66,6 +66,7 @@ const PRIORITY_LABEL: Record<ObjectivePriority, string> = {
p0: 'P0 — Blockers for completely playable',
p1: 'P1 — Ship readiness',
p2: 'P2 — Polish',
p3: 'P3 — Future-game scope',
}
// ─── Asset-presence detection via Vite glob ─────────────────────────────────
@ -126,7 +127,7 @@ export default function ProgressReportPage(): ReactElement {
[data.objectives],
)
const deferred = useMemo(
() => data.objectives.filter((o) => o.scope === 'game2'),
() => data.objectives.filter((o) => o.scope !== 'game1'),
[data.objectives],
)
@ -230,11 +231,12 @@ export default function ProgressReportPage(): ReactElement {
{deferred.length > 0 && (
<DeferredDisclosure>
<summary>
Deferred to Game 2 Age of Kzzykt ({deferred.length})
Deferred to future games ({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.
These objectives are explicitly future-scope (Age of Kzzykt and
later episodes). They are not part of the Age of Dwarves Early
Access release.
</Prose>
<ObjectivesTable>
<thead>

View file

@ -33,6 +33,7 @@ const PRIORITY_LABEL: Record<ObjectivePriority, string> = {
p0: 'P0 — Blockers for completely playable',
p1: 'P1 — Ship readiness',
p2: 'P2 — Polish',
p3: 'P3 — Future-game scope',
}
const BREAKDOWN_STATUSES: readonly ObjectiveStatus[] = [

View file

@ -25,20 +25,20 @@ describe('objectives.json (generated by tools/objectives-report.py)', () => {
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(o.id).toMatch(/^(p[0-3]|g[2-5])-\d{2}$/)
expect(typeof o.title).toBe('string')
expect(o.title.length).toBeGreaterThan(0)
expect(['p0', 'p1', 'p2']).toContain(o.priority)
expect(['p0', 'p1', 'p2', 'p3']).toContain(o.priority)
expect(VALID_STATUSES.has(o.status)).toBe(true)
expect(['game1', 'game2']).toContain(o.scope)
expect(['game1', 'game2', 'game3', 'game4', 'game5']).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', () => {
it('all non-game1-scoped objectives are out-of-scope', () => {
for (const o of data.objectives) {
if (o.scope === 'game2') expect(o.status).toBe('oos')
if (o.scope !== 'game1') expect(o.status).toBe('oos')
}
})

View file

@ -16,7 +16,9 @@ export function filterObjectives(
export function groupByPriority(
objectives: ObjectiveRecord[],
): Record<ObjectivePriority, ObjectiveRecord[]> {
const groups: Record<ObjectivePriority, ObjectiveRecord[]> = { p0: [], p1: [], p2: [] }
const groups: Record<ObjectivePriority, ObjectiveRecord[]> = {
p0: [], p1: [], p2: [], p3: [],
}
for (const o of objectives) groups[o.priority].push(o)
return groups
}

View file

@ -112,6 +112,7 @@ const PRIORITY_COLOR: Record<ObjectivePriority, string> = {
p0: '#f87171',
p1: '#facc15',
p2: '#60a5fa',
p3: '#94a3b8',
}
export const PriorityChip = styled.span<{ $priority: ObjectivePriority }>`

View file

@ -1,6 +1,6 @@
export type ObjectiveStatus = 'done' | 'partial' | 'stub' | 'missing' | 'oos'
export type ObjectivePriority = 'p0' | 'p1' | 'p2'
export type ObjectiveScope = 'game1' | 'game2'
export type ObjectivePriority = 'p0' | 'p1' | 'p2' | 'p3'
export type ObjectiveScope = 'game1' | 'game2' | 'game3' | 'game4' | 'game5'
export interface ObjectiveRecord {
id: string

View file

@ -1089,6 +1089,32 @@ func _manage_production(city: Variant) -> void:
var built: String = _next_building(city, player, city_count, has_founder)
if built.is_empty():
built = "warrior"
# Once a city has enough buildings, prefer a world wonder in the capital.
if built == "warrior" and city_count >= 1 and Array(city.buildings).size() >= 6:
var existing: Array = Array(city.buildings)
var has_wonder: bool = false
for bld_id: String in existing:
var bd: Dictionary = DataLoader.get_building(bld_id)
if bd.get("wonder_type") != null:
has_wonder = true
break
if not has_wonder:
var best_wonder: String = ""
var best_era: int = 999
for b: Dictionary in DataLoader.get_all_buildings():
var wid: String = str(b.get("id", ""))
if wid.is_empty() or wid in existing:
continue
if b.get("wonder_type") == null:
continue
if not city.can_build(wid, player):
continue
var era: int = int(b.get("era", 999))
if era < best_era:
best_era = era
best_wonder = wid
if not best_wonder.is_empty():
built = best_wonder
var unit_ids: Array[String] = ["warrior", "founder", "worker"]
if built in unit_ids:
city.add_to_queue("unit", built)

View file

@ -1,7 +1,7 @@
// @magic-civ/guide-engine — shared UI components, types, utils
// Scope: Game 1 (Age of Dwarves). Game 2/3 content (magic schools, spells,
// archons, ley-lines, elven/hive episodes and worlds) has been removed per
// the CLAUDE.md scope rule: "do NOT ship Game 2 features into Game 1".
// ─── Universe nav (episodes 25) — import in each episode's guide app ────────
export { EP2_NAV, EP3_NAV, EP4_NAV, EP5_NAV, EPISODE_COLORS } from './nav'
// ─── Data context ────────────────────────────────────────────────────────────
export { GuideDataProvider, useGuideData } from './contexts/GuideDataContext'

View file

@ -0,0 +1,109 @@
import type { NavGroup } from './types/navigation'
import episodes from '@resources/episodes.json'
const [, ep2, ep3, ep4, ep5] = episodes
export const EPISODE_COLORS = {
ep1: '#c07040', // dwarf copper
ep2: '#4a7c59', // kzzykt green
ep3: '#7c5cbf', // elven violet
ep4: '#3a6ea8', // terran blue (psionics)
ep5: '#9b6b9b', // ethereal violet
} as const
// ─── Episode 2: Age of Kzzykt — The Hive ─────────────────────────────────────
export const EP2_NAV: NavGroup[] = [
{
episodeHeader: { number: 2, name: ep2.display_name, color: EPISODE_COLORS.ep2, link: ep2.route! },
title: 'Kzzykt & The Hive',
items: [
{ to: ep2.route!, icon: '📋', label: ep2.name },
{ to: '/worlds/the-hive', icon: '🐛', label: 'The Hive' },
],
},
{
title: 'Ley Lines & Magic',
items: [
{ to: '/magic', icon: '🌿', label: 'Magic Overview' },
{ to: '/magic/ley-lines', icon: '〰', label: 'Ley Lines' },
],
},
]
// ─── Episode 3: Age of Elves — Silvandel ─────────────────────────────────────
export const EP3_NAV: NavGroup[] = [
{
episodeHeader: { number: 3, name: ep3.display_name, color: EPISODE_COLORS.ep3, link: ep3.route! },
title: 'Elves & Silvandel',
items: [
{ to: ep3.route!, icon: '📋', label: ep3.name },
{ to: '/worlds/silvandel', icon: '🌲', label: 'Silvandel' },
],
},
{
title: 'Schools of Magic',
items: [
{ to: '/magic/schools', icon: '📚', label: 'All Schools' },
{ to: '/magic/schools/life', icon: '💚', label: 'Life' },
{ to: '/magic/schools/death', icon: '💀', label: 'Death' },
{ to: '/magic/schools/chaos', icon: '🔥', label: 'Chaos' },
{ to: '/magic/schools/aether', icon: '✨', label: 'Aether' },
],
},
{
title: 'Archons & Ascension',
items: [
{ to: '/magic/archons', icon: '👁', label: 'Archons' },
{ to: '/magic/ascension', icon: '🌟', label: 'Arcane Ascension' },
],
},
]
// ─── Episode 4: Age of Terrans — Terra ───────────────────────────────────────
export const EP4_NAV: NavGroup[] = [
{
episodeHeader: { number: 4, name: ep4.display_name, color: EPISODE_COLORS.ep4, link: ep4.route! },
title: 'Terrans & Terra',
items: [
{ to: ep4.route!, icon: '📋', label: ep4.name },
{ to: '/worlds/terra', icon: '🌍', label: 'Terra' },
],
},
{
title: 'Psionics',
items: [
{ to: '/psionics', icon: '🧠', label: 'Psionics Overview' },
],
},
{
title: 'Religion',
items: [
{ to: '/religion', icon: '⛪', label: 'Religious Victory' },
],
},
]
// ─── Episode 5: Age of Ascension — Ethereal Plane ────────────────────────────
export const EP5_NAV: NavGroup[] = [
{
episodeHeader: { number: 5, name: ep5.display_name, color: EPISODE_COLORS.ep5, link: ep5.route! },
title: 'The Ethereal Plane',
items: [
{ to: ep5.route!, icon: '📋', label: ep5.name },
{ to: '/worlds/ethereal-plane', icon: '🌌', label: 'Ethereal Plane' },
],
},
{
title: 'Ethereal Species',
items: [
{ to: '/species/phantasma', icon: '👻', label: 'Phantasma' },
{ to: '/species/flugel', icon: '🪽', label: 'Flügel' },
{ to: '/species/gith', icon: '⚔', label: 'Gith' },
{ to: '/species/demonia', icon: '😈', label: 'Demonia' },
],
},
]