feat(@projects/@magic-civilization): add economy resource system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-16 21:58:15 -07:00
parent 345e056c56
commit c53a38a2b8
5 changed files with 503 additions and 4 deletions

View file

@ -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 {
<Route path="/resources" element={<Navigate to="/map/resources" replace />} />
<Route path="/map-types" element={<Navigate to="/map/map-types" replace />} />
{/* Economy */}
<Route path="/economy/resources" element={<EconomyResourcesPage />} />
{/* Climate & Survival */}
<Route path="/climate" element={<ClimateOverviewPage />} />
<Route path="/climate/weather" element={<ClimateEventsPage />} />

View file

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

View file

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

View file

@ -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<string, DepositRecord>
// 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<string, BiomeCollection>
// ─── 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<string, QualityLevel>
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<string, { name: string; collection: string; resources: string[] }>()
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<string, string[]>()
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<string, string> = {
'1': '#c8c0c8',
'2': '#BCC6CC',
'3': '#00B8E6',
'4': '#b8960c',
'5': '#8b2be2',
}
const CATEGORY_ACCENTS: Record<string, string> = {
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 (
<FadeIn duration="fast">
<PageTitle>
<Heading as="h1" size="2xl">Resources</Heading>
<Text color="muted" size="sm">
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.
</Text>
</PageTitle>
<Callout>
<CalloutText>
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.
</CalloutText>
</Callout>
{/* ── Category overview ── */}
<Section>
<SectionHeading>Three categories</SectionHeading>
<Prose>
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.
</Prose>
<CategoryGrid>
<CategoryCard $accent={CATEGORY_ACCENTS.bonus}>
<CategoryLabel>Bonus</CategoryLabel>
<Prose>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.</Prose>
<CategoryStat><span>Count</span>{byCategory.bonus.length} resources</CategoryStat>
<CategoryStat><span>Happiness</span>None (happiness_per_copy: 0)</CategoryStat>
<CategoryStat><span>Trade</span>Not tradeable to other civs</CategoryStat>
</CategoryCard>
<CategoryCard $accent={CATEGORY_ACCENTS.luxury}>
<CategoryLabel>Luxury</CategoryLabel>
<Prose>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.</Prose>
<CategoryStat><span>Count</span>{byCategory.luxury.length} resources</CategoryStat>
<CategoryStat><span>Happiness</span>+3 to +6 per unique type</CategoryStat>
<CategoryStat><span>Trade</span>1 copy = happiness delivered to trading partner</CategoryStat>
</CategoryCard>
<CategoryCard $accent={CATEGORY_ACCENTS.strategic}>
<CategoryLabel>Strategic</CategoryLabel>
<Prose>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.</Prose>
<CategoryStat><span>Count</span>{byCategory.strategic.length} resources</CategoryStat>
<CategoryStat><span>Happiness</span>None strategic value is military</CategoryStat>
<CategoryStat><span>Trade</span>Supply allies who lack the resource</CategoryStat>
</CategoryCard>
</CategoryGrid>
</Section>
{/* ── Quality scale ── */}
<Section>
<SectionHeading>Quality scale</SectionHeading>
<Prose>
Every deposit spawns at quality 15. 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.
</Prose>
{qualityRows.length > 0 && (
<QualityTable>
<QRow $header>
<div>Tier</div>
<div>Label</div>
<div>Multiplier</div>
<div>Design intent</div>
</QRow>
{qualityRows.map(([tier, level]) => (
<QRow key={tier}>
<div>
<QualityDot $color={QUALITY_COLORS[tier] ?? '#888'} />
Q{tier}
</div>
<div>{level.label}</div>
<div>{level.multiplier}×</div>
<div>
{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'}
</div>
</QRow>
))}
</QualityTable>
)}
</Section>
{/* ── Luxury happiness ── */}
<Section>
<SectionHeading>Luxury happiness</SectionHeading>
<Prose>
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.
</Prose>
<Prose>
The happiness-per-copy values across current luxury resources:
</Prose>
<GatingGrid>
{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 => (
<GatingCard key={r.id}>
<ResourceName>{r.name}</ResourceName>
<HappinessBadge>+{r.happiness_per_unique_copy} happiness / unique copy</HappinessBadge>
{r.revealed_by_tech && (
<TechBadge>Requires: {r.revealed_by_tech.replace(/_/g, ' ')}</TechBadge>
)}
</GatingCard>
))}
</GatingGrid>
</Section>
{/* ── Strategic gating ── */}
<Section>
<SectionHeading>Strategic gating</SectionHeading>
<Prose>
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.
</Prose>
<GatingGrid>
{gatingSummary.map(r => (
<GatingCard key={r.id}>
<ResourceName>{r.name}</ResourceName>
<UnitList>
{r.gates.map(u => (
<UnitChip key={u}>{u.replace(/_/g, ' ')}</UnitChip>
))}
</UnitList>
</GatingCard>
))}
</GatingGrid>
</Section>
{/* ── Biome collectibles ── */}
<Section>
<SectionHeading>Biome collectibles</SectionHeading>
<Prose>
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.
</Prose>
<BiomeTable>
<BRow $header>
<div>Biome</div>
<div>Category</div>
<div>Collectibles</div>
</BRow>
{sortedBiomes.map(biome => (
<BRow key={biome.name}>
<div>{biome.name}</div>
<div style={{ textTransform: 'capitalize' }}>{biome.collection.replace(/_/g, ' ')}</div>
<div>
{biome.resources.map(r => (
<CollectibleChip key={r}>{r.replace(/_/g, ' ')}</CollectibleChip>
))}
</div>
</BRow>
))}
</BiomeTable>
</Section>
</FadeIn>
)
}

View file

@ -51,7 +51,6 @@
"runesmith"
],
"gates_buildings": [
"forge",
"smelter"
"forge"
]
}
}