The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days. Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates. Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
247 lines
10 KiB
JavaScript
247 lines
10 KiB
JavaScript
// The global market (06-global-market.md): one shared world stock per storable
|
|
// good, a price derived from that stock, and a buy/sell order in fixed lots.
|
|
// The hard economy keeps its drifting reference prices and has no market order.
|
|
|
|
import { TestCase } from "./framework/test_case.js";
|
|
import { simpleState, smallState, smallConfig, SEED } from "./framework/helpers.js";
|
|
import {
|
|
RESOURCE_IDS,
|
|
STORABLE_RESOURCE_IDS,
|
|
RESOURCE_MARKET_BASE,
|
|
MARKET_START_STOCK,
|
|
MARKET_LOTS,
|
|
} from "../shared/data.js";
|
|
import { GameServer } from "../server/game_server.js";
|
|
|
|
function configuredServer(economyModel) {
|
|
const server = new GameServer({ getPeerIds: () => [], send: () => {} });
|
|
server.configureGame({
|
|
seed: SEED,
|
|
player_civ: 0,
|
|
civilisations: ["france", "britain"],
|
|
mapConfig: smallConfig(),
|
|
economyModel,
|
|
});
|
|
server.peerCiv.set(1, 0);
|
|
return server;
|
|
}
|
|
|
|
export class SimpleMarketTest extends TestCase {
|
|
test_the_opening_price_is_the_base_price() {
|
|
const state = simpleState();
|
|
for (const id of STORABLE_RESOURCE_IDS) {
|
|
this.assertEqual(state.marketStock.get(id), MARKET_START_STOCK, `${id} opens full`);
|
|
this.assertApprox(state.marketPrice(id), RESOURCE_MARKET_BASE[id], 1e-9, `${id} at base`);
|
|
this.assertApprox(
|
|
state.getResourcePrice(id),
|
|
RESOURCE_MARKET_BASE[id],
|
|
1e-9,
|
|
`${id} reads at base`
|
|
);
|
|
}
|
|
// Energy is a regional flow, not a market good: it keeps its base price and
|
|
// is never stocked.
|
|
this.assertEqual(state.marketStock.has("energy"), false, "energy is not stocked");
|
|
this.assertApprox(
|
|
state.getResourcePrice("energy"),
|
|
RESOURCE_MARKET_BASE.energy,
|
|
1e-9,
|
|
"energy keeps its base price"
|
|
);
|
|
}
|
|
|
|
test_buying_drains_the_stock_and_raises_the_price() {
|
|
const state = simpleState();
|
|
const pool = state.getResourcePool(0);
|
|
const before = pool.steel;
|
|
const treasuryBefore = state.getBudget(0);
|
|
const price = state.marketPrice("steel");
|
|
const stockBefore = state.marketStock.get("steel");
|
|
this.assertTrue(state.requestMarketTrade(0, "steel", "buy", 2000), "the buy is accepted");
|
|
this.assertApprox(pool.steel, before + 2000, 1e-9, "the pool gained exactly the lot");
|
|
this.assertApprox(
|
|
state.getBudget(0),
|
|
treasuryBefore - 2000 * price,
|
|
1e-6,
|
|
"the treasury paid qty x price"
|
|
);
|
|
this.assertApprox(state.marketStock.get("steel"), stockBefore - 2000, 1e-9, "the stock fell");
|
|
this.assertGreater(state.marketPrice("steel"), price, "and the price rose");
|
|
}
|
|
|
|
test_selling_fills_the_stock_and_lowers_the_price() {
|
|
const state = simpleState();
|
|
const pool = state.getResourcePool(0);
|
|
pool.food = 100_000;
|
|
const before = pool.food;
|
|
const treasuryBefore = state.getBudget(0);
|
|
const price = state.marketPrice("food");
|
|
const stockBefore = state.marketStock.get("food");
|
|
this.assertTrue(state.requestMarketTrade(0, "food", "sell", 2000), "the sell is accepted");
|
|
this.assertApprox(pool.food, before - 2000, 1e-9, "the pool gave up exactly the lot");
|
|
this.assertApprox(
|
|
state.getBudget(0),
|
|
treasuryBefore + 2000 * price,
|
|
1e-6,
|
|
"the treasury was credited qty x price"
|
|
);
|
|
this.assertApprox(state.marketStock.get("food"), stockBefore + 2000, 1e-9, "the stock rose");
|
|
this.assertLess(state.marketPrice("food"), price, "and the price fell");
|
|
}
|
|
|
|
test_only_the_three_lots_are_accepted() {
|
|
const state = simpleState();
|
|
state.getResourcePool(0).steel = 1e9;
|
|
state.budgets.set(0, 1e15);
|
|
for (const lot of MARKET_LOTS) {
|
|
this.assertTrue(
|
|
state.requestMarketTrade(0, "steel", "buy", lot),
|
|
`the ${lot} lot is accepted`
|
|
);
|
|
}
|
|
for (const bad of [1, 50, 999, 2500, 100_000, 0, -100, NaN, undefined]) {
|
|
this.assertFalse(
|
|
state.requestMarketTrade(0, "steel", "buy", bad),
|
|
`the ${bad} quantity is refused`
|
|
);
|
|
}
|
|
}
|
|
|
|
test_a_refused_trade_moves_nothing() {
|
|
const state = simpleState();
|
|
const pool = state.getResourcePool(0);
|
|
const poolBefore = pool.hightech;
|
|
const stockBefore = state.marketStock.get("hightech");
|
|
// A treasury that cannot afford the lot.
|
|
state.budgets.set(0, 1);
|
|
this.assertFalse(state.requestMarketTrade(0, "hightech", "buy", 100), "no funds, no trade");
|
|
this.assertEqual(state.getBudget(0), 1, "the treasury is untouched");
|
|
this.assertEqual(pool.hightech, poolBefore, "the pool is untouched");
|
|
this.assertEqual(state.marketStock.get("hightech"), stockBefore, "the stock is untouched");
|
|
|
|
// A pool that does not hold the goods being sold.
|
|
state.budgets.set(0, 1e12);
|
|
pool.steel = 50;
|
|
this.assertFalse(state.requestMarketTrade(0, "steel", "sell", 100), "nothing to sell");
|
|
this.assertEqual(pool.steel, 50, "the pool keeps what it had");
|
|
this.assertEqual(state.getBudget(0), 1e12, "and the treasury does not move");
|
|
|
|
// A world stock that cannot cover the buy.
|
|
state.marketStock.set("steel", 50);
|
|
this.assertFalse(state.requestMarketTrade(0, "steel", "buy", 100), "the world is short");
|
|
this.assertEqual(state.marketStock.get("steel"), 50, "the stock keeps what it had");
|
|
|
|
// A malformed side or good.
|
|
this.assertFalse(state.requestMarketTrade(0, "steel", "hold", 100), "a bad side is refused");
|
|
this.assertFalse(state.requestMarketTrade(0, "energy", "buy", 100), "energy is not traded");
|
|
this.assertFalse(state.requestMarketTrade(0, "nonsense", "buy", 100), "an unknown good");
|
|
}
|
|
|
|
test_the_market_is_shared_by_every_player() {
|
|
const state = simpleState();
|
|
const before = state.marketPrice("steel");
|
|
this.assertTrue(state.requestMarketTrade(0, "steel", "buy", 50_000), "civ 0 buys");
|
|
const after = state.marketPrice("steel");
|
|
this.assertGreater(after, before, "the buy moved the price");
|
|
this.assertApprox(state.marketPrice("steel"), after, 1e-9, "civ 1 reads the same market");
|
|
this.assertApprox(state.getResourcePrice("steel"), after, 1e-9, "and the price read agrees");
|
|
const view = state.getMarketView(1).find((entry) => entry.id === "steel");
|
|
this.assertApprox(view.price, after, 1e-9, "the market view shows the shared price");
|
|
this.assertApprox(view.stock, MARKET_START_STOCK - 50_000, 1e-9, "and the shared stock");
|
|
}
|
|
|
|
test_a_near_empty_stock_stays_finite() {
|
|
const state = simpleState();
|
|
for (const stock of [0, -5, NaN, undefined]) {
|
|
state.marketStock.set("steel", stock);
|
|
const price = state.marketPrice("steel");
|
|
this.assertTrue(Number.isFinite(price), `a stock of ${stock} gives a finite price`);
|
|
this.assertGreater(price, 0, "and a positive one");
|
|
}
|
|
}
|
|
|
|
test_the_simple_tick_leaves_the_price_to_the_market() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
const steel = state.marketPrice("steel");
|
|
const market = new Map();
|
|
for (const id of RESOURCE_IDS) {
|
|
market.set(id, { supply: 0, demand: 1e6, price: state.getResourcePrice(id) });
|
|
}
|
|
state._updateMarketPrices(market);
|
|
this.assertApprox(state.marketPrice("steel"), steel, 1e-9, "no supply/demand drift");
|
|
for (let hour = 0; hour < 24; hour++) state.advanceHour();
|
|
this.assertApprox(state.marketPrice("steel"), steel, 1e-9, "a whole day does not drift it");
|
|
}
|
|
|
|
test_the_snapshot_carries_the_stock_and_price() {
|
|
const state = simpleState();
|
|
const market = state.snapshot(0).resourceMarket;
|
|
this.assertSize(market, RESOURCE_IDS.length, "one entry per resource");
|
|
const steel = market.find((entry) => entry.id === "steel");
|
|
this.assertEqual(steel.stock, MARKET_START_STOCK, "the stock ships");
|
|
this.assertApprox(steel.price, RESOURCE_MARKET_BASE.steel, 1e-9, "the price ships");
|
|
const energy = market.find((entry) => entry.id === "energy");
|
|
this.assertEqual(energy.stock, null, "energy ships no stock");
|
|
}
|
|
|
|
test_a_trade_bumps_the_market_version() {
|
|
const state = simpleState();
|
|
const before = state.snapshot(0).versions.market;
|
|
this.assertGreater(before, 0, "the version starts set");
|
|
this.assertTrue(state.requestMarketTrade(0, "food", "sell", 100), "a trade is accepted");
|
|
this.assertGreater(state.snapshot(0).versions.market, before, "the version moved with it");
|
|
}
|
|
|
|
test_the_server_validates_a_market_trade() {
|
|
const server = configuredServer("simple");
|
|
const state = server.state;
|
|
const pool = state.getResourcePool(0);
|
|
const steelBefore = pool.steel;
|
|
this.assertTrue(
|
|
server.handleOrder(1, { t: "market_trade", side: "buy", resource: "steel", lot: 2000 }),
|
|
"a valid trade is accepted"
|
|
);
|
|
this.assertEqual(pool.steel, steelBefore + 2000, "and the pool moved");
|
|
this.assertFalse(
|
|
server.handleOrder(1, { t: "market_trade", side: "buy", resource: "steel", lot: 1234 }),
|
|
"a bad lot is refused"
|
|
);
|
|
this.assertFalse(
|
|
server.handleOrder(1, { t: "market_trade", side: "hold", resource: "steel", lot: 100 }),
|
|
"a bad side is refused"
|
|
);
|
|
this.assertFalse(
|
|
server.handleOrder(1, { t: "market_trade", side: "buy", resource: "energy", lot: 100 }),
|
|
"energy cannot be bought"
|
|
);
|
|
this.assertFalse(
|
|
server.handleOrder(1, { t: "market_trade", side: "buy", resource: "nonsense", lot: 100 }),
|
|
"an unknown good is refused"
|
|
);
|
|
}
|
|
|
|
test_the_hard_server_has_no_market_order() {
|
|
const server = configuredServer("hard");
|
|
this.assertFalse(
|
|
server.handleOrder(1, { t: "market_trade", side: "buy", resource: "steel", lot: 2000 }),
|
|
"the hard model refuses the market order"
|
|
);
|
|
}
|
|
|
|
test_the_hard_prices_still_drift_with_supply_and_demand() {
|
|
const state = smallState();
|
|
const base = state.getResourcePrice("food");
|
|
this.assertApprox(base, RESOURCE_MARKET_BASE.food, 1e-9, "a hard game opens at base");
|
|
const market = new Map();
|
|
for (const id of RESOURCE_IDS) {
|
|
market.set(id, { supply: 1000, demand: 0, price: state.getResourcePrice(id) });
|
|
}
|
|
market.get("food").supply = 1;
|
|
market.get("food").demand = 1000;
|
|
state._updateMarketPrices(market);
|
|
this.assertGreater(state.getResourcePrice("food"), base, "a shortage lifts the hard price");
|
|
this.assertEqual(typeof state.marketPrice, "undefined", "the hard model has no market stock API");
|
|
}
|
|
}
|