feat: economy with lazy stock advancement and price curve
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
export const GOODS = ['ore', 'food', 'fuel', 'tech', 'meds', 'luxury'];
|
||||||
|
export const BASE_PRICE = { ore: 10, food: 8, fuel: 12, tech: 40, meds: 30, luxury: 60 };
|
||||||
|
export const TYPICAL_STOCK = 120; // stock at which price == base price
|
||||||
|
export const MAX_STOCK = 2000;
|
||||||
|
|
||||||
|
export const NEWS_DAYS_PER_LANE = 1; // price news travels 1 lane per day
|
||||||
|
export const SNAPSHOT_KEEP = 30; // days of price history kept per planet
|
||||||
|
|
||||||
|
export const DOCK_DELAY = 0.25; // days an NPC idles between arrival and replanning
|
||||||
|
export const INSYS_LEG_DAYS = 0.3; // planet<->planet or planet<->gate hop inside a system
|
||||||
|
export const OBSERVE_INTERVAL = 0.1; // how often the player's system marks ships as seen
|
||||||
|
export const VIS_BAND = 0.15; // fraction of a lane near a system that is "inside" it
|
||||||
|
|
||||||
|
export const WIN_CREDITS = 100000;
|
||||||
|
export const PLAYER_START_CREDITS = 1000;
|
||||||
|
export const PLAYER_CAPACITY = 30;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { GOODS, BASE_PRICE, TYPICAL_STOCK, MAX_STOCK } from './constants.js';
|
||||||
|
|
||||||
|
// Deterministic price curve: scarce -> expensive. Clamped to [0.25x, 4x].
|
||||||
|
export function priceOf(good, stock) {
|
||||||
|
const ratio = TYPICAL_STOCK / Math.max(stock, 1);
|
||||||
|
const mult = Math.min(4, Math.max(0.25, ratio));
|
||||||
|
return Math.round(BASE_PRICE[good] * mult * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily advance a planet's stocks to time t (no per-tick simulation).
|
||||||
|
export function advancePlanet(planet, t) {
|
||||||
|
const dt = t - planet.stockTime;
|
||||||
|
if (dt <= 0) return;
|
||||||
|
for (const g of GOODS) {
|
||||||
|
const rate = (planet.produces[g] || 0) - (planet.consumes[g] || 0);
|
||||||
|
planet.stock[g] = Math.min(MAX_STOCK, Math.max(0, planet.stock[g] + rate * dt));
|
||||||
|
}
|
||||||
|
planet.stockTime = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute a trade at the current price. deltaQty > 0 delivers goods,
|
||||||
|
// deltaQty < 0 removes them. Returns the unit price the trade executed at.
|
||||||
|
export function applyTrade(planet, t, good, deltaQty) {
|
||||||
|
advancePlanet(planet, t);
|
||||||
|
const price = priceOf(good, planet.stock[good]);
|
||||||
|
planet.stock[good] = Math.min(MAX_STOCK, Math.max(0, planet.stock[good] + deltaQty));
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marketView(planet, t) {
|
||||||
|
advancePlanet(planet, t);
|
||||||
|
return GOODS.map((g) => ({
|
||||||
|
good: g,
|
||||||
|
stock: Math.floor(planet.stock[g]),
|
||||||
|
price: priceOf(g, planet.stock[g]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { priceOf, advancePlanet, marketView, applyTrade } from '../src/sim/economy.js';
|
||||||
|
import { BASE_PRICE, GOODS, TYPICAL_STOCK } from '../src/sim/constants.js';
|
||||||
|
|
||||||
|
function makePlanet() {
|
||||||
|
return {
|
||||||
|
id: 0, systemId: 0, name: 'Test Prime',
|
||||||
|
produces: { food: 10 }, consumes: { fuel: 5 },
|
||||||
|
stock: { ore: 100, food: 100, fuel: 100, tech: 100, meds: 100, luxury: 100 },
|
||||||
|
stockTime: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('economy', () => {
|
||||||
|
it('price falls as stock rises, clamped to [0.25x, 4x] base', () => {
|
||||||
|
expect(priceOf('ore', TYPICAL_STOCK)).toBeCloseTo(BASE_PRICE.ore, 1);
|
||||||
|
expect(priceOf('ore', 10)).toBeGreaterThan(priceOf('ore', 500));
|
||||||
|
expect(priceOf('ore', 0)).toBe(BASE_PRICE.ore * 4);
|
||||||
|
expect(priceOf('ore', 1e9)).toBe(BASE_PRICE.ore * 0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advancePlanet applies production/consumption over elapsed time', () => {
|
||||||
|
const p = makePlanet();
|
||||||
|
advancePlanet(p, 10);
|
||||||
|
expect(p.stock.food).toBeCloseTo(200, 5); // +10/day
|
||||||
|
expect(p.stock.fuel).toBeCloseTo(50, 5); // -5/day
|
||||||
|
expect(p.stock.ore).toBeCloseTo(100, 5); // untouched
|
||||||
|
expect(p.stockTime).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advancePlanet never goes negative and is idempotent', () => {
|
||||||
|
const p = makePlanet();
|
||||||
|
advancePlanet(p, 100);
|
||||||
|
expect(p.stock.fuel).toBe(0);
|
||||||
|
const snapshot = { ...p.stock };
|
||||||
|
advancePlanet(p, 100); // same time again: no change
|
||||||
|
expect(p.stock).toEqual(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyTrade returns pre-trade price and mutates stock', () => {
|
||||||
|
const p = makePlanet();
|
||||||
|
const price = applyTrade(p, 0, 'ore', -20); // a buyer removes 20
|
||||||
|
expect(price).toBeCloseTo(priceOf('ore', 100), 5);
|
||||||
|
expect(p.stock.ore).toBe(80);
|
||||||
|
applyTrade(p, 0, 'ore', 50); // a seller delivers 50
|
||||||
|
expect(p.stock.ore).toBe(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marketView lists all goods with integer stock and price', () => {
|
||||||
|
const p = makePlanet();
|
||||||
|
const view = marketView(p, 5);
|
||||||
|
expect(view.map((r) => r.good)).toEqual(GOODS);
|
||||||
|
for (const row of view) {
|
||||||
|
expect(Number.isInteger(row.stock)).toBe(true);
|
||||||
|
expect(row.price).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user