Add trade economy engine, credits currency, and goods catalog
- New Economy.js price/stock engine: deterministic buy/sell prices based on producer/demand classes and hop distance (regional availability model) - Goods catalog (114 goods, 9 categories) with spritesheet mapping and per-class producer/demand roles - Credits state on Ship (add/spend/set/canAfford), save/restore support, and upper-right HUD stacked above the minerals readout - Quest reward payout now supports credits alongside minerals - cargoHold reduced to 4 units for initial play balance - Economy generation pass in Galaxy.js computes classBySystem and producerHops via multi-source BFS over the jump network - dev/economy.test.mjs validates determinism, fixed prices, trade loop profitability, distance scaling, shop floor composition, and icon mapping - docs/ECONOMY.md: full design doc covering pricing model, gemstone system, and locked decisions
This commit is contained in:
parent
63ea50928d
commit
5f54cc327e
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Binary file not shown.
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"_comment": "CREDITS readout — the trade-currency line, UPPER-RIGHT, stacked ABOVE the minerals HUD (data/mineralhud.json). One right-aligned line: 'CREDITS n'. The number counts up when credits land. Gold (theme 'amber') reads as value and stays distinct from the cyan minerals. `gap` is the space left BELOW this line — the scene shifts the minerals HUD down by (label height + gap) so the two read as one console (the comms box anchor follows the minerals bar, so it keeps clear of both).",
|
||||
"pad": 16,
|
||||
"gap": 8,
|
||||
"label": {
|
||||
"fontSize": 12,
|
||||
"letterSpacing": 1
|
||||
},
|
||||
"color": "#ffc94d",
|
||||
"countUp": {
|
||||
"durationMs": 420,
|
||||
"ease": "Sine.easeOut"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"_comment": "ECONOMY TUNING (the knobs, not the data). goods.json is WHAT exists; this file is HOW it prices and stocks. Everything here is a tunable number so you can rebalance the whole economy without touching code or the catalog. MODEL (full version in docs/ECONOMY.md §5): for a good at a place there are TWO FIXED prices in credits — a BUY price (cheap where the place's class produces the good) and a SELL price (a premium where the class wants it, growing with the hops between this system and the good's home region). Prices are fixed per (good, location), decided once at generation, and never depend on where the player bought the good or what's in their hold. Distance is enforced by REGIONAL AVAILABILITY (goods only cheap in their home region), not a live per-trade multiplier. Credits are the go-between — buying spends them, selling earns them, no barter.",
|
||||
"shop": {
|
||||
"_comment": "Stock size per settlement kind. `station` and `capital` get wider stock (more variety to resell) than a single world.",
|
||||
"minStock": 8,
|
||||
"maxStock": 16,
|
||||
"stockByKind": {
|
||||
"colony": { "min": 8, "max": 12 },
|
||||
"miningStation":{ "min": 8, "max": 12 },
|
||||
"cloudBase": { "min": 8, "max": 12 },
|
||||
"capital": { "min": 12, "max": 16 },
|
||||
"deepSpaceStation": { "min": 10, "max": 16 }
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"_comment": "All multipliers are dimensionless and multiply baseValue. BUY price = baseValue * (1 - producerDiscount if the place's class produces the good) * jitter. SELL price = baseValue * (1 + demandBonusPerClass * #demand-classes) * (1 + distancePerHop * min(hops, cap)) * jitter. `jitter` is a per-good, per-place seeded wobble (±%) so no two shops are identical.",
|
||||
"demandBonusPerClass": 0.12,
|
||||
"producerDiscount": 0.35,
|
||||
"distancePerHop": 0.22,
|
||||
"distanceCapHops": 6,
|
||||
"jitter": 0.10
|
||||
},
|
||||
"cargo": {
|
||||
"_comment": "Cargo capacity is the ship's hold (ship.stats.cargoHold = 100). Decision #2: each good occupies 1 hold unit (unitPerGood=1) — the simplest-to-manage option. You CARRY goods between shops; that's the whole game loop.",
|
||||
"capacitySource": "ship.stats.cargoHold",
|
||||
"unitPerGood": 1
|
||||
},
|
||||
"credits": {
|
||||
"_comment": "The trade currency (the go-between). Displayed next to the minerals HUD (upper-right). Player starts at 0 (startingCredits). Trading is LOCKED until the third story quest (not yet written); until then first credits come from quest rewards and, later, asteroid gemstone mining. Buying spends credits; selling earns them.",
|
||||
"startingCredits": 0,
|
||||
"display": { "nextTo": "mineralHud", "anchor": "upper-right" },
|
||||
"tradingLockedUntilQuest": 3,
|
||||
"earning": ["sellGoods", "questRewards", "miningLaserGems (later)"]
|
||||
},
|
||||
"restock": {
|
||||
"_comment": "Shops are NON-persistent. When the player leaves and later returns, the shop re-derives its stock from the seed — the same goods and prices every time, so there is no inventory to track and no 'you just bought out their stock' bookkeeping. The engine treats a shop's stock as a pure function of (seed, place, economy version).",
|
||||
"policy": "deterministic-derivation",
|
||||
"stockIsPureFunction": true,
|
||||
"note": "No per-player sold/bought counters are stored. Revisiting a shop yields identical stock & prices."
|
||||
},
|
||||
"reputation": {
|
||||
"_comment": "DEFERRED (decision #3 — wanted, later). Hook: standing with a place's owner faction nudges prices (good standing = better deal). `maxPriceShift` is the max +/- fraction of price. Wired to js/reputation/Reputation.js standingFor(place). OFF for v1.",
|
||||
"enabled": false,
|
||||
"maxPriceShift": 0.08
|
||||
},
|
||||
"faction": {
|
||||
"_comment": "DEFERRED (decision #3). Hook: some factions may levy a tariff or patron a good class. `tariffRange` is the +/- fraction. `patronGoods` (a future map of factionId -> [goodId]) lets a faction pay premium for its favored goods. Left empty — populate when factions take an economic role.",
|
||||
"tariffRange": 0.05,
|
||||
"patronGoods": {}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
{
|
||||
"_comment": "TRADE GOODS CATALOG (the economy's inventory of things that exist in the universe). This is DATA, not code — the shop UI (later) renders it, the generator prices it (data/economy.json), and the spritesheet (`sheet` below + assets/images/goods.png) maps an `icon` frame per id. Every good has: `id` (stable key), `name`, `category` (groups it in the shop UI — see `categories`), `tier` (1 basic … 4 exotic, drives price band + how far it should travel), `baseValue` (credits, the good's intrinsic worth before local/distance factors), `producer` (economic class(es) that naturally make it — cheap to BUY there), `demand` (class(es) that want it — good to SELL there), `weight` (1 rare … 5 common, how often it appears in a shop's 8–16 stock and how plentiful), `flavor` (one-line shop-keep description), `icon` (the good frame index on the spritesheet — see `sheet`). Economic classes: terran / arid / rocky / gas / ice / lava (planet classes, data/planets.json) + station (deep-space station) + capital (a faction seat). The opposition web the design leans on: ice sells cool to lava while lava sells heat to ice; terran sells water/food to arid while arid sells salt/solar/spice to terran; gas sells fuel to rocky while rocky sells metal to gas. Every class also yields a signature GEMSTONE (category gemstone) that other classes covet. NOTE: this is a DRAFT catalog — names/values are placeholders until you weigh in.",
|
||||
"classes": {
|
||||
"terran": { "label": "Terran World", "economy": "temperate biosphere — food, water, living things" },
|
||||
"arid": { "label": "Arid World", "economy": "dry sun-world — salt, silica, solar, spice" },
|
||||
"rocky": { "label": "Rocky World", "economy": "mineral-rich — ores, metal, stone" },
|
||||
"gas": { "label": "Gas Giant", "economy": "volatile atmosphere — fuel, gases, isotopes" },
|
||||
"ice": { "label": "Ice World", "economy": "frozen — water, cryo, volatiles" },
|
||||
"lava": { "label": "Lava World", "economy": "molten — heat, rare metals, volcanic glass" },
|
||||
"station": { "label": "Deep-Space Station", "economy": "adrift refiner — processed goods, services" },
|
||||
"capital": { "label": "Capital World", "economy": "a faction's seat — luxury, finance, high-end tech" }
|
||||
},
|
||||
"categories": {
|
||||
"food": { "label": "Food & Agriculture", "order": 1 },
|
||||
"water": { "label": "Water & Cryo", "order": 2 },
|
||||
"energy": { "label": "Energy & Fuel", "order": 3 },
|
||||
"metals": { "label": "Ores & Metals", "order": 4 },
|
||||
"industrial": { "label": "Refined & Industrial","order": 5 },
|
||||
"tech": { "label": "Technology", "order": 6 },
|
||||
"pharma": { "label": "Pharma & Biotech", "order": 7 },
|
||||
"luxury": { "label": "Luxury & Exotic", "order": 8 },
|
||||
"gemstone": { "label": "Gemstones", "order": 9 }
|
||||
},
|
||||
"sheet": {
|
||||
"_comment": "The goods spritesheet (assets/images/goods.png) — 128×128 icon frames, 12 across (1536×1280 = 120 cells), read left→right then top→bottom. Frame index N = the Nth entry in goods below (each also carries its index in icon — set that field to reflow the sheet without moving entries). Cell for frame N: col = N % 12, row = floor(N / 12), top-left at (col·128, row·128). Categories are contiguous blocks: food 0–13 · water 14–21 · energy 22–35 · metals 36–53 · industrial 54–67 · tech 68–85 · pharma 86–95 · luxury 96–105 · gemstone 106–113 (the 8 signature gems sit in the bottom-right corner).",
|
||||
"texture": "assets/images/goods.png",
|
||||
"frameWidth": 128,
|
||||
"frameHeight": 128,
|
||||
"cols": 12,
|
||||
"rows": 10
|
||||
},
|
||||
"goods": [
|
||||
{ "id": "fresh-produce", "name": "Fresh Produce", "category": "food", "tier": 1, "baseValue": 12, "producer": ["terran"], "demand": ["rocky","gas","ice","lava","station"], "weight": 5, "flavor": "Crisp, clean, alive. A small miracle off-world.", "icon": 0 },
|
||||
{ "id": "grain-crates", "name": "Grain Crates", "category": "food", "tier": 1, "baseValue": 10, "producer": ["terran"], "demand": ["rocky","gas","ice","lava","station"], "weight": 5, "flavor": "Bulk feed. No one romanticizes a crate of grain.", "icon": 1 },
|
||||
{ "id": "cured-livestock", "name": "Cured Livestock", "category": "food", "tier": 1, "baseValue": 15, "producer": ["terran"], "demand": ["rocky","gas","ice","lava"], "weight": 3, "flavor": "Smoked and salted to survive the jump.", "icon": 2 },
|
||||
{ "id": "terran-bread", "name": "Terran Bread", "category": "food", "tier": 1, "baseValue": 8, "producer": ["terran"], "demand": ["rocky","gas","ice","lava","station"], "weight": 5, "flavor": "Warm if you're quick. A comfort you'd miss.", "icon": 3 },
|
||||
{ "id": "arid-spices", "name": "Arid Spice Blend", "category": "food", "tier": 2, "baseValue": 26, "producer": ["arid"], "demand": ["terran","capital","station","rocky"], "weight": 3, "flavor": "Sun-baked heat that makes everything taste like the desert.", "icon": 4 },
|
||||
{ "id": "cactus-pulp", "name": "Cactus Pulp", "category": "food", "tier": 1, "baseValue": 10, "producer": ["arid"], "demand": ["terran","ice","lava"], "weight": 3, "flavor": "Fibrous, sweet, stubborn. Like the planet.", "icon": 5 },
|
||||
{ "id": "salted-crystals", "name": "Salted Crystals", "category": "food", "tier": 1, "baseValue": 12, "producer": ["arid"], "demand": ["terran","gas","ice","station"], "weight": 3, "flavor": "Mineral-dense. Technically food, technically not.", "icon": 6 },
|
||||
{ "id": "desert-dates", "name": "Desert Dates", "category": "food", "tier": 1, "baseValue": 11, "producer": ["arid"], "demand": ["terran","rocky","station"], "weight": 4, "flavor": "A little sweetness from a hard place.", "icon": 7 },
|
||||
{ "id": "kelp-fronds", "name": "Kelp Fronds", "category": "food", "tier": 1, "baseValue": 12, "producer": ["ice"], "demand": ["terran","gas","rocky"], "weight": 3, "flavor": "Slippery and cold. A delicacy in some cultures.", "icon": 8 },
|
||||
{ "id": "fungal-bloom", "name": "Fungal Bloom", "category": "food", "tier": 1, "baseValue": 14, "producer": ["ice","rocky"], "demand": ["terran","gas","station"], "weight": 3, "flavor": "Grows in the dark, feeds on the dark.", "icon": 9 },
|
||||
{ "id": "cloud-protein", "name": "Cloud Protein", "category": "food", "tier": 2, "baseValue": 28, "producer": ["gas"], "demand": ["rocky","terran","station","lava"], "weight": 3, "flavor": "Harvested from the upper clouds. Tastes like altitude.", "icon": 10 },
|
||||
{ "id": "algal-cakes", "name": "Algal Cakes", "category": "food", "tier": 1, "baseValue": 9, "producer": ["ice","terran"], "demand": ["rocky","gas","lava"], "weight": 4, "flavor": "Staple of long voyages. Unremarkable. Saving.", "icon": 11 },
|
||||
{ "id": "exotic-fruit", "name": "Exotic Fruit", "category": "food", "tier": 2, "baseValue": 30, "producer": ["terran"], "demand": ["capital","station"], "weight": 2, "flavor": "So unusual most off-worlders are afraid to bite it.", "icon": 12 },
|
||||
{ "id": "terran-herbs", "name": "Terran Herbs", "category": "food", "tier": 2, "baseValue": 26, "producer": ["terran"], "demand": ["capital","station","lava"], "weight": 2, "flavor": "Culinary medicine from a mild world.", "icon": 13 },
|
||||
|
||||
{ "id": "pure-water", "name": "Pure Water", "category": "water", "tier": 1, "baseValue": 10, "producer": ["ice","terran"], "demand": ["lava","arid","gas","rocky","station"], "weight": 5, "flavor": "The most valuable commodity, most taken for granted.", "icon": 14 },
|
||||
{ "id": "ice-blocks", "name": "Ice Blocks", "category": "water", "tier": 1, "baseValue": 9, "producer": ["ice"], "demand": ["lava","arid","gas"], "weight": 4, "flavor": "Solid calm. Keep it cold.", "icon": 15 },
|
||||
{ "id": "coolant-fluid", "name": "Coolant Fluid", "category": "water", "tier": 2, "baseValue": 24, "producer": ["ice"], "demand": ["lava","gas","rocky","station"], "weight": 3, "flavor": "The reason a reactor doesn't boil itself over.", "icon": 16 },
|
||||
{ "id": "cryo-compound", "name": "Cryo Compound", "category": "water", "tier": 2, "baseValue": 30, "producer": ["ice"], "demand": ["lava","gas","station","rocky"], "weight": 3, "flavor": "Engineered to hold heat in check.", "icon": 17 },
|
||||
{ "id": "deisot-water", "name": "De-Isotopized Water", "category": "water", "tier": 2, "baseValue": 26, "producer": ["ice"], "demand": ["gas","station","lava"], "weight": 3, "flavor": "Purified beyond what a desert world can dream of.", "icon": 18 },
|
||||
{ "id": "frozen-organics", "name": "Frozen Organics", "category": "water", "tier": 2, "baseValue": 28, "producer": ["ice"], "demand": ["terran","lava","station"], "weight": 2, "flavor": "A biosphere, preserved.", "icon": 19 },
|
||||
{ "id": "liq-helium", "name": "Liquefied Helium", "category": "water", "tier": 2, "baseValue": 32, "producer": ["ice"], "demand": ["gas","station","lava"], "weight": 2, "flavor": "Rarer than it looks, colder than it should be.", "icon": 20 },
|
||||
{ "id": "brine-solution", "name": "Brine Solution", "category": "water", "tier": 1, "baseValue": 12, "producer": ["ice","rocky"], "demand": ["lava","arid","station"], "weight": 3, "flavor": "Salty, dense, and useful in more ways than one.", "icon": 21 },
|
||||
|
||||
{ "id": "refined-fuel", "name": "Refined Fuel", "category": "energy", "tier": 1, "baseValue": 14, "producer": ["gas"], "demand": ["rocky","ice","terran","lava","station","arid"], "weight": 5, "flavor": "The lifeblood of the fleet.", "icon": 22 },
|
||||
{ "id": "hydrogen-cells", "name": "Hydrogen Cells", "category": "energy", "tier": 2, "baseValue": 26, "producer": ["gas"], "demand": ["rocky","ice","terran","station","lava","arid"], "weight": 4, "flavor": "Light, bright, and slightly dangerous.", "icon": 23 },
|
||||
{ "id": "helium3", "name": "Helium-3", "category": "energy", "tier": 3, "baseValue": 85, "producer": ["gas"], "demand": ["station","capital","rocky","lava"], "weight": 2, "flavor": "The cleanest fire, and the hardest to get.", "icon": 24 },
|
||||
{ "id": "ammonia-derivs", "name": "Ammonia Derivatives", "category": "energy", "tier": 2, "baseValue": 22, "producer": ["gas"], "demand": ["rocky","lava","station"], "weight": 3, "flavor": "The pungent workhorse of gas worlds.", "icon": 25 },
|
||||
{ "id": "noble-gases", "name": "Noble Gas Mix", "category": "energy", "tier": 2, "baseValue": 42, "producer": ["gas"], "demand": ["station","lava","capital","rocky"], "weight": 3, "flavor": "Inert, expensive, and absolutely essential.", "icon": 26 },
|
||||
{ "id": "volcanic-energy", "name": "Volcanic Energy Cells", "category": "energy", "tier": 2, "baseValue": 34, "producer": ["lava"], "demand": ["ice","rocky","station","gas"], "weight": 3, "flavor": "Bottled fury from a molten world.", "icon": 27 },
|
||||
{ "id": "solar-panels", "name": "Solar Panel Arrays", "category": "energy", "tier": 2, "baseValue": 30, "producer": ["arid"], "demand": ["ice","gas","rocky","lava","station"], "weight": 3, "flavor": "Desert sun, harvested and shipped.", "icon": 28 },
|
||||
{ "id": "thermal-batteries","name": "Thermal Batteries", "category": "energy", "tier": 2, "baseValue": 36, "producer": ["lava"], "demand": ["ice","rocky","gas","station"], "weight": 3, "flavor": "Heat you can bank for a cold morning.", "icon": 29 },
|
||||
{ "id": "methane-crates", "name": "Methane Crates", "category": "energy", "tier": 1, "baseValue": 13, "producer": ["gas"], "demand": ["rocky","ice","station"], "weight": 3, "flavor": "Combustible in the most literal sense.", "icon": 30 },
|
||||
{ "id": "plasma-cells", "name": "Plasma Cells", "category": "energy", "tier": 3, "baseValue": 70, "producer": ["lava"], "demand": ["station","capital","rocky"], "weight": 2, "flavor": "A star, shrunk to the size of a crate.", "icon": 31 },
|
||||
{ "id": "biofuel-slurry", "name": "Biofuel Slurry", "category": "energy", "tier": 1, "baseValue": 12, "producer": ["terran"], "demand": ["rocky","gas","station"], "weight": 3, "flavor": "Grown, not drilled. Smells accordingly.", "icon": 32 },
|
||||
{ "id": "isotope-fuel", "name": "Isotope Fuel Rods", "category": "energy", "tier": 3, "baseValue": 78, "producer": ["lava","gas"], "demand": ["station","capital","rocky"], "weight": 2, "flavor": "A little goes a very long way.", "icon": 33 },
|
||||
{ "id": "cryo-fuel", "name": "Cryo-Stabilized Fuel", "category": "energy", "tier": 2, "baseValue": 28, "producer": ["ice","gas"], "demand": ["rocky","lava","station"], "weight": 2, "flavor": "Stable at temperatures that freeze thought.", "icon": 34 },
|
||||
{ "id": "geothermal-taps", "name": "Geothermal Taps", "category": "energy", "tier": 2, "baseValue": 32, "producer": ["lava"], "demand": ["rocky","ice","station"], "weight": 3, "flavor": "Drinks directly from the planet's furnace.", "icon": 35 },
|
||||
|
||||
{ "id": "iron-ore", "name": "Iron Ore", "category": "metals", "tier": 1, "baseValue": 10, "producer": ["rocky"], "demand": ["gas","station","ice","lava","arid"], "weight": 5, "flavor": "The bone of industry.", "icon": 36 },
|
||||
{ "id": "copper-ore", "name": "Copper Ore", "category": "metals", "tier": 1, "baseValue": 11, "producer": ["rocky"], "demand": ["gas","station","lava"], "weight": 4, "flavor": "Conducts everything, including rumors.", "icon": 37 },
|
||||
{ "id": "nickel-ore", "name": "Nickel Ore", "category": "metals", "tier": 1, "baseValue": 12, "producer": ["rocky"], "demand": ["gas","station","lava","ice"], "weight": 4, "flavor": "Hard, grey, and indispensable.", "icon": 38 },
|
||||
{ "id": "rare-earth", "name": "Rare Earth Ores", "category": "metals", "tier": 3, "baseValue": 95, "producer": ["rocky","lava"], "demand": ["station","capital","gas"], "weight": 2, "flavor": "The stuff the future runs on.", "icon": 39 },
|
||||
{ "id": "platinum-group", "name": "Platinum Group", "category": "metals", "tier": 3, "baseValue": 125, "producer": ["lava"], "demand": ["station","capital","gas","rocky"], "weight": 2, "flavor": "Worth its weight, several times over.", "icon": 40 },
|
||||
{ "id": "fissile-ore", "name": "Fissile Ore", "category": "metals", "tier": 3, "baseValue": 105, "producer": ["rocky","lava"], "demand": ["station","capital","gas"], "weight": 2, "flavor": "Handle with respect, ship with care.", "icon": 41 },
|
||||
{ "id": "bauxite", "name": "Bauxite", "category": "metals", "tier": 1, "baseValue": 9, "producer": ["rocky"], "demand": ["gas","station"], "weight": 4, "flavor": "Light as air, once refined.", "icon": 42 },
|
||||
{ "id": "manganese", "name": "Manganese Nodules", "category": "metals", "tier": 1, "baseValue": 10, "producer": ["rocky"], "demand": ["gas","station","lava"], "weight": 3, "flavor": "Dark grains that strengthen everything.", "icon": 43 },
|
||||
{ "id": "cobalt-crystal", "name": "Cobalt Crystal", "category": "metals", "tier": 3, "baseValue": 88, "producer": ["lava"], "demand": ["station","capital","gas","rocky"], "weight": 2, "flavor": "Blue heat, crystallized.", "icon": 44 },
|
||||
{ "id": "tungsten-carbide", "name": "Tungsten Carbide", "category": "metals", "tier": 2, "baseValue": 46, "producer": ["rocky","lava"], "demand": ["station","gas","capital"], "weight": 3, "flavor": "Denser than the problems it solves.", "icon": 45 },
|
||||
{ "id": "tin-ore", "name": "Tin Ore", "category": "metals", "tier": 1, "baseValue": 9, "producer": ["rocky"], "demand": ["gas","station"], "weight": 3, "flavor": "Old, reliable, unglamorous.", "icon": 46 },
|
||||
{ "id": "zircon-sand", "name": "Zircon Sand", "category": "metals", "tier": 2, "baseValue": 36, "producer": ["arid","rocky"], "demand": ["station","gas"], "weight": 3, "flavor": "A grain of glass with ambitions.", "icon": 47 },
|
||||
{ "id": "silicate-agg", "name": "Silicate Aggregates", "category": "metals", "tier": 1, "baseValue": 8, "producer": ["arid","rocky"], "demand": ["station","gas","rocky"], "weight": 4, "flavor": "The filler in ten thousand structures.", "icon": 48 },
|
||||
{ "id": "salt-rock", "name": "Salt Rock", "category": "metals", "tier": 1, "baseValue": 7, "producer": ["arid"], "demand": ["terran","gas","ice","station"], "weight": 4, "flavor": "Older than the sea that left it behind.", "icon": 49 },
|
||||
{ "id": "sulfur-nodes", "name": "Sulfur Nodes", "category": "metals", "tier": 1, "baseValue": 11, "producer": ["lava"], "demand": ["station","gas","rocky"], "weight": 3, "flavor": "Smells like a world with no filters.", "icon": 50 },
|
||||
{ "id": "graphite", "name": "Graphite", "category": "metals", "tier": 1, "baseValue": 10, "producer": ["rocky"], "demand": ["gas","station","lava"], "weight": 4, "flavor": "Soft, black, and a better conductor than most.", "icon": 51 },
|
||||
{ "id": "aluminum-ore", "name": "Aluminum Ore", "category": "metals", "tier": 1, "baseValue": 11, "producer": ["rocky"], "demand": ["gas","station","ice"], "weight": 3, "flavor": "Light enough to forget, strong enough to remember.", "icon": 52 },
|
||||
{ "id": "lithium-brine", "name": "Lithium Brine", "category": "metals", "tier": 2, "baseValue": 42, "producer": ["rocky","ice"], "demand": ["station","gas","capital"], "weight": 3, "flavor": "Charged with quiet potential.", "icon": 53 },
|
||||
|
||||
{ "id": "refined-steel", "name": "Refined Steel", "category": "industrial", "tier": 2, "baseValue": 24, "producer": ["station","rocky"], "demand": ["gas","ice","lava","terran","rocky"], "weight": 4, "flavor": "Order, imposed on iron.", "icon": 54 },
|
||||
{ "id": "alloy-bars", "name": "Alloy Bars", "category": "industrial", "tier": 2, "baseValue": 30, "producer": ["station","lava"], "demand": ["gas","ice","rocky","lava","station"], "weight": 3, "flavor": "Strength, tailored.", "icon": 55 },
|
||||
{ "id": "composite-panels", "name": "Composite Panels", "category": "industrial", "tier": 2, "baseValue": 32, "producer": ["station"], "demand": ["gas","rocky","ice","lava"], "weight": 3, "flavor": "Layers of nothing, into something.", "icon": 56 },
|
||||
{ "id": "machinery-parts", "name": "Machinery Parts", "category": "industrial", "tier": 2, "baseValue": 28, "producer": ["station","rocky"], "demand": ["gas","ice","lava","terran"], "weight": 4, "flavor": "The parts that keep the parts moving.", "icon": 57 },
|
||||
{ "id": "tooling", "name": "Industrial Tooling", "category": "industrial", "tier": 1, "baseValue": 16, "producer": ["station"], "demand": ["rocky","gas","ice","lava"], "weight": 3, "flavor": "Sharp, patient, exact.", "icon": 58 },
|
||||
{ "id": "habitat-prefabs", "name": "Habitat Prefabs", "category": "industrial", "tier": 2, "baseValue": 35, "producer": ["station"], "demand": ["ice","lava","gas","rocky"], "weight": 2, "flavor": "A room in a box, waiting for someone.", "icon": 59 },
|
||||
{ "id": "glass-sheets", "name": "Volcanic Glass Sheets", "category": "industrial", "tier": 2, "baseValue": 26, "producer": ["lava","arid"], "demand": ["station","capital","gas","rocky"], "weight": 3, "flavor": "Transparency, forged in fire.", "icon": 60 },
|
||||
{ "id": "ceramics", "name": "Advanced Ceramics", "category": "industrial", "tier": 2, "baseValue": 38, "producer": ["lava","arid"], "demand": ["station","gas","capital"], "weight": 3, "flavor": "Harder than it looks, prettier than it needs.", "icon": 61 },
|
||||
{ "id": "polymers", "name": "Synthetic Polymers", "category": "industrial", "tier": 2, "baseValue": 30, "producer": ["gas","station"], "demand": ["rocky","ice","lava","terran"], "weight": 3, "flavor": "Flexibility, by design.", "icon": 62 },
|
||||
{ "id": "adhesives", "name": "Industrial Adhesives", "category": "industrial", "tier": 1, "baseValue": 14, "producer": ["station"], "demand": ["rocky","gas","ice","lava"], "weight": 3, "flavor": "Holds. That is the whole job.", "icon": 63 },
|
||||
{ "id": "lubricants", "name": "Precision Lubricants", "category": "industrial", "tier": 1, "baseValue": 15, "producer": ["gas","station"], "demand": ["rocky","lava","ice"], "weight": 3, "flavor": "The difference between running and seizing.", "icon": 64 },
|
||||
{ "id": "atmo-filters", "name": "Atmosphere Filters", "category": "industrial", "tier": 1, "baseValue": 16, "producer": ["station"], "demand": ["gas","ice","lava","rocky"], "weight": 3, "flavor": "Keeps the outside, outside.", "icon": 65 },
|
||||
{ "id": "insulating-foam", "name": "Insulating Foam", "category": "industrial", "tier": 1, "baseValue": 17, "producer": ["station"], "demand": ["ice","lava","gas"], "weight": 3, "flavor": "A wall against temperature's opinion.", "icon": 66 },
|
||||
{ "id": "timber-lots", "name": "Timber Lots", "category": "industrial", "tier": 1, "baseValue": 13, "producer": ["terran"], "demand": ["rocky","station","gas"], "weight": 3, "flavor": "A forest, reduced to a number.", "icon": 67 },
|
||||
|
||||
{ "id": "processor-chips", "name": "Quantum Processor Chips", "category": "tech", "tier": 3, "baseValue": 98, "producer": ["capital"], "demand": ["rocky","gas","ice","lava","station"], "weight": 2, "flavor": "Thought, compressed to a wafer.", "icon": 68 },
|
||||
{ "id": "sensor-arrays", "name": "Sensor Arrays", "category": "tech", "tier": 3, "baseValue": 72, "producer": ["station","capital"], "demand": ["rocky","gas","ice","lava"], "weight": 3, "flavor": "Eyes where there were none.", "icon": 69 },
|
||||
{ "id": "navigation-cores", "name": "Navigation Cores", "category": "tech", "tier": 3, "baseValue": 112, "producer": ["capital"], "demand": ["rocky","gas","ice","lava","station"], "weight": 2, "flavor": "Knows where you are before you do.", "icon": 70 },
|
||||
{ "id": "comms-arrays", "name": "Comms Arrays", "category": "tech", "tier": 2, "baseValue": 48, "producer": ["station","capital"], "demand": ["rocky","gas","ice","lava"], "weight": 3, "flavor": "A voice across the void.", "icon": 71 },
|
||||
{ "id": "power-cells", "name": "High-Density Power Cells","category": "tech", "tier": 3, "baseValue": 88, "producer": ["capital","lava"], "demand": ["rocky","gas","ice","lava","station"], "weight": 3, "flavor": "A battery with a grudge.", "icon": 72 },
|
||||
{ "id": "servo-motors", "name": "Servo Motors", "category": "tech", "tier": 2, "baseValue": 42, "producer": ["station","rocky"], "demand": ["gas","ice","lava","terran"], "weight": 3, "flavor": "Muscle, without the mess.", "icon": 73 },
|
||||
{ "id": "actuators", "name": "Precision Actuators", "category": "tech", "tier": 2, "baseValue": 44, "producer": ["station"], "demand": ["gas","ice","lava","rocky"], "weight": 3, "flavor": "Small moves, exactly repeated.", "icon": 74 },
|
||||
{ "id": "thruster-nozzles", "name": "Thruster Nozzles", "category": "tech", "tier": 2, "baseValue": 50, "producer": ["station","lava"], "demand": ["rocky","gas","ice"], "weight": 3, "flavor": "The polite end of a very loud argument.", "icon": 75 },
|
||||
{ "id": "shield-emitters", "name": "Shield Emitters", "category": "tech", "tier": 3, "baseValue": 132, "producer": ["capital","lava"], "demand": ["rocky","gas","ice","lava","station"], "weight": 2, "flavor": "A wall that only exists when you need it.", "icon": 76 },
|
||||
{ "id": "tactical-ai", "name": "Tactical AI Cores", "category": "tech", "tier": 4, "baseValue": 210, "producer": ["capital"], "demand": ["rocky","gas","ice","lava","station"], "weight": 2, "flavor": "It has already decided. You are along for the ride.", "icon": 77 },
|
||||
{ "id": "medical-bays", "name": "Medical Bay Modules", "category": "tech", "tier": 3, "baseValue": 78, "producer": ["capital","terran"], "demand": ["rocky","gas","ice","lava","station"], "weight": 3, "flavor": "A promise to fix what is broken.", "icon": 78 },
|
||||
{ "id": "hull-plating", "name": "Hull Plating", "category": "tech", "tier": 2, "baseValue": 38, "producer": ["station","rocky"], "demand": ["gas","ice","lava"], "weight": 3, "flavor": "The last thing between you and the void.", "icon": 79 },
|
||||
{ "id": "rad-shielding", "name": "Radiation Shielding", "category": "tech", "tier": 2, "baseValue": 40, "producer": ["station","lava"], "demand": ["gas","ice","rocky","lava"], "weight": 3, "flavor": "Quiet armor against invisible enemies.", "icon": 80 },
|
||||
{ "id": "gyro-stators", "name": "Stabilization Gyros", "category": "tech", "tier": 2, "baseValue": 46, "producer": ["station"], "demand": ["gas","ice","lava","rocky"], "weight": 3, "flavor": "Keeps you honest in any direction.", "icon": 81 },
|
||||
{ "id": "reactor-housings", "name": "Reactor Housings", "category": "tech", "tier": 3, "baseValue": 92, "producer": ["station","lava"], "demand": ["gas","rocky","capital"], "weight": 2, "flavor": "A cage for a small star.", "icon": 82 },
|
||||
{ "id": "cargo-clamps", "name": "Cargo Clamps", "category": "tech", "tier": 1, "baseValue": 18, "producer": ["station"], "demand": ["rocky","gas","ice","lava"], "weight": 3, "flavor": "Grip, in a holdable form.", "icon": 83 },
|
||||
{ "id": "life-support", "name": "Life Support Units", "category": "tech", "tier": 2, "baseValue": 36, "producer": ["station","ice"], "demand": ["gas","rocky","lava","ice"], "weight": 3, "flavor": "The reason you are still breathing.", "icon": 84 },
|
||||
{ "id": "data-crystals", "name": "Data Crystals", "category": "tech", "tier": 3, "baseValue": 82, "producer": ["capital","station"], "demand": ["rocky","gas","ice","lava"], "weight": 3, "flavor": "A library, grown rather than written.", "icon": 85 },
|
||||
|
||||
{ "id": "med-kits", "name": "Medical Kits", "category": "pharma", "tier": 2, "baseValue": 30, "producer": ["terran"], "demand": ["rocky","gas","ice","lava","station"], "weight": 4, "flavor": "Hope, boxed.", "icon": 86 },
|
||||
{ "id": "antibiotics", "name": "Antibiotic Compounds", "category": "pharma", "tier": 2, "baseValue": 35, "producer": ["terran"], "demand": ["rocky","gas","ice","lava","station"], "weight": 3, "flavor": "A small war, fought invisibly.", "icon": 87 },
|
||||
{ "id": "repair-nanites", "name": "Repair Nanites", "category": "pharma", "tier": 4, "baseValue": 185, "producer": ["capital"], "demand": ["rocky","gas","ice","lava","station"], "weight": 2, "flavor": "A million tiny hands, all helping.", "icon": 88 },
|
||||
{ "id": "cryo-serum", "name": "Cryo Sleep Serum", "category": "pharma", "tier": 3, "baseValue": 66, "producer": ["ice","capital"], "demand": ["rocky","gas","lava","station"], "weight": 2, "flavor": "A pause button for the body.", "icon": 89 },
|
||||
{ "id": "toxin-antidote", "name": "Toxin Antidotes", "category": "pharma", "tier": 2, "baseValue": 40, "producer": ["terran"], "demand": ["rocky","gas","ice","lava","station"], "weight": 3, "flavor": "The answer to a very bad question.", "icon": 90 },
|
||||
{ "id": "genetic-editors", "name": "Genetic Editing Kits", "category": "pharma", "tier": 4, "baseValue": 225, "producer": ["capital"], "demand": ["rocky","gas","ice","lava","station"], "weight": 2, "flavor": "Rewrite the source. Proceed carefully.", "icon": 91 },
|
||||
{ "id": "vaccine-batches", "name": "Vaccine Batches", "category": "pharma", "tier": 3, "baseValue": 60, "producer": ["terran","capital"], "demand": ["rocky","gas","ice","lava","station"], "weight": 3, "flavor": "A head start on sickness.", "icon": 92 },
|
||||
{ "id": "neural-stims", "name": "Neural Stims", "category": "pharma", "tier": 3, "baseValue": 96, "producer": ["capital"], "demand": ["rocky","gas","ice","lava","station"], "weight": 2, "flavor": "A nudge the mind does not forget.", "icon": 93 },
|
||||
{ "id": "bio-fertilizer", "name": "Bio-Fertilizer", "category": "pharma", "tier": 1, "baseValue": 14, "producer": ["terran"], "demand": ["arid","rocky","gas"], "weight": 3, "flavor": "Feeds the soil that feeds you.", "icon": 94 },
|
||||
{ "id": "enzyme-cultures", "name": "Enzyme Cultures", "category": "pharma", "tier": 2, "baseValue": 28, "producer": ["terran"], "demand": ["rocky","gas","lava","station"], "weight": 3, "flavor": "Life, doing its patient work.", "icon": 95 },
|
||||
|
||||
{ "id": "luxury-silks", "name": "Luxury Silks", "category": "luxury", "tier": 3, "baseValue": 88, "producer": ["capital","terran"], "demand": ["capital","station"], "weight": 2, "flavor": "Softness, priced by the inch.", "icon": 96 },
|
||||
{ "id": "exotic-pet", "name": "Exotic Companion", "category": "luxury", "tier": 3, "baseValue": 112, "producer": ["terran"], "demand": ["capital","station"], "weight": 2, "flavor": "For those who want a friend that is not a machine.", "icon": 97 },
|
||||
{ "id": "art-sculpture", "name": "Artisan Sculpture", "category": "luxury", "tier": 3, "baseValue": 102, "producer": ["arid","capital"], "demand": ["capital","station","terran"], "weight": 2, "flavor": "A shape that argues with the eye.", "icon": 98 },
|
||||
{ "id": "treasure-spice", "name": "Treasure Spice Blend", "category": "luxury", "tier": 3, "baseValue": 122, "producer": ["arid"], "demand": ["capital","station"], "weight": 2, "flavor": "A pinch changes a meal; a fortune changes a ledger.", "icon": 99 },
|
||||
{ "id": "gem-weapon", "name": "Gem-Inlaid Weapon", "category": "luxury", "tier": 4, "baseValue": 165, "producer": ["capital"], "demand": ["capital","station","rocky"], "weight": 2, "flavor": "Beauty with a point.", "icon": 100 },
|
||||
{ "id": "faction-heirloom", "name": "Faction Heirloom", "category": "luxury", "tier": 4, "baseValue": 255, "producer": ["capital"], "demand": ["capital","station"], "weight": 1, "flavor": "Old money, older grudges.", "icon": 101 },
|
||||
{ "id": "fine-porcelain", "name": "Fine Porcelain", "category": "luxury", "tier": 3, "baseValue": 78, "producer": ["lava","capital"], "demand": ["capital","station","terran"], "weight": 2, "flavor": "Fragile, on purpose.", "icon": 102 },
|
||||
{ "id": "aromatic-oils", "name": "Aromatic Oils", "category": "luxury", "tier": 2, "baseValue": 46, "producer": ["arid","terran"], "demand": ["capital","station"], "weight": 3, "flavor": "A scent that travels further than a ship.", "icon": 103 },
|
||||
{ "id": "living-ornamental","name": "Living Ornamental", "category": "luxury", "tier": 2, "baseValue": 56, "producer": ["terran"], "demand": ["capital","station","gas"], "weight": 2, "flavor": "Green, alive, and very expensive.", "icon": 104 },
|
||||
{ "id": "rare-vintage", "name": "Rare Vintage Reserve", "category": "luxury", "tier": 3, "baseValue": 132, "producer": ["terran"], "demand": ["capital","station"], "weight": 2, "flavor": "Time, served in a glass.", "icon": 105 },
|
||||
|
||||
{ "id": "verdant-emerald", "name": "Verdant Emerald", "category": "gemstone", "tier": 3, "baseValue": 120, "producer": ["terran"], "demand": ["rocky","gas","ice","lava","capital","station"],"weight": 3, "flavor": "The green heart of a mild world.", "icon": 106 },
|
||||
{ "id": "dune-topaz", "name": "Dune Topaz", "category": "gemstone", "tier": 3, "baseValue": 110, "producer": ["arid"], "demand": ["terran","ice","lava","capital"], "weight": 3, "flavor": "Sunlight, trapped in sand.", "icon": 107 },
|
||||
{ "id": "karnite", "name": "Karnite", "category": "gemstone", "tier": 3, "baseValue": 105, "producer": ["rocky"], "demand": ["gas","ice","lava","capital","station"], "weight": 3, "flavor": "Stone that remembers being a mountain.", "icon": 108 },
|
||||
{ "id": "jovian-sapphire", "name": "Jovian Sapphire", "category": "gemstone", "tier": 3, "baseValue": 118, "producer": ["gas"], "demand": ["rocky","ice","lava","capital","terran"], "weight": 3, "flavor": "The sky of a world with no ground.", "icon": 109 },
|
||||
{ "id": "frost-diamond", "name": "Frost Diamond", "category": "gemstone", "tier": 3, "baseValue": 135, "producer": ["ice"], "demand": ["lava","arid","rocky","gas","capital"], "weight": 3, "flavor": "Cold, cut to a point.", "icon": 110 },
|
||||
{ "id": "magma-ruby", "name": "Magma Ruby", "category": "gemstone", "tier": 3, "baseValue": 128, "producer": ["lava"], "demand": ["ice","rocky","gas","capital","terran"], "weight": 3, "flavor": "Fire, held still.", "icon": 111 },
|
||||
{ "id": "aether-crystal", "name": "Aether Crystal", "category": "gemstone", "tier": 3, "baseValue": 140, "producer": ["station"], "demand": ["capital","rocky","gas"], "weight": 2, "flavor": "Grown in the quiet between stars.", "icon": 112 },
|
||||
{ "id": "imperial-diamond", "name": "Imperial Diamond", "category": "gemstone", "tier": 4, "baseValue": 300, "producer": ["capital"], "demand": ["capital","station","terran","rocky","gas","ice","lava"],"weight": 2, "flavor": "The measure by which all others are weighed.", "icon": 113 }
|
||||
]
|
||||
}
|
||||
|
|
@ -27,8 +27,11 @@
|
|||
"signalCompass.json",
|
||||
"save.json",
|
||||
"mineralhud.json",
|
||||
"creditshud.json",
|
||||
"map.json",
|
||||
"quests.json",
|
||||
"comms.json"
|
||||
"comms.json",
|
||||
"goods.json",
|
||||
"economy.json"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
"claimToast": {
|
||||
"glyph": "✓",
|
||||
"color": "#67e863",
|
||||
"text": "REWARD CLAIMED — +{n} MINERALS"
|
||||
"text": "REWARD CLAIMED — {reward}"
|
||||
},
|
||||
"claimDenyToast": {
|
||||
"glyph": "✗",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"_comment": "Base stats — the ship's starting condition, before any upgrades (builds/research layer deltas on top). hullIntegrity = the hull's max health (damage it can take); shields = damage absorbed in front of the hull; cargoHold = goods capacity; mineralStorage = minerals capacity (the hold's max load — the player's current minerals live in ship.minerals, loaded by the mining sequence); miningSpeed = multiplier on mining rate (1 = baseline). Exposed as ship.stats (js/entities/Ship.js); combat/trading/mining systems will read these as capacities.",
|
||||
"hullIntegrity": 100,
|
||||
"shields": 0,
|
||||
"cargoHold": 100,
|
||||
"cargoHold": 4,
|
||||
"mineralStorage": 250,
|
||||
"miningSpeed": 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Economy test (dev tool, run with Node — no browser needed):
|
||||
*
|
||||
* node dev/economy.test.mjs
|
||||
*
|
||||
* Drives the real trade engine (js/economy/Economy.js) against a real
|
||||
* generated galaxy (js/galaxy/Galaxy.js) and the real catalog + tuning
|
||||
* (data/goods.json, data/economy.json). Asserts the model in
|
||||
* docs/ECONOMY.md §5:
|
||||
*
|
||||
* - THE PASS RAN: galaxy.economy has classBySystem (every system) + a
|
||||
* producerHops map for every good; the home system carries terran
|
||||
* (the home world) + its composition classes.
|
||||
* - DETERMINISM: same seed ⇒ identical prices + shop floors; a different
|
||||
* seed ⇒ a different economy (the trade map is a pure function of the
|
||||
* seed, lazy-safe and save-free).
|
||||
* - FIXED PRICES: buyPrice/sellPrice are stable for (good, class, system)
|
||||
* — independent of where the player got the good or what's in their hold.
|
||||
* - THE LOOP: carrying a good far from its home region sells for MORE than
|
||||
* it cost to buy at the source (buy cheap where produced, dear where
|
||||
* wanted + far) — the trip is the reward.
|
||||
* - DISTANCE: the further a system is from a good's producer, the higher
|
||||
* the good sells there (the hop term, capped).
|
||||
* - SHOP FLOOR: 8–16 goods; locally produced goods present and dominant;
|
||||
* the class's wanted goods in the buy-back list; a station carries
|
||||
* resold goods (variety); a capital buys back EVERYTHING (universal sink).
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// --- Load the real config (data/*.json) into the config singleton ------
|
||||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||
const fs = await import('node:fs');
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
|
||||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||
const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const SEED = 'orbit-economy-test';
|
||||
const g = Galaxy.create(SEED);
|
||||
const goods = config.get('goods.goods', []);
|
||||
const clsOf = (sysId) => [...g.economy.classBySystem.get(sysId)];
|
||||
const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.classBySystem.get(sysId).has(c));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. The economy pass ran and is complete
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
check('galaxy.economy exists', !!g.economy);
|
||||
check('classBySystem covers every system', g.economy.classBySystem.size === g.byId.size);
|
||||
check('producerHops has an entry for every good', goods.every((x) => g.economy.producerHops[x.id] && Object.keys(g.economy.producerHops[x.id]).length > 0));
|
||||
|
||||
const home = clsOf(g.homeSystemId);
|
||||
check('home system carries terran (the home world)', home.includes('terran'));
|
||||
check('home system carries its composition classes (gas + rocky)', home.includes('gas') && home.includes('rocky'));
|
||||
|
||||
// Every economic class in the catalog is present somewhere in the galaxy.
|
||||
const present = new Set();
|
||||
for (const set of g.economy.classBySystem.values()) for (const c of set) present.add(c);
|
||||
const wantClasses = ['terran', 'arid', 'rocky', 'gas', 'ice', 'lava', 'station', 'capital'];
|
||||
const missing = wantClasses.filter((c) => !present.has(c));
|
||||
check(`every catalog class exists in the galaxy (missing: ${missing.join(', ') || 'none'})`, missing.length === 0);
|
||||
|
||||
// Every good has at least one producing system (the loop is possible).
|
||||
const unproducible = goods.filter((x) => !goods.length || ![...g.economy.classBySystem.values()].some((s) => x.producer.some((c) => s.has(c))));
|
||||
check('every good is produced somewhere', unproducible.length === 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Determinism (pure function of the seed) + fixed prices
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const g2 = Galaxy.create(SEED);
|
||||
const sysA = g.byId.keys().next().value;
|
||||
const rockySys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
|
||||
const goodId = goods[0].id;
|
||||
|
||||
check('buyPrice is fixed + deterministic', E.buyPrice(goodId, 'rocky', rockySys, g) === E.buyPrice(goodId, 'rocky', rockySys, g2) && E.buyPrice(goodId, 'rocky', rockySys, g) > 0);
|
||||
check('sellPrice is fixed + deterministic', E.sellPrice(goodId, 'rocky', rockySys, g) === E.sellPrice(goodId, 'rocky', rockySys, g2) && E.sellPrice(goodId, 'rocky', rockySys, g) > 0);
|
||||
check('shopFor is deterministic', JSON.stringify(E.shopFor('rocky', rockySys, g)) === JSON.stringify(E.shopFor('rocky', rockySys, g2)));
|
||||
check('prices do not depend on call order (lazy-safe)', E.buyPrice(goodId, 'rocky', rockySys, g) === E.buyPrice(goodId, 'rocky', rockySys, g));
|
||||
void sysA;
|
||||
}
|
||||
|
||||
{
|
||||
const other = Galaxy.create('orbit-economy-other-seed');
|
||||
const rockyA = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
|
||||
const rockyB = [...other.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
|
||||
const goodId = goods[0].id;
|
||||
check('a different seed yields a different trade map', JSON.stringify(E.shopFor('rocky', rockyA, g)) !== JSON.stringify(E.shopFor('rocky', rockyB, other)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. The loop: buy cheap at the source, sell dear far away (trip = reward)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const base = (id) => (goods.find((x) => x.id === id) || {}).baseValue || 0;
|
||||
let checked = 0;
|
||||
let ok = 0;
|
||||
for (const good of goods) {
|
||||
// A system that PRODUCES it (buy it cheap there).
|
||||
const prod = [...g.economy.classBySystem.entries()].find(([, s]) => good.producer.some((c) => s.has(c)));
|
||||
if (!prod) continue;
|
||||
const [prodId] = prod;
|
||||
const buyCls = classFor(prodId, good, good.producer);
|
||||
// A system that WANTS it, ≥ 3 hops from the nearest producer.
|
||||
const far = [...g.economy.classBySystem.entries()].find(
|
||||
([id, s]) => good.demand.some((c) => s.has(c)) && (g.economy.producerHops[good.id][id] ?? 0) >= 3,
|
||||
);
|
||||
if (!far) continue;
|
||||
const [farId] = far;
|
||||
const sellCls = classFor(farId, good, good.demand);
|
||||
const buy = E.buyPrice(good.id, buyCls, prodId, g);
|
||||
const sell = E.sellPrice(good.id, sellCls, farId, g);
|
||||
checked++;
|
||||
if (sell > buy && sell > base(good.id) * 0.9) ok++;
|
||||
}
|
||||
check(`carry-far beats buy-local on a meaningful share of goods (${ok}/${checked})`, checked > 0 && ok / checked >= 0.6);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Distance: the further from the producer, the higher the sell price
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Pick a good with several producers/demanders, then two demanders at
|
||||
// different hop distances and assert the farther one pays more.
|
||||
let found = false;
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
for (const good of goods) {
|
||||
const hops = g.economy.producerHops[good.id] ?? {};
|
||||
const demanders = [...g.economy.classBySystem.entries()]
|
||||
.filter(([, s]) => good.demand.some((c) => s.has(c)))
|
||||
.map(([id]) => ({ id, h: hops[id] ?? 99 }))
|
||||
.sort((a, b) => a.h - b.h);
|
||||
if (demanders.length < 2) continue;
|
||||
const near = demanders[0];
|
||||
const far = demanders[demanders.length - 1];
|
||||
if (far.h <= near.h + 1) continue; // need a real distance gap
|
||||
const nearCls = classFor(near.id, good, good.demand);
|
||||
const farCls = classFor(far.id, good, good.demand);
|
||||
const nearPrice = E.sellPrice(good.id, nearCls, near.id, g);
|
||||
const farPrice = E.sellPrice(good.id, farCls, far.id, g);
|
||||
total++;
|
||||
if (farPrice >= nearPrice) passed++;
|
||||
found = true;
|
||||
}
|
||||
check(`distance term: farther demanders pay ≥ nearer ones (${passed}/${total})`, found && passed >= total * 0.7);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. The shop floor (8–16, local-dominant, buy-back, hubs)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const rockySys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky') && !s.has('station') && !s.has('capital'))[0];
|
||||
const shop = E.shopFor('rocky', rockySys, g);
|
||||
check('plain-world floor is 8–16 goods', shop.buy.length >= 8 && shop.buy.length <= 16);
|
||||
check('locally produced goods are on the floor', shop.buy.some((b) => b.produced));
|
||||
check('more than a third of the floor is locally produced', shop.buy.filter((b) => b.produced).length >= shop.buy.length / 3);
|
||||
check('the buy-back list is non-empty (it wants things)', shop.sell.length > 0);
|
||||
// No good appears on the floor twice.
|
||||
const ids = shop.buy.map((b) => b.id);
|
||||
check('no duplicate goods on the floor', new Set(ids).size === ids.length);
|
||||
|
||||
const stationSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
|
||||
const sshop = E.shopFor('station', stationSys, g);
|
||||
check('station floor is 8–16 goods', sshop.buy.length >= 8 && sshop.buy.length <= 16);
|
||||
check('a station carries resold goods (variety)', sshop.buy.some((b) => !b.produced));
|
||||
|
||||
const capSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('capital'))[0];
|
||||
const cshop = E.shopFor('capital', capSys, g);
|
||||
check('a capital buys back EVERYTHING (universal sink)', cshop.sell.length === goods.length);
|
||||
check('a capital floor is 8–16 goods', cshop.buy.length >= 8 && cshop.buy.length <= 16);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Credits start at 0 (the config the Ship reads)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
check('the player starts at 0 credits', config.get('economy.credits.startingCredits', 0) === 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. The goods sheet icon mapping (frame N = goods[N], 12×128 grid)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const sheet = config.section('goods', {}).sheet ?? {};
|
||||
check('sheet config: 128×128 frames, 12 across, 10 down', sheet.frameWidth === 128 && sheet.frameHeight === 128 && sheet.cols === 12 && sheet.rows === 10);
|
||||
check('sheet texture points at assets/images/goods.png', sheet.texture === 'assets/images/goods.png');
|
||||
const icons = goods.map((x) => x.icon);
|
||||
check('every good has a stamped icon frame', goods.every((x) => Number.isInteger(x.icon) && x.icon >= 0));
|
||||
check('icon frames are unique (no two goods share a cell)', new Set(icons).size === icons.length);
|
||||
check('icon frames fit the 120-cell sheet', icons.every((n) => n < sheet.cols * sheet.rows));
|
||||
check('goodFrame() resolves id → stamped frame', goods.every((x) => E.goodFrame(x.id) === x.icon));
|
||||
check('goodFrame() is safe on unknown ids (blank frame, no crash)', E.goodFrame('a-never-defined-good') === 0 && E.goodFrame('fresh-produce') === 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('');
|
||||
if (failures) {
|
||||
console.error(`${failures} check(s) FAILED`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('All economy checks passed ✔');
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
# Orbit — Trade & Economy (design)
|
||||
|
||||
This is the design for the game's trade/economy. It is written to be built on top of what
|
||||
Orbit already has — the jump network, factions, planet classes, settlements, the SHOP button
|
||||
that already exists on the surface deck, and the `ship.stats.cargoHold` slot that's already
|
||||
reserved for cargo. Nothing here requires new world-structure; it all hangs off the existing
|
||||
deterministic galaxy.
|
||||
|
||||
**Status: DRAFT / design + data only.** The goods catalog and tuning knobs are written to
|
||||
`data/` so they can be iterated on. The JS plumbing (HUD, credits, shop logic, generator
|
||||
hook) is intentionally **not** written yet — the open questions at the bottom of this doc need
|
||||
your call first.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design goals
|
||||
|
||||
1. **Trade is a thing the player does, not a thing the player manages.** No market simulation,
|
||||
no stock you can manipulate, no persistent shop state. The economy is a *landscape* you
|
||||
travel through and exploit; it doesn't react to you (or only very lightly, via reputation).
|
||||
2. **Every world has a market.** Every settled planet and station gets a shop of 8–16 goods,
|
||||
priced so that what's local is cheap and what's foreign is dear.
|
||||
3. **The trip is the reward.** A good's profit scales with how far it is from where it's made.
|
||||
Local trade (buy low in-system, sell nearby) works and is safe; long-haul trade pays much
|
||||
more and costs jumps/time/risk. Longer journey ⇒ meaningfully better reward.
|
||||
4. **Planet type drives production.** A terran world makes food; a lava world makes heat and
|
||||
rare metals; a gas giant makes fuel. Opposing types want each other's signature goods —
|
||||
ice wants heat, lava wants cool, terran wants salt/solar, arid wants water/food, gas wants
|
||||
metal, rocky wants fuel.
|
||||
5. **Local money is possible without crossing the galaxy.** You never *have* to cross the map
|
||||
to make a living; you choose to, for more.
|
||||
6. **Each planet type yields a gemstone that other types want** (a universal premium good, and
|
||||
a later mining/loot hook).
|
||||
|
||||
---
|
||||
|
||||
## 2. The core loop
|
||||
|
||||
```
|
||||
LAND on a world SHIP a good LAND on another world
|
||||
┌──────────────┐ (jump) ┌──────────────┐
|
||||
│ open SHOP │ ◄────────────────────────── │ SELL for │
|
||||
│ BUY cheap │ │ CREDITS │
|
||||
│ (local/low) │ ──────────────────────────► │ (dear/hi) │
|
||||
└──────────────┘ carry in cargoHold └──────────────┘
|
||||
▲ │
|
||||
└──────────────── spend credits on builds/quests ◄──────┘
|
||||
```
|
||||
|
||||
- **BUY** where a good is cheap (it's produced there, or it's local).
|
||||
- **CARRY** it in your cargo hold (`ship.stats.cargoHold`).
|
||||
- **SELL** where it's dear (it's wanted there, or it's far from its source).
|
||||
- **PROFIT** = sell price − buy price. The gap is bigger the further the good has travelled.
|
||||
- **SPEND** credits on ship builds, quest rewards, upgrades.
|
||||
|
||||
You are never *balancing* a market. You are *finding* an arbitrage the galaxy already set up
|
||||
and running it. That is the "interact, don't control" requirement made concrete.
|
||||
|
||||
---
|
||||
|
||||
## 3. The eight economic classes
|
||||
|
||||
`data/goods.json` defines them; they map straight onto existing structures:
|
||||
|
||||
| Class | Where it exists | Naturally PRODUCES (cheap to buy) | Strongly WANTS (dear to sell) |
|
||||
|---|---|---|---|
|
||||
| **terran** | `terran` planets | food, water, timber, pharma, biology, gems: **Verdant Emerald** | rare metals, fuel, heat goods, high-end tech |
|
||||
| **arid** | `arid` planets | salt, silica, solar, spice, gems: **Dune Topaz** | water, humidity, coolants, food |
|
||||
| **rocky** | `rocky` planets | ores, metal, stone, gems: **Karnite** | fuel, food, water, processed tech |
|
||||
| **gas** | `gas` planets | fuel, gases, isotopes, gems: **Jovian Sapphire** | solid metal, food, water, hull parts |
|
||||
| **ice** | `ice` planets | water, ice, cryo, volatiles, gems: **Frost Diamond** | heat, energy, food, fuel |
|
||||
| **lava** | `lava` planets | heat, rare metals, volcanic glass, gems: **Magma Ruby** | coolants, water, food, fuel |
|
||||
| **station** | deep-space stations | processed/refined goods, services, gems: **Aether Crystal** | raw ore, food, water, bulk fuel |
|
||||
| **capital** | a faction's seat | luxury, finance, high-end tech, gems: **Imperial Diamond** | everything, at a premium (the universal buyer) |
|
||||
|
||||
This is the supply/demand web. It's deliberately symmetric on the opposition pairs you asked
|
||||
for:
|
||||
|
||||
- **ice ⇄ lava** — ice sells cool (coolants, cryo, water) to lava; lava sells heat (thermal
|
||||
batteries, plasma, volcanic energy) to ice.
|
||||
- **terran ⇄ arid** — terran sells water/food to arid; arid sells salt/solar/spice to terran.
|
||||
- **gas ⇄ rocky** — gas sells fuel to rocky; rocky sells metal to gas.
|
||||
|
||||
The `capital` class is the great sink: it buys almost everything at a premium, which is what
|
||||
makes a run to a faction seat lucrative and gives long-haul a ceiling.
|
||||
|
||||
---
|
||||
|
||||
## 4. The gemstone system
|
||||
|
||||
One signature gem per class, category `gemstone`. They are:
|
||||
|
||||
- **Universal demand** — every gem is wanted by *other* classes (see each entry's `demand`),
|
||||
so there is always a premium outlet for a world's signature stone.
|
||||
- **Higher value band** (tier 3–4, baseValue 105–300) than most commodity goods — they are the
|
||||
"jewel" trade that makes even a short trip worthwhile.
|
||||
- **A future mining/loot hook** — right now they are tradeable goods; later they can also drop
|
||||
from mining/loot, as you noted. The catalog already treats them as goods, so a drop source
|
||||
just adds a second way to acquire the same id.
|
||||
|
||||
The eight gems: Verdant Emerald (terran), Dune Topaz (arid), Karnite (rocky), Jovian Sapphire
|
||||
(gas), Frost Diamond (ice), Magma Ruby (lava), Aether Crystal (station), Imperial Diamond
|
||||
(capital — the measure by which the rest are weighed).
|
||||
|
||||
---
|
||||
|
||||
## 5. Pricing — fixed per location, distance baked in by regionality
|
||||
|
||||
**The prices are FIXED per (good, location) pair — decided once at generation, and they do NOT
|
||||
depend on where you got the good or what's in your hold.** A good's sell price at a planet is the
|
||||
same whether you mined it two systems away or carried it across the galaxy. This is what makes
|
||||
the shops stateless (no per-player stock bookkeeping) and the whole economy a pure function of
|
||||
the seed.
|
||||
|
||||
For a good `g` at a place `p` (class `C`, system `S`) there are TWO fixed prices, both in credits:
|
||||
|
||||
- **BUY price** (what you pay `p`): cheap where `C` produces the good (its home region), standard
|
||||
where a hub resells it.
|
||||
- **SELL price** (what `p` pays you): a premium where `C` wants the good, growing with the hops
|
||||
between `S` and the good's home region. That hop distance is the "trip is the reward" term.
|
||||
|
||||
```
|
||||
price(g,p) = baseValue(g)
|
||||
× (1 + demandBonus if p.class is in g.demand) ← it's wanted here
|
||||
× (1 − producerDiscount if p.class is in g.producer)← it's made here (cheaper)
|
||||
× (1 + distancePerHop × min(hops, cap)) ← how far from its source
|
||||
× jitter
|
||||
```
|
||||
|
||||
- `baseValue` — the good's intrinsic worth (goods.json).
|
||||
- **demandBonus** (+12%/class in the draft) — a place that *wants* the good pays more.
|
||||
- **producerDiscount** (−35% in the draft) — a place that *makes* the good sells it cheap.
|
||||
**This is the "local = cheap" rule.**
|
||||
- **distance** (+22%/hop, capped at 6 hops) — the good is worth more the further it is from the
|
||||
nearest class that produces it. **This is the "trip is the reward" rule.** It uses the jump
|
||||
graph (hops), which PROJECT_NOTES already establishes as ≈ map distance, so it's consistent
|
||||
with how the galaxy is generated.
|
||||
- **jitter** (±10% seeded) — per-good, per-place wobble so no two shops are identical and no
|
||||
number feels "formulated."
|
||||
|
||||
- **Regional availability is how distance is enforced.** A good is only offered for sale (cheap)
|
||||
where its class produces it — its HOME region. You must be in that region to buy it cheap; a
|
||||
far region doesn't carry it locally, so the only way to get it there is to carry it. That
|
||||
regional lock — not a live per-trade hop-multiplier — is the distance mechanism.
|
||||
- **Credits are the go-between — there is no barter.** Buying is one credit transaction (you
|
||||
spend); selling is a separate one (you earn). Your profit is simply
|
||||
`SELL price (where you sell) − BUY price (where you bought)`.
|
||||
|
||||
**Concrete example (draft numbers):** `Refined Fuel` (base 14, gas producer) is on sale on a gas
|
||||
world for `14 × 0.65 ≈ 9`. A rocky world 2 hops out, which wants fuel, buys it back for
|
||||
`14 × 1.12 × 1.44 ≈ 22`. Profit ≈ **13 credits** on a 2-hop run. Same good, 5 hops out:
|
||||
`14 × 1.12 × 2.10 ≈ 33` → profit ≈ **24** — same good, same hold-space, more than double, because
|
||||
it was carried further. Every one of those numbers is fixed for its location; none changes based
|
||||
on your inventory.
|
||||
|
||||
---
|
||||
|
||||
## 6. Shop stock rules (8–16 goods)
|
||||
|
||||
Each settlement gets a shop with a stock size from `economy.json → shop.stockByKind`
|
||||
(colonies/mining 8–12, stations/capitals 10–16). The stock is **seeded**, not random-at-open,
|
||||
so it's stable per place. The mix follows the class:
|
||||
|
||||
- **Locally produced goods** appear in *higher* stock and *lower* price (the `producer` rule).
|
||||
- **Goods the class wants** appear at a *premium* or are *absent* — a place that needs fuel
|
||||
often won't sell fuel cheap; it sells it dear or not at all, so your imported fuel is what
|
||||
they're buying.
|
||||
- **Signature gem** of the local class is always present (the world's "jewel" is always on the
|
||||
shelf) — guarantees a premium good in every shop and a guaranteed local buy.
|
||||
- **Stations/capitals** carry wider, more exotic stock (they're resellers/finance), so they
|
||||
list more tier-3/4 and tech/luxury goods than a mining station does.
|
||||
|
||||
The exact pick is `seed → (place class, distance profile) → weighted sample of 8–16 goods`,
|
||||
weighted by the good's `weight` field and its relevance to the class. It's a pure function, so
|
||||
it's deterministic and re-derivable (see §8).
|
||||
|
||||
---
|
||||
|
||||
## 7. How it hangs off the existing galaxy
|
||||
|
||||
| Existing system | Economy uses it |
|
||||
|---|---|
|
||||
| **Jump network / hops** (`Galaxy.js`, `data/galaxy.json`) | the distance term in pricing — hops ≈ map distance, per PROJECT_NOTES. Longer path ⇒ higher sell price. |
|
||||
| **Faction territories** (`data/factions.json`) | `capital` class = faction seats (universal premium buyers). Faction tariff/patron hooks (`economy.json → faction`). |
|
||||
| **Planet classes** (`data/planets.json`) | the six world classes + their producer/demand roles. |
|
||||
| **Settlements** (`data/settlements.json`, every planet settled) | each settlement = a shop. `settledKindByClass` already tells us the shop's "personality." |
|
||||
| **Stations** (`data/stations.json`) | the `station` class (refiner/reseller, wider stock). |
|
||||
| **Zones** (`data/galaxy.json → zones`) | optional regional price bands — far zones (Far Reach / Outer Verge) run wilder prices. |
|
||||
| **Reputation** (`js/reputation/Reputation.js`) | standing with a place's owner faction nudges its prices (hook, `economy.json → reputation`). |
|
||||
| **SHOP button** (surface deck, `data/actionbar.json`) | the existing surface `shop` action becomes the buy/sell UI. |
|
||||
| **cargoHold** (`data/ship.json`) | your carrying capacity for goods. |
|
||||
| **Credits** (builds, quest rewards already reference `credits`) | the currency. HUD next to minerals. |
|
||||
|
||||
Determinism: all of this is a **pure function of the seed**. Shop stock and prices are derived
|
||||
from `(seed, placeId, economyVersion)` exactly like system contents are derived today
|
||||
(`Rng.derive(seed, 'economy', …)`). That means lazy and eager generation agree, saves stay
|
||||
tiny (we store only *your* credits + your carried goods, never the shops), and the economy can
|
||||
never drift or corrupt.
|
||||
|
||||
---
|
||||
|
||||
## 8. The credits currency (decided)
|
||||
|
||||
- **Displayed next to the minerals HUD** (upper-right). Minerals are *bulk matter*; credits are
|
||||
*trade value*. They coexist because they're different resources: you mine minerals, you earn
|
||||
credits.
|
||||
- **You start at 0** (`economy.json → credits.startingCredits`).
|
||||
- **Earning credits** = selling goods + mission/quest credit rewards. **Spending** = builds,
|
||||
upgrades, and buying goods.
|
||||
- **Trading is LOCKED until the third story quest** (not written yet). Until then the economy
|
||||
exists in the world (shops, prices, gems) but the player's buy/sell UI is gated. Initial
|
||||
credits come from **mission/quest rewards** (the quest reward object already has a `credits`
|
||||
field — `data/quests.json` → `reward.credits`, surfaced in `_questRewardLabel`) and, later,
|
||||
from **mining-laser upgrades that periodically yield gemstones from asteroids** — the gems are
|
||||
already tradeable goods, so selling them is the credit faucet. (Decision #1.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Interact, don't control (your requirement)
|
||||
|
||||
What the player **does**: pick goods, buy low, travel, sell high, spend. Nothing else.
|
||||
|
||||
What the player **cannot do**: change prices, stock a shop, corner a market, set a price, or
|
||||
make a good appear that isn't produced. The economy is a fixed landscape generated from the
|
||||
seed. The only "control" surface that exists is *light* and comes from your existing systems:
|
||||
reputation slightly moves a place's prices, and (later) factions may patron/tax specific goods.
|
||||
That's flavor, not management.
|
||||
|
||||
---
|
||||
|
||||
## 10. Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `data/goods.json` | **114 goods** across 9 categories, each with producer/demand/weight/tier/value/flavor. The catalog. |
|
||||
| `data/economy.json` | All the tuning knobs (shop size, price multipliers, distance curve, cargo, credits start, trading lock, reputation/faction hooks). |
|
||||
| `data/creditshud.json` | The CREDITS readout styling (upper-right, stacked above the minerals HUD). |
|
||||
| `js/economy/Economy.js` | The pure price/stock engine (`buyPrice`/`sellPrice`/`shopFor`) + the `buildEconomy` generation pass (classBySystem + producerHops). |
|
||||
| `js/ui/CreditsHud.js` | The CREDITS line, upper-right, above the minerals HUD (count-up on change). |
|
||||
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
|
||||
| `js/entities/Ship.js` | `ship.credits` + `canAfford`/`addCredits`/`spendCredits`/`setCredits` (mirrors the minerals seam). |
|
||||
| `js/save/SaveData.js` | Credits captured/restored with the ship record. |
|
||||
| `js/scenes/GameScene.js` | Builds the credits HUD (minerals HUD shifted below it), refreshes it on credit changes, pays quest `reward.credits` on claim. |
|
||||
| `dev/economy.test.mjs` | The engine's invariants: determinism, fixed prices, the loop, distance, shop floor, hubs. |
|
||||
| `docs/ECONOMY.md` | This document. |
|
||||
|
||||
Implemented this pass: the **price/stock engine**, the **generation pass**, **credits state + save + HUD**, and **quest-credit payout** (the reward seam is live; no first-pass quest pays credits yet). The **shop buy/sell UI panel** and the **goods spritesheet** remain deferred — trading is locked until the third story quest.
|
||||
|
||||
---
|
||||
|
||||
## 11. Decisions (locked in this pass)
|
||||
|
||||
1. **Initial credits** — trading is gated until the third story quest; first credits come from
|
||||
**mission/quest rewards** (the `reward.credits` field already exists in `data/quests.json`)
|
||||
and, later, **asteroid gemstone mining** (mining-laser upgrades yield gems you sell).
|
||||
2. **Cargo weight** — **1 hold unit per good** (simplest to manage; `cargoHold = 100`).
|
||||
3. **Reputation & faction prices** — **deferred** (the hooks stay in `economy.json`; wired later).
|
||||
4. **Zones / regional bands** — **hop-distance only** (no extra per-zone price swing).
|
||||
5. **Station vs. capital** — stations = wide *variety* (resellers), capitals = wide *premium*
|
||||
(the universal buyer). Locked.
|
||||
6. **Gems** — **purely tradeable this pass** (the asteroid mining faucet is later).
|
||||
7. **Catalog** — the 114-goods list is **good for the first pass**.
|
||||
|
||||
**Model confirmations (the two "before you code" points):**
|
||||
- Prices are **fixed per (good, location)** — independent of where you got the good or your
|
||||
hold. Distance is enforced by **regional availability** (goods only cheap in their home
|
||||
region), not a live per-trade hop-multiplier.
|
||||
- **Credits are the go-between** — buy and sell are two separate credit transactions; there is
|
||||
no good-for-good barter.
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
/**
|
||||
* The trade economy — a pure, deterministic price + stock engine.
|
||||
*
|
||||
* MODEL (full version in docs/ECONOMY.md §5, tuning in data/economy.json):
|
||||
*
|
||||
* For a good at a place there are TWO FIXED prices in credits, decided once
|
||||
* at generation and NEVER dependent on where the player got the good or what
|
||||
* is in their hold:
|
||||
*
|
||||
* BUY price (you pay the shop) — cheap where the place's class PRODUCES
|
||||
* the good (its home region), standard
|
||||
* where a hub resells it.
|
||||
* SELL price (the shop pays you) — a premium where the class WANTS the
|
||||
* good, growing with the hops between
|
||||
* this system and the good's home region.
|
||||
*
|
||||
* Distance is enforced by REGIONAL AVAILABILITY (goods only cheap in their
|
||||
* home region), not a live per-trade multiplier. Credits are the go-between:
|
||||
* buying spends them, selling earns them, no good-for-good barter.
|
||||
*
|
||||
* Everything here is a pure function of (seed, place, catalog) — the same
|
||||
* galaxy seed always yields the same shops, so lazy === eager and saves never
|
||||
* store a shop's stock (see data/economy.json → restock).
|
||||
*
|
||||
* The two layers:
|
||||
* 1. buildEconomy(galaxy) — the generation pass (called once at roster build,
|
||||
* after the jump network + faction pass). Produces `galaxy.economy`:
|
||||
* classBySystem — Map systemId → Set of economic classes present
|
||||
* producerHops — Map goodId → { systemId → hops-to-nearest-producer }
|
||||
* Both are pure functions of the seed, so they're lazy-safe and save-free.
|
||||
* 2. buyPrice / sellPrice / shopFor — the per-shop queries the UI and tests
|
||||
* call with (goodId, class, systemId, galaxy).
|
||||
*/
|
||||
import { config } from '../config/Config.js';
|
||||
import { Rng } from '../utils/Rng.js';
|
||||
import { rollSystemComposition, settlementDensity } from '../galaxy/SystemGenerator.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog access (data/goods.json, via the config singleton)
|
||||
// ---------------------------------------------------------------------------
|
||||
let _goods = null; // cache (config is loaded once)
|
||||
const goodsList = () => (_goods ??= config.get('goods.goods', []));
|
||||
const goodById = (id) => goodsList().find((g) => g.id === id) ?? null;
|
||||
const econ = () => config.section('economy', {});
|
||||
|
||||
/**
|
||||
* The goods spritesheet (data/goods.json → sheet): the Phaser texture key
|
||||
* GameScene.preload loads it under (128×128 icon frames — 12 across, see
|
||||
* goods.json → sheet._comment for the cell math). One key shared by every
|
||||
* consumer so the shop UI and anything else that draws a good's icon
|
||||
* reference the same texture.
|
||||
*/
|
||||
export const GOODS_TEXTURE_KEY = 'goods_icons';
|
||||
|
||||
/**
|
||||
* The sprite frame index for a good (the shop UI draws
|
||||
* `scene.textures.get(GOODS_TEXTURE_KEY).get(goodFrame(id))` — Phaser frame
|
||||
* number = sheet frame N). A good's `icon` field in data/goods.json is the
|
||||
* authoritative index (stamped = the good's array position, i.e. the sheet
|
||||
* was authored in goods.json order); it falls back to that position when
|
||||
* `icon` is missing/null, and to 0 for an unknown id (a blank-ish frame
|
||||
* beats a crash).
|
||||
*
|
||||
* @param {string} goodId
|
||||
* @returns {number} the sheet frame index (0..113 today)
|
||||
*/
|
||||
export function goodFrame(goodId) {
|
||||
const list = goodsList();
|
||||
const i = list.findIndex((g) => g.id === goodId);
|
||||
if (i < 0) return 0;
|
||||
const n = Number(list[i].icon);
|
||||
return Number.isInteger(n) && n >= 0 ? n : i;
|
||||
}
|
||||
|
||||
/** True when the sheet's texture is actually available this scene. */
|
||||
export function goodsTextureReady(scene) {
|
||||
return !!(scene?.textures?.exists?.(GOODS_TEXTURE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* The economic class of a settlement — the single key the catalog prices off.
|
||||
* planet → its own class (terran/arid/rocky/gas/ice/lava), or 'capital'
|
||||
* for a capital world (class capital*).
|
||||
* station → 'station' (a deepSpaceStation trades; a waypoint does not).
|
||||
* @returns {'terran'|'arid'|'rocky'|'gas'|'ice'|'lava'|'station'|'capital'|null}
|
||||
*/
|
||||
export function economicClass(planet = null, settlement = null) {
|
||||
if (planet && typeof planet.class === 'string') {
|
||||
return planet.class.startsWith('capital') ? 'capital' : planet.class;
|
||||
}
|
||||
if (settlement && typeof settlement.kind === 'string') {
|
||||
return settlement.kind === 'deepSpaceStation' ? 'station' : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True if a class PRODUCES the good (it's on sale cheap there). */
|
||||
export function produces(goodId, cls) {
|
||||
const g = goodById(goodId);
|
||||
return !!(g && cls && Array.isArray(g.producer) && g.producer.includes(cls));
|
||||
}
|
||||
|
||||
/** True if a class WANTS the good (it buys it back at a premium). */
|
||||
export function wants(goodId, cls) {
|
||||
const g = goodById(goodId);
|
||||
return !!(g && cls && Array.isArray(g.demand) && g.demand.includes(cls));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic per-shop wobble (±economy.pricing.jitter), so no two shops are
|
||||
// identical and no value looks "formulated." Same (good, class, system) ⇒ same
|
||||
// jitter for both the buy and the sell price of that good at that shop.
|
||||
// ---------------------------------------------------------------------------
|
||||
function jitter(goodId, cls, systemId, seed) {
|
||||
const j = Number(econ().pricing?.jitter ?? 0);
|
||||
if (!(j > 0) || !seed) return 1;
|
||||
const r = Rng.derive(seed, 'economy', 'jitter', String(goodId), String(systemId), String(cls)).next();
|
||||
return 1 + (r * 2 - 1) * j;
|
||||
}
|
||||
|
||||
/** Hops from a system to the nearest system that produces the good (0 = local). */
|
||||
function hopsToProducer(goodId, systemId, galaxy) {
|
||||
const m = galaxy?.economy?.producerHops?.[goodId]?.[systemId];
|
||||
return Number.isFinite(m) ? m : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FIXED PRICES (the two credit numbers per good + shop)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The BUY price — what the player pays for one unit at this shop. Cheap where
|
||||
* the class produces the good (producerDiscount), standard where a hub resells
|
||||
* it. Fixed per (good, class, system); independent of the player's inventory.
|
||||
*/
|
||||
export function buyPrice(goodId, cls, systemId, galaxy) {
|
||||
const g = goodById(goodId);
|
||||
if (!g || !cls) return 0;
|
||||
const p = econ().pricing ?? {};
|
||||
const prod = produces(goodId, cls) ? (1 - (p.producerDiscount ?? 0)) : 1;
|
||||
return Math.max(1, Math.round((Number(g.baseValue) || 0) * prod * jitter(goodId, cls, systemId, galaxy?.seed)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The SELL price — what the shop pays the player for one unit. A premium where
|
||||
* the class wants the good (demandBonusPerClass), growing with the hops
|
||||
* between this system and the good's home region (distancePerHop, capped).
|
||||
* Fixed per (good, class, system); independent of where the player bought it.
|
||||
*/
|
||||
export function sellPrice(goodId, cls, systemId, galaxy) {
|
||||
const g = goodById(goodId);
|
||||
if (!g || !cls) return 0;
|
||||
const p = econ().pricing ?? {};
|
||||
const demand = wants(goodId, cls) ? 1 + (p.demandBonusPerClass ?? 0) : 1;
|
||||
const hops = Math.min(hopsToProducer(goodId, systemId, galaxy), p.distanceCapHops ?? 6);
|
||||
const dist = 1 + (p.distancePerHop ?? 0) * hops;
|
||||
return Math.max(1, Math.round((Number(g.baseValue) || 0) * demand * dist * jitter(goodId, cls, systemId, galaxy?.seed)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE SHOP FLOOR — goods on sale (buy list) + goods the shop buys back (sell list)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Seeded weighted pick of `n` ids from `items` (by each good's `weight`). */
|
||||
function seededSelect(items, n, seed, cls, systemId) {
|
||||
const rng = Rng.derive(seed, 'economy', 'shop', String(cls), String(systemId));
|
||||
const pool = items.slice();
|
||||
const out = [];
|
||||
const w = (g) => Math.max(1, Number(g?.weight) || 3);
|
||||
for (let i = 0; i < n && pool.length; i++) {
|
||||
const total = pool.reduce((s, g) => s + w(g), 0);
|
||||
let r = rng.next() * total;
|
||||
let idx = 0;
|
||||
for (let k = 0; k < pool.length; k++) { r -= w(pool[k]); if (r <= 0) { idx = k; break; } }
|
||||
out.push(pool[idx].id);
|
||||
pool.splice(idx, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full shop for a settlement class at a system:
|
||||
* buy — goods on SALE (what you can buy), 8–16 entries, each { id, price, produced }
|
||||
* · plain world: its produced goods (abundant, cheap)
|
||||
* · station: produced (≤ half) + a seeded selection of resold goods (variety)
|
||||
* · capital: produced (≤ half) + a seeded selection of premium goods (tier 3–4)
|
||||
* sell — goods the shop BUYS BACK (what you can sell), each { id, price }
|
||||
* · plain world / station: the goods the class wants
|
||||
* · capital: EVERYTHING (the universal sink)
|
||||
* Pure in (cls, systemId, galaxy) — deterministic and re-derivable.
|
||||
*/
|
||||
export function shopFor(cls, systemId, galaxy) {
|
||||
if (!cls) return { buy: [], sell: [] };
|
||||
const shop = econ().shop ?? {};
|
||||
const kind = cls === 'capital' ? 'capital' : cls === 'station' ? 'deepSpaceStation' : null;
|
||||
const sk = shop.stockByKind?.[kind] ?? {};
|
||||
const maxStock = sk.max ?? shop.maxStock ?? 16;
|
||||
const minStock = sk.min ?? shop.minStock ?? 8;
|
||||
const goods = goodsList();
|
||||
const seed = galaxy?.seed;
|
||||
|
||||
const core = goods.filter((g) => produces(g.id, cls));
|
||||
const isHub = cls === 'station' || cls === 'capital';
|
||||
|
||||
let buyIds;
|
||||
if (isHub) {
|
||||
// Hubs: at most half the floor is their own produce (the rest is their
|
||||
// reseller role — variety for stations, premium for capitals).
|
||||
const coreCap = Math.max(1, Math.ceil(maxStock / 2));
|
||||
const coreIds = seededSelect(core, coreCap, seed, cls, systemId);
|
||||
const other = goods.filter((g) => !produces(g.id, cls));
|
||||
const premium = cls === 'capital'
|
||||
? other.filter((g) => (Number(g.tier) || 1) >= 3)
|
||||
: other.filter((g) => (Number(g.tier) || 1) <= 3);
|
||||
const fill = seededSelect(premium.length ? premium : other, maxStock - coreIds.length, seed, cls + '-resold', systemId);
|
||||
buyIds = [...coreIds, ...fill];
|
||||
} else {
|
||||
buyIds = core.slice(0, maxStock).map((g) => g.id);
|
||||
}
|
||||
|
||||
// Guarantee the floor reaches minStock when the catalog allows it.
|
||||
if (buyIds.length < minStock) {
|
||||
const have = new Set(buyIds);
|
||||
for (const g of goods) {
|
||||
if (buyIds.length >= minStock) break;
|
||||
if (!have.has(g.id)) { buyIds.push(g.id); have.add(g.id); }
|
||||
}
|
||||
}
|
||||
|
||||
const buy = buyIds.map((id) => ({
|
||||
id,
|
||||
name: goodById(id)?.name ?? id,
|
||||
price: buyPrice(id, cls, systemId, galaxy),
|
||||
produced: produces(id, cls),
|
||||
}));
|
||||
|
||||
const sell = (cls === 'capital' ? goods : goods.filter((g) => wants(g.id, cls)))
|
||||
.map((g) => ({ id: g.id, name: g.name, price: sellPrice(g.id, cls, systemId, galaxy) }));
|
||||
|
||||
return { buy, sell };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE GENERATION PASS — galaxy.economy (classBySystem + producerHops)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Undirected adjacency from the jump network (tree edges run both ways). */
|
||||
function adjacency(galaxy) {
|
||||
const adj = new Map();
|
||||
const add = (a, b) => {
|
||||
if (!adj.has(a)) adj.set(a, new Set());
|
||||
if (!adj.has(b)) adj.set(b, new Set());
|
||||
if (a !== b) { adj.get(a).add(b); adj.get(b).add(a); }
|
||||
};
|
||||
const gates = galaxy.jumpNetwork?.gates;
|
||||
if (gates) for (const [id, targets] of gates) for (const t of targets) add(id, t);
|
||||
for (const id of galaxy.byId.keys()) if (!adj.has(id)) adj.set(id, new Set());
|
||||
return adj;
|
||||
}
|
||||
|
||||
/** Multi-source BFS: hops from every system to the nearest of `sources`. */
|
||||
function multiSourceBFS(sources, adj) {
|
||||
const dist = {};
|
||||
const queue = [];
|
||||
for (const s of new Set(sources)) { if (adj.has(s) || !adj.size) { dist[s] = 0; queue.push(s); } }
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
for (const nb of adj.get(cur) ?? []) {
|
||||
if (dist[nb] === undefined) { dist[nb] = dist[cur] + 1; queue.push(nb); }
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ECONOMY PASS — the galaxy-level trade map, computed once at roster build
|
||||
* (after the jump network + faction pass, before any content generation). A
|
||||
* pure function of the seed:
|
||||
* classBySystem — Map systemId → Set of economic classes (the planet classes
|
||||
* from the SAME roll the content generator uses, + the home
|
||||
* world's class at home, + 'station' if it has a deep-space
|
||||
* station, + 'capital' if it's a designated capital).
|
||||
* producerHops — Map goodId → { systemId → hops to the nearest producer }.
|
||||
* The distance term for sell prices: the further a system
|
||||
* is from a good's home region, the higher the good sells.
|
||||
* Stashed on `galaxy.economy` and read by buyPrice/sellPrice/shopFor.
|
||||
*/
|
||||
export function buildEconomy(galaxy) {
|
||||
const typeDefs = galaxy.typeDefs ?? config.get('systems.types', {});
|
||||
const homeCls = config.get('planets.homePlanet', 'terran');
|
||||
|
||||
const classBySystem = new Map();
|
||||
for (const [id, record] of galaxy.byId) {
|
||||
const isHome = id === galaxy.homeSystemId;
|
||||
const type = typeDefs[record.type] ?? {};
|
||||
const spec = type.attributes?.settlements ?? {};
|
||||
const attr = type.attributes ?? {};
|
||||
const density = settlementDensity(galaxy, record);
|
||||
const comp = rollSystemComposition(galaxy.seed, record, isHome, spec, density, attr);
|
||||
const set = new Set(comp.classes);
|
||||
if (isHome) set.add(homeCls); // the home world (a terran body by rule)
|
||||
if (comp.deepSpace) set.add('station');
|
||||
if (record.capital) set.add('capital');
|
||||
classBySystem.set(id, set);
|
||||
}
|
||||
|
||||
const adj = adjacency(galaxy);
|
||||
|
||||
const producerHops = {};
|
||||
for (const g of goodsList()) {
|
||||
const producers = [];
|
||||
for (const [id, set] of classBySystem) {
|
||||
if (Array.isArray(g.producer) && g.producer.some((c) => set.has(c))) producers.push(id);
|
||||
}
|
||||
producerHops[g.id] = multiSourceBFS(producers, adj);
|
||||
}
|
||||
|
||||
galaxy.economy = { classBySystem, producerHops };
|
||||
return galaxy.economy;
|
||||
}
|
||||
|
|
@ -115,6 +115,13 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
|
|||
// strips the rock; the cap is the base stat above.
|
||||
this.minerals = 0;
|
||||
|
||||
// CREDITS — the trade currency (data/economy.json → credits).
|
||||
// Displayed next to the minerals HUD. The player starts at 0
|
||||
// (startingCredits); earning = selling goods + quest rewards, spending
|
||||
// = builds/upgrades/buying goods. Unlike minerals, credits have no
|
||||
// cap (they're a ledger, not a hold). See js/economy/Economy.js.
|
||||
this.credits = Math.max(0, Math.round(Number(config.get('economy.credits.startingCredits', 0)) || 0));
|
||||
|
||||
// World size = size × scale. Sheet frames are frameWidth px wide; the
|
||||
// dart is drawn at `size` px — the sprite scale maps either one onto
|
||||
// the same world size, so both look identical.
|
||||
|
|
@ -196,6 +203,45 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
|
|||
this.minerals = Math.max(0, Math.min(this.stats.mineralStorage, Math.round(Number(n) || 0)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CREDITS (the trade currency) — no cap, floored at 0
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** True when the credit ledger can cover `n`. */
|
||||
canAfford(n) {
|
||||
return this.credits >= Math.max(0, Math.round(Number(n) || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add credits (quest rewards, selling goods). No cap. Returns the amount
|
||||
* actually added (the caller sees a negative n as 0).
|
||||
*/
|
||||
addCredits(n) {
|
||||
const amount = Math.max(0, Math.round(Number(n) || 0));
|
||||
this.credits += amount;
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend credits (buying goods, builds, upgrades). No-ops when the ledger
|
||||
* can't cover it. Returns true when the spend went through.
|
||||
*/
|
||||
spendCredits(n) {
|
||||
const amount = Math.max(0, Math.round(Number(n) || 0));
|
||||
if (this.credits < amount) return false;
|
||||
this.credits -= amount;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the credit ledger directly (the SAVE/LOAD path — js/save/SaveData.js
|
||||
* → captureState writes it, GameScene.applyRestore restores it). Floored
|
||||
* at 0 — a record can never go into the red.
|
||||
*/
|
||||
setCredits(n) {
|
||||
this.credits = Math.max(0, Math.round(Number(n) || 0));
|
||||
}
|
||||
|
||||
/** Set the destination to fly to (world coordinates). */
|
||||
setTarget(x, y) {
|
||||
this.target = { x, y };
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { buildJumpNetwork } from './JumpNetwork.js';
|
|||
import { assignPlanetFrames } from './PlanetFrames.js';
|
||||
import { assignStationFrames } from './StationFrames.js';
|
||||
import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
|
||||
import { buildEconomy } from '../economy/Economy.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
|
|
@ -331,6 +332,16 @@ export class Galaxy {
|
|||
neighborsOf: (id) => this.neighborsOf(id, pool8),
|
||||
});
|
||||
this.stationFrames = stationFrames; // Map id → frame (station-bearing systems)
|
||||
|
||||
// TRADE ECONOMY (js/economy/Economy.js): the galaxy-level trade map —
|
||||
// per-system economic classes + each good's hop-distance to its nearest
|
||||
// producer. A pure function of the seed (it re-derives the same
|
||||
// composition roll the content generator uses), so it's lazy-safe and
|
||||
// save-free; the price engine (Economy.buyPrice/sellPrice/shopFor) reads
|
||||
// it to fix each shop's prices. Distance is enforced by regional
|
||||
// availability — a good is cheap only in its home region — not by a live
|
||||
// per-trade multiplier (docs/ECONOMY.md §5).
|
||||
buildEconomy(this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ export function captureState(scene, now) {
|
|||
// The hold (minerals aboard) — saves that predate it just lack the
|
||||
// field; the restore treats a missing number as 0.
|
||||
minerals: Math.max(0, Math.round(Number(scene.ship.minerals) || 0)),
|
||||
// The credit ledger (data/economy.json → credits) — the trade
|
||||
// currency. Saves predating it lack the field; the restore treats
|
||||
// the absence as 0 (old saves load).
|
||||
credits: Math.max(0, Math.round(Number(scene.ship.credits) || 0)),
|
||||
},
|
||||
discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} },
|
||||
reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(),
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import { parseTypeSpec, pickPreviewSystem } from '../galaxy/TypeSystems.js';
|
|||
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
||||
import { ActionBar } from '../ui/ActionBar.js';
|
||||
import { MineralHud } from '../ui/MineralHud.js';
|
||||
import { CreditsHud } from '../ui/CreditsHud.js';
|
||||
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
|
||||
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||
import { SavePanel } from '../ui/SavePanel.js';
|
||||
import { SaveManager } from '../save/SaveManager.js';
|
||||
|
|
@ -190,6 +192,23 @@ export class GameScene extends Phaser.Scene {
|
|||
);
|
||||
}
|
||||
|
||||
// The goods spritesheet (data/goods.json → sheet): 128×128 icon frames
|
||||
// (12 across, 120 cells) — one per trade good, in goods.json order
|
||||
// (frame N = goods[N]). Loaded now so the icons are ready when the
|
||||
// shop UI lands (trading is locked until the third story quest); an
|
||||
// empty/blank file is harmless (120 transparent frames).
|
||||
const goodsTexture = config.get('goods.sheet.texture', '');
|
||||
if (goodsTexture) {
|
||||
this.load.spritesheet(
|
||||
GOODS_TEXTURE_KEY,
|
||||
goodsTexture,
|
||||
{
|
||||
frameWidth: config.get('goods.sheet.frameWidth', 128),
|
||||
frameHeight: config.get('goods.sheet.frameHeight', 128),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// The research console's archive feed (data/research.json → video):
|
||||
// a muted 2:3 loop behind the RESEARCH window. A missing file just
|
||||
// leaves the window's NO SIGNAL plate up — the console still works.
|
||||
|
|
@ -867,7 +886,14 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
// The MINERALS readout — upper right (the corner the old tether stack
|
||||
// used to occupy): the hold's fill as a bar + counting number.
|
||||
this.mineralHud = new MineralHud(this);
|
||||
// The CREDITS readout stacks ABOVE it (the trade currency — see
|
||||
// js/economy/Economy.js + data/creditshud.json); the minerals HUD is
|
||||
// shifted down by the credits line's height so the two read as one
|
||||
// console, and the comms box (anchored to the minerals bar's bottom)
|
||||
// keeps clear of both.
|
||||
this.creditsHud = new CreditsHud(this);
|
||||
this.creditsHud.set(this.ship.credits);
|
||||
this.mineralHud = new MineralHud(this, { topOffset: this.creditsHud.height + this.creditsHud.gap });
|
||||
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
|
||||
|
||||
// ---- COMMS (the characters speak to the player — data/comms.json) --
|
||||
|
|
@ -3050,6 +3076,17 @@ export class GameScene extends Phaser.Scene {
|
|||
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the ship's credit ledger into the upper-right readout
|
||||
* (js/ui/CreditsHud.js) — the line stacked above the minerals HUD. A
|
||||
* no-op when unchanged (no re-count), so it's safe to call from every
|
||||
* credit seam (quest claims, selling goods, …).
|
||||
*/
|
||||
refreshCreditsHud() {
|
||||
if (!this.creditsHud || !this.ship) return;
|
||||
this.creditsHud.set(this.ship.credits);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mining hum (data/sfx.json → mining_loop, assets/fx/mining-01.mp3):
|
||||
* a loop that starts when the beam goes live (phase 'mining') and
|
||||
|
|
@ -3507,16 +3544,25 @@ export class GameScene extends Phaser.Scene {
|
|||
if (!this.questState.claim(id)) return; // someone beat us to it — idempotent
|
||||
// The reward: minerals into the hold (clamped to the ship's storage —
|
||||
// addMinerals hands back what fit, so a full hold just wastes the
|
||||
// excess, the game's rule for mined ore).
|
||||
// excess, the game's rule for mined ore) + credits onto the ledger
|
||||
// (data/quests.json → reward.credits; none of the first-pass quests pay
|
||||
// credits yet, but the seam is live for the quest rewards that will).
|
||||
const rw = def.reward ?? {};
|
||||
const rewardParts = [];
|
||||
if (typeof rw.minerals === 'number' && this.ship) {
|
||||
this.ship.addMinerals(rw.minerals);
|
||||
this.refreshMineralHud();
|
||||
rewardParts.push(`+${rw.minerals} MINERALS`);
|
||||
}
|
||||
if (typeof rw.credits === 'number' && this.ship) {
|
||||
this.ship.addCredits(rw.credits);
|
||||
this.refreshCreditsHud();
|
||||
rewardParts.push(`+${rw.credits} CREDITS`);
|
||||
}
|
||||
this.playSfx('construct'); // the power-up tick — the reward lands
|
||||
const t = config.get('quests.claimToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'REWARD CLAIMED — +{n} MINERALS').replace('{n}', String(rw.minerals ?? 0)),
|
||||
String(t.text ?? 'REWARD CLAIMED — {reward}').replace('{reward}', rewardParts.join(' · ') || 'REWARD'),
|
||||
{
|
||||
glyph: t.glyph ?? '✓',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
|
||||
|
|
@ -4910,6 +4956,9 @@ export class GameScene extends Phaser.Scene {
|
|||
// The hold — saves that predate minerals have no field (stay at 0);
|
||||
// setMinerals clamps to the ship's capacity.
|
||||
if (typeof r.ship.minerals === 'number') this.ship.setMinerals(r.ship.minerals);
|
||||
// The credit ledger (data/economy.json) — a save predating credits
|
||||
// has no field; setCredits floors a missing/invalid value at 0.
|
||||
this.ship.setCredits(r.ship.credits ?? 0);
|
||||
}
|
||||
// The corner readout (upper right) was built with the FRESH hold (0)
|
||||
// earlier in create() — push the restored value through it. No-op
|
||||
|
|
@ -5030,6 +5079,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.commsPanel?.destroy();
|
||||
this.researchWindow?.destroy(); // the console (video + UI)
|
||||
this.mapWindow?.destroy(); // the map console (video + chart canvas)
|
||||
this.creditsHud?.destroy();
|
||||
this.mineralHud?.destroy();
|
||||
this.mining?.destroy();
|
||||
this.scanPulse?.destroy();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { config } from '../config/Config.js';
|
||||
import { toColor, toCss } from '../utils/Color.js';
|
||||
import { fontStack } from '../utils/Theme.js';
|
||||
|
||||
/**
|
||||
* CREDITS HUD — the trade-currency readout, UPPER-RIGHT, stacked ABOVE the
|
||||
* minerals HUD (data/creditshud.json). One right-aligned line:
|
||||
*
|
||||
* CREDITS 1,250
|
||||
*
|
||||
* `set(n)` counts the number up when credits land (a single tween, gen-token
|
||||
* guarded so quick successive updates can't snap it back). Gold (theme 'amber')
|
||||
* reads as value and stays distinct from the cyan minerals line below it.
|
||||
*
|
||||
* The scene stacks the two readouts: it builds this first (top), then builds
|
||||
* the MineralHud with `topOffset = creditsHud.height + creditsHud.gap`, so
|
||||
* the comms box (which anchors to the minerals bar's bottom) keeps clear of
|
||||
* both.
|
||||
*
|
||||
* Component usage (scenes own one instance, like MineralHud):
|
||||
*
|
||||
* this.creditsHud = new CreditsHud(this);
|
||||
* this.creditsHud.set(ship.credits);
|
||||
* // …whenever credits change:
|
||||
* this.creditsHud.set(ship.credits);
|
||||
* shutdown() { this.creditsHud.destroy(); }
|
||||
*/
|
||||
export class CreditsHud extends Phaser.GameObjects.Container {
|
||||
/** @param {Phaser.Scene} scene */
|
||||
constructor(scene) {
|
||||
super(scene, 0, 0);
|
||||
this.scene.add.existing(this);
|
||||
this.setScrollFactor(0); // UI — pinned to the screen, not the world
|
||||
this.setDepth(30); // with the minerals HUD and the other corner readouts
|
||||
|
||||
const hud = config.section('creditshud', {});
|
||||
this.pad = hud.pad ?? 16;
|
||||
this.gap = Math.max(0, hud.gap ?? 8); // space the scene leaves below this line
|
||||
const lab = hud.label ?? {};
|
||||
const cu = hud.countUp ?? {};
|
||||
this.countUpMs = Math.max(0, cu.durationMs ?? 420);
|
||||
this.countUpEase = cu.ease ?? 'Sine.easeOut';
|
||||
this.int = toColor(hud.color, 0xffc94d);
|
||||
|
||||
const W = scene.scale.width;
|
||||
const right = W - this.pad;
|
||||
const fam = fontStack('body');
|
||||
|
||||
this.label = scene.add
|
||||
.text(0, 0, '', {
|
||||
fontFamily: fam,
|
||||
fontSize: `${lab.fontSize ?? 12}px`,
|
||||
color: toCss(this.int),
|
||||
letterSpacing: lab.letterSpacing ?? 1,
|
||||
})
|
||||
.setOrigin(1, 0) // right edge sticks to the corner margin
|
||||
.setDepth(30)
|
||||
.setScrollFactor(0);
|
||||
this.label.setPosition(right, this.pad);
|
||||
|
||||
// State: `value` is settled (where the animation lands). A set() with the
|
||||
// same value is a no-op (no re-count).
|
||||
this.value = 0;
|
||||
this._gen = 0; // supersede guard: a new set() voids any in-flight tween
|
||||
|
||||
this.label.setText(this.format(0));
|
||||
}
|
||||
|
||||
/** The rendered line height (the scene uses this to stack the minerals HUD). */
|
||||
get height() {
|
||||
return this.label.height;
|
||||
}
|
||||
|
||||
/** The label text for a settled value. */
|
||||
format(n) {
|
||||
return `CREDITS ${Math.max(0, Math.round(n)).toLocaleString('en-US')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the credit ledger: `n` credits aboard. Counts the number up from the
|
||||
* last settled value (so it always reads from where the player last saw it).
|
||||
* A no-op when unchanged.
|
||||
*
|
||||
* @returns {boolean} true when the value actually changed
|
||||
*/
|
||||
set(n) {
|
||||
const v = Math.max(0, Math.round(Number(n) || 0));
|
||||
if (v === this.value) return false;
|
||||
const from = this.value;
|
||||
this.value = v;
|
||||
if (this.countUpMs <= 0) {
|
||||
this.label.setText(this.format(v));
|
||||
return true;
|
||||
}
|
||||
const gen = ++this._gen;
|
||||
const live = () => gen === this._gen;
|
||||
const holder = { v: from };
|
||||
this.scene.tweens.add({
|
||||
targets: holder,
|
||||
v,
|
||||
duration: this.countUpMs,
|
||||
ease: this.countUpEase,
|
||||
onUpdate: () => {
|
||||
if (!live()) return;
|
||||
this.label.setText(this.format(holder.v));
|
||||
},
|
||||
onComplete: () => {
|
||||
if (!live()) return;
|
||||
this.label.setText(this.format(v)); // land exactly, whatever the clock did
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Tear the readout down (scene shutdown). */
|
||||
destroy() {
|
||||
this.label?.destroy();
|
||||
this.label = null;
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -30,8 +30,13 @@ import { fontStack, themeColor } from '../utils/Theme.js';
|
|||
* shutdown() { this.mineralHud.destroy(); }
|
||||
*/
|
||||
export class MineralHud extends Phaser.GameObjects.Container {
|
||||
/** @param {Phaser.Scene} scene */
|
||||
constructor(scene) {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {{topOffset?: number}} [options] — `topOffset` shifts the whole
|
||||
* block down by that many px (the scene passes the credits line's height
|
||||
* so the minerals readout stacks BELOW it; default 0 = the corner top).
|
||||
*/
|
||||
constructor(scene, options = {}) {
|
||||
super(scene, 0, 0);
|
||||
// v4 quirk: a directly-constructed GameObject is NOT added to the
|
||||
// scene's display list — register it or it never renders.
|
||||
|
|
@ -41,6 +46,7 @@ export class MineralHud extends Phaser.GameObjects.Container {
|
|||
|
||||
const hud = config.section('mineralhud', {});
|
||||
this.pad = hud.pad ?? 16;
|
||||
this.topOffset = Math.max(0, Number(options.topOffset) || 0); // credits line above
|
||||
this.lineGap = hud.lineGap ?? 8;
|
||||
const lab = hud.label ?? {};
|
||||
const bar = hud.bar ?? {};
|
||||
|
|
@ -73,6 +79,7 @@ export class MineralHud extends Phaser.GameObjects.Container {
|
|||
|
||||
const W = scene.scale.width;
|
||||
const right = W - this.pad;
|
||||
const top = this.pad + this.topOffset; // below the stacked credits line
|
||||
const fam = fontStack('body');
|
||||
|
||||
// --- The number: MINERALS n / N (right-aligned, top-anchored) ------
|
||||
|
|
@ -86,11 +93,11 @@ export class MineralHud extends Phaser.GameObjects.Container {
|
|||
.setOrigin(1, 0) // right edge sticks to the corner margin
|
||||
.setDepth(30) // with the dossier — the scene-level objects render at 0 otherwise
|
||||
.setScrollFactor(0); // screen-fixed — the camera scrolls the world, these don't
|
||||
this.label.setPosition(right, this.pad);
|
||||
this.label.setPosition(right, top);
|
||||
|
||||
// --- The bar (below the label, right-aligned): track + fill + flash -
|
||||
this.barX = right - this.barW;
|
||||
this.barY = this.pad + this.label.height + this.lineGap;
|
||||
this.barY = top + this.label.height + this.lineGap;
|
||||
this.innerW = this.barW - 2 * this.inset;
|
||||
this.innerH = this.barH - 2 * this.inset;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue