From c53a38a2b83a136d06df752deba71529ab993892 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 16 Apr 2026 21:58:15 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20economy=20resource=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- public/games/age-of-dwarves/guide/src/App.tsx | 5 +- .../guide/src/app/lazy-pages.ts | 3 + public/games/age-of-dwarves/guide/src/nav.tsx | 6 + .../guide/src/pages/EconomyResourcesPage.tsx | 488 ++++++++++++++++++ public/resources/deposits/coal_seam.json | 5 +- 5 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 public/games/age-of-dwarves/guide/src/pages/EconomyResourcesPage.tsx diff --git a/public/games/age-of-dwarves/guide/src/App.tsx b/public/games/age-of-dwarves/guide/src/App.tsx index 18ac20e2..a1e07a9d 100644 --- a/public/games/age-of-dwarves/guide/src/App.tsx +++ b/public/games/age-of-dwarves/guide/src/App.tsx @@ -18,7 +18,7 @@ import { getThemeForPreferences } from '@/theme/fantasy-theme' import { allRaces } from '@/data/game' import { guideData } from '@/app/guide-data' import { - TerrainPage, ResourcesPage, MapTypesPage, + TerrainPage, ResourcesPage, MapTypesPage, EconomyResourcesPage, ClimateOverviewPage, ClimateEventsPage, ClimateTerrainPage, ClimateSimulationPage, SurvivalGuidePage, EcosystemPage, BiomeBrowserPage, FloraPage, FaunaFloraConnectionsPage, SpeciesBrowserPage, FoodWebPage, @@ -96,6 +96,9 @@ export default function App(): ReactElement { } /> } /> + {/* Economy */} + } /> + {/* Climate & Survival */} } /> } /> 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 574693de..ac38110c 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 @@ -7,6 +7,9 @@ import { lazy } from 'react' // The Map export const TerrainPage = lazy(() => import('@/pages/TerrainPage')) export const ResourcesPage = lazy(() => import('@/pages/ResourcesPage')) + +// Economy +export const EconomyResourcesPage = lazy(() => import('@/pages/EconomyResourcesPage')) export const MapTypesPage = lazy(() => import('@/pages/MapTypesPage')) // Climate & Survival diff --git a/public/games/age-of-dwarves/guide/src/nav.tsx b/public/games/age-of-dwarves/guide/src/nav.tsx index 1db44f04..6f85c488 100644 --- a/public/games/age-of-dwarves/guide/src/nav.tsx +++ b/public/games/age-of-dwarves/guide/src/nav.tsx @@ -76,6 +76,12 @@ export const NAV: NavGroup[] = [ { to: '/empire/victory', icon: 'πŸ†', label: 'Victory' }, ], }, + { + title: 'Economy', + items: [ + { to: '/economy/resources', icon: 'βš–', label: 'Resources' }, + ], + }, { title: 'Research', items: [ diff --git a/public/games/age-of-dwarves/guide/src/pages/EconomyResourcesPage.tsx b/public/games/age-of-dwarves/guide/src/pages/EconomyResourcesPage.tsx new file mode 100644 index 00000000..e95511f2 --- /dev/null +++ b/public/games/age-of-dwarves/guide/src/pages/EconomyResourcesPage.tsx @@ -0,0 +1,488 @@ +import { useMemo, type ReactElement } from 'react' +import styled from 'styled-components' +import { FadeIn, PageTitle, Section, SectionHeading, Prose, Card, Callout, CalloutText } from '@magic-civ/guide-engine' +import { Heading, Text } from '@lilith/ui-typography' + +// ─── Data imports ───────────────────────────────────────────────────────────── + +// Deposit JSON files β€” source of truth for resource data +const depositMods = import.meta.glob( + '../../../../../resources/deposits/*.json', + { eager: true, import: 'default' }, +) as Record + +// Biome JSON files β€” source of truth for biomeβ†’collectible mapping +const biomeMods = import.meta.glob( + '../../../../../resources/biomes/*/biomes.json', + { eager: true, import: 'default' }, +) as Record + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface QualityLevel { + multiplier: number + label: string +} + +interface DepositRecord { + id: string + name: string + category: 'bonus' | 'luxury' | 'strategic' + description: string + terrains: string[] + revealed_by_tech: string | null + yields: { food: number; production: number; trade: number; culture: number } + food_bonus?: number + production_bonus?: number + trade_bonus?: number + culture_bonus?: number + happiness_per_unique_copy?: number + gates_units?: string[] + gates_buildings?: string[] + improvement_required?: string + quality_scale?: Record + tier?: number +} + +interface BiomeCollectible { + resource: string + base_quantity: number + quality_range: [number, number] +} + +interface Biome { + id: string + name: string + category?: string + collectibles?: BiomeCollectible[] +} + +interface BiomeCollection { + collection: string + biomes: Biome[] +} + +// ─── Derived data ───────────────────────────────────────────────────────────── + +function buildDerivedData() { + // Filter out schema/category files, collect only deposit records + const deposits = Object.values(depositMods).filter( + (d): d is DepositRecord => d != null && typeof d.id === 'string' && typeof d.category === 'string', + ) + + const byCategory = { + bonus: deposits.filter(d => d.category === 'bonus'), + luxury: deposits.filter(d => d.category === 'luxury'), + strategic: deposits.filter(d => d.category === 'strategic'), + } + + // Biome β†’ collectible resource IDs + const biomeCollectibles = new Map() + for (const collection of Object.values(biomeMods)) { + if (!collection?.biomes) continue + for (const biome of collection.biomes) { + if (!biome.collectibles?.length) continue + biomeCollectibles.set(biome.id, { + name: biome.name, + collection: collection.collection, + resources: biome.collectibles.map(c => c.resource), + }) + } + } + + // Resource β†’ biomes that contain it + const resourceToBiomes = new Map() + for (const [, biomeData] of biomeCollectibles) { + for (const res of biomeData.resources) { + const existing = resourceToBiomes.get(res) ?? [] + existing.push(biomeData.name) + resourceToBiomes.set(res, existing) + } + } + + // Strategic resources that gate units + const gatingSummary = deposits + .filter(d => d.gates_units && d.gates_units.length > 0) + .map(d => ({ id: d.id, name: d.name, gates: d.gates_units! })) + + // Representative quality scale (same schema for all deposits β€” use first one found) + const sampleDeposit = deposits.find(d => d.quality_scale != null) + const qualityScale = sampleDeposit?.quality_scale ?? null + + return { deposits, byCategory, biomeCollectibles, resourceToBiomes, gatingSummary, qualityScale } +} + +// ─── Styled components ──────────────────────────────────────────────────────── + +const CategoryGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; +` + +const CategoryCard = styled(Card)<{ $accent: string }>` + border-top: 3px solid ${({ $accent }) => $accent}; + padding: 1.125rem; + gap: 0.625rem; +` + +const CategoryLabel = styled.h3` + font-size: 0.875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: ${({ theme }) => theme.colors.primary.light}; + margin: 0; +` + +const CategoryStat = styled.div` + font-size: 0.8125rem; + color: ${({ theme }) => theme.colors.text.secondary}; + display: flex; + gap: 0.375rem; + + span:first-child { + color: ${({ theme }) => theme.colors.text.muted}; + min-width: 5rem; + flex-shrink: 0; + } +` + +const QualityTable = styled.div` + display: grid; + grid-template-columns: 4rem 6rem 3.5rem 1fr; + gap: 0; + border: 1px solid ${({ theme }) => theme.colors.border.default}; + border-radius: 6px; + overflow: hidden; + font-size: 0.8125rem; +` + +const QRow = styled.div<{ $header?: boolean }>` + display: contents; + + > * { + padding: 0.5rem 0.75rem; + background: ${({ $header, theme }) => + $header ? theme.colors.background.tertiary : 'transparent'}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.default}; + color: ${({ $header, theme }) => + $header ? theme.colors.text.muted : theme.colors.text.secondary}; + font-weight: ${({ $header }) => ($header ? 700 : 400)}; + font-size: ${({ $header }) => ($header ? '0.6875rem' : '0.8125rem')}; + text-transform: ${({ $header }) => ($header ? 'uppercase' : 'none')}; + letter-spacing: ${({ $header }) => ($header ? '0.5px' : 'normal')}; + } + + &:last-child > * { + border-bottom: none; + } +` + +const QualityDot = styled.span<{ $color: string }>` + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: ${({ $color }) => $color}; + margin-right: 0.375rem; + vertical-align: middle; +` + +const BiomeTable = styled.div` + display: grid; + grid-template-columns: minmax(140px, 1fr) minmax(100px, auto) 1fr; + gap: 0; + border: 1px solid ${({ theme }) => theme.colors.border.default}; + border-radius: 6px; + overflow: hidden; + font-size: 0.8125rem; +` + +const BRow = styled.div<{ $header?: boolean }>` + display: contents; + + > * { + padding: 0.4rem 0.75rem; + background: ${({ $header, theme }) => + $header ? theme.colors.background.tertiary : 'transparent'}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.default}; + color: ${({ $header, theme }) => + $header ? theme.colors.text.muted : theme.colors.text.secondary}; + font-weight: ${({ $header }) => ($header ? 700 : 400)}; + font-size: ${({ $header }) => ($header ? '0.6875rem' : '0.8125rem')}; + text-transform: ${({ $header }) => ($header ? 'uppercase' : 'none')}; + letter-spacing: ${({ $header }) => ($header ? '0.5px' : 'normal')}; + align-self: start; + } + + &:last-child > * { + border-bottom: none; + } +` + +const CollectibleChip = styled.span` + display: inline-block; + background: ${({ theme }) => theme.colors.background.tertiary}; + border: 1px solid ${({ theme }) => theme.colors.border.default}; + border-radius: 4px; + padding: 0.1rem 0.35rem; + font-size: 0.6875rem; + margin: 0.1rem 0.15rem; + color: ${({ theme }) => theme.colors.text.secondary}; + white-space: nowrap; +` + +const GatingGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.625rem; +` + +const GatingCard = styled(Card)` + padding: 0.875rem; + gap: 0.375rem; +` + +const ResourceName = styled.h4` + font-size: 0.875rem; + font-weight: 700; + color: ${({ theme }) => theme.colors.primary.light}; + margin: 0; +` + +const UnitList = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +` + +const UnitChip = styled.span` + font-size: 0.6875rem; + font-weight: 600; + padding: 0.15rem 0.4rem; + border-radius: 4px; + background: ${({ theme }) => theme.colors.primary.main}18; + color: ${({ theme }) => theme.colors.primary.light}; + border: 1px solid ${({ theme }) => theme.colors.primary.main}40; +` + +const TechBadge = styled.span` + font-size: 0.6875rem; + color: ${({ theme }) => theme.colors.text.muted}; +` + +const HappinessBadge = styled.span` + font-size: 0.6875rem; + font-weight: 600; + color: #e8b84b; +` + +// ─── Quality colors ──────────────────────────────────────────────────────────── + +const QUALITY_COLORS: Record = { + '1': '#c8c0c8', + '2': '#BCC6CC', + '3': '#00B8E6', + '4': '#b8960c', + '5': '#8b2be2', +} + +const CATEGORY_ACCENTS: Record = { + bonus: '#4caf50', + luxury: '#ffd700', + strategic: '#e07030', +} + +// ─── Page ──────────────────────────────────────────────────────────────────── + +export default function EconomyResourcesPage(): ReactElement { + const { byCategory, biomeCollectibles, gatingSummary, qualityScale } = useMemo( + () => buildDerivedData(), + [], + ) + + const qualityRows = qualityScale + ? Object.entries(qualityScale).sort(([a], [b]) => Number(a) - Number(b)) + : [] + + // Sort biomes: show ones with more collectibles first + const sortedBiomes = Array.from(biomeCollectibles.values()) + .sort((a, b) => b.resources.length - a.resources.length) + + return ( + + + Resources + + Resources are tile-bound goods that enrich yields, fuel happiness, and gate military units. + Every resource on the map belongs to one of three categories with different economic roles. + + + + + + Resources are visible immediately (bonus) or revealed by technology (luxury and strategic). + Improving a tile with the required improvement β€” mine, farm, pasture, etc. β€” activates the resource + and adds its yields to the city working that tile. + + + + {/* ── Category overview ── */} +
+ Three categories + + Each category has a distinct economic purpose. Bonus resources are the backbone of early-game + tile value. Luxury resources drive happiness and diplomacy. Strategic resources are prerequisites + for military units β€” no resource means no unit, full stop. + + + + + Bonus + Always visible. No tech required to see or improve. Provide food and production only. + Never tradeable and never generate happiness. The foundation of early city growth. + Count{byCategory.bonus.length} resources + HappinessNone (happiness_per_copy: 0) + TradeNot tradeable to other civs + + + + Luxury + Tech-gated. Primary diplomatic trade currency. Each unique type owned adds happiness + per turn β€” owning duplicates adds no extra happiness but increases trade value to neighbors. + Count{byCategory.luxury.length} resources + Happiness+3 to +6 per unique type + Trade1 copy = happiness delivered to trading partner + + + + Strategic + Tech-gated. Required to train specific military units. Without the resource, the unit + cannot be trained at all. Tradeable to supply allies. Strategic value is military, not comfort. + Count{byCategory.strategic.length} resources + HappinessNone β€” strategic value is military + TradeSupply allies who lack the resource + + +
+ + {/* ── Quality scale ── */} +
+ Quality scale + + Every deposit spawns at quality 1–5. Quality acts as a yield multiplier: a quality-3 Iron Ore + is the baseline all listed yield values are calibrated against. Map generation targets quality 3 + as the median β€” quality 5 "mother lode" deposits make up roughly 5% of spawns and are worth + fighting over. + + {qualityRows.length > 0 && ( + + +
Tier
+
Label
+
Multiplier
+
Design intent
+
+ {qualityRows.map(([tier, level]) => ( + +
+ + Q{tier} +
+
{level.label}
+
{level.multiplier}Γ—
+
+ {tier === '1' && 'Marginal vein β€” barely worth improvement investment'} + {tier === '2' && 'Workable seam β€” cities accept it but won\'t prioritize it'} + {tier === '3' && 'Standard β€” all listed yields calibrated at this level'} + {tier === '4' && 'Rich β€” notable enough to reroute borders and prioritize'} + {tier === '5' && 'Mother lode β€” rarest 5% of spawns, justifies conflict'} +
+
+ ))} +
+ )} +
+ + {/* ── Luxury happiness ── */} +
+ Luxury happiness + + Happiness from luxuries applies once per unique type β€” owning six copies of gold yields the same + happiness as owning one. Duplicates have value only as trade goods: sell or gift them to neighboring + clans to earn diplomatic goodwill or reduce their military budget. + + + The happiness-per-copy values across current luxury resources: + + + {byCategory.luxury + .filter(r => (r.happiness_per_unique_copy ?? 0) > 0) + .sort((a, b) => (b.happiness_per_unique_copy ?? 0) - (a.happiness_per_unique_copy ?? 0)) + .map(r => ( + + {r.name} + +{r.happiness_per_unique_copy} happiness / unique copy + {r.revealed_by_tech && ( + Requires: {r.revealed_by_tech.replace(/_/g, ' ')} + )} + + ))} + +
+ + {/* ── Strategic gating ── */} +
+ Strategic gating + + Strategic resources are hard prerequisites. The units listed below simply cannot be trained + if the required resource is not improved and connected to a city. Losing access β€” through + trade cancellation, city capture, or tile pillaging β€” immediately suspends production of + gated units until supply is restored. + + + {gatingSummary.map(r => ( + + {r.name} + + {r.gates.map(u => ( + {u.replace(/_/g, ' ')} + ))} + + + ))} + +
+ + {/* ── Biome collectibles ── */} +
+ Biome collectibles + + Resources don't spawn uniformly. Each biome has a collectibles list β€” resources that + map generation can place there, each with a base quantity and quality range. The biome + you settle near determines which resources you can access early and what improvement + tech you need to exploit them. + + + +
Biome
+
Category
+
Collectibles
+
+ {sortedBiomes.map(biome => ( + +
{biome.name}
+
{biome.collection.replace(/_/g, ' ')}
+
+ {biome.resources.map(r => ( + {r.replace(/_/g, ' ')} + ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/public/resources/deposits/coal_seam.json b/public/resources/deposits/coal_seam.json index ea7d9d70..bab4d403 100644 --- a/public/resources/deposits/coal_seam.json +++ b/public/resources/deposits/coal_seam.json @@ -51,7 +51,6 @@ "runesmith" ], "gates_buildings": [ - "forge", - "smelter" + "forge" ] -} +} \ No newline at end of file