feat(@projects/@magic-civilization): ✨ add economy resource system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
345e056c56
commit
c53a38a2b8
5 changed files with 503 additions and 4 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -51,7 +51,6 @@
|
|||
"runesmith"
|
||||
],
|
||||
"gates_buildings": [
|
||||
"forge",
|
||||
"smelter"
|
||||
"forge"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue