7 Commits 46b8437907 ... 7c9d9f9089

Author SHA1 Message Date
  raylu 7c9d9f9089 gov: education 1 week ago
  raylu d95fc0e16e gov: show happiness, fix 0 prices 1 week ago
  raylu e0316e2bdb gov: migration 1 week ago
  raylu c160603c21 gov: calculate daily cost 1 week ago
  raylu c111e48ee4 gov: pareto front 1 week ago
  raylu 5547df1967 gov: capping of higher tier needs 1 week ago
  raylu 9eb921a031 gov: multiply by building tier 1 week ago
3 changed files with 224 additions and 52 deletions
  1. 1 1
      ts/buy.ts
  2. 212 51
      ts/gov.ts
  3. 11 0
      www/style.css

+ 1 - 1
ts/buy.ts

@@ -169,7 +169,7 @@ async function calcForCX(username: string, apiKey: string, supplyForDays: number
 }
 
 async function getPrices(cx: string) {
-	const rawPrices= await cachedFetchJSON('https://refined-prun.github.io/refined-prices/all.json');
+	const rawPrices = await cachedFetchJSON('https://refined-prun.github.io/refined-prices/all.json');
 	const prices = new Map<string, RawPrice>();
 	for (const p of rawPrices)
 		if (p.ExchangeCode === cx)

+ 212 - 51
ts/gov.ts

@@ -44,6 +44,7 @@ async function render(planetName: string, pop: Pop) {
 	try {
 		await _render(planetName, pop);
 	} catch (e) {
+		console.error(e);
 		renderTarget.textContent = (e as Error).message;
 	}
 	loader.style.display = 'none';
@@ -51,10 +52,11 @@ async function render(planetName: string, pop: Pop) {
 
 async function _render(planetName: string, pop: Pop) {
 	const encodedPlanetName = encodeURIComponent(planetName);
-	const [planet, siteCounts, infras]: [Planet, SiteCount[], Infrastructure[]] = await Promise.all([
+	const [planet, siteCounts, infras, allPrices]: [Planet, SiteCount[], Infrastructure[], Price[]] = await Promise.all([
 		cachedFetchJSON(`https://api.fnar.net/planet/${encodedPlanetName}?include_population_reports=true`),
 		cachedFetchJSON(`https://api.fnar.net/planet/sitecount?planet=${encodedPlanetName}`),
-		cachedFetchJSON(`https://api.fnar.net/infrastructure?infrastructure=${encodedPlanetName}&include_upkeeps=true`)
+		cachedFetchJSON(`https://api.fnar.net/infrastructure?infrastructure=${encodedPlanetName}&include_upkeeps=true`),
+		cachedFetchJSON('https://api.prunplanner.org/data/exchanges'),
 	]);
 
 	let lastPOPR = null;
@@ -69,6 +71,26 @@ async function _render(planetName: string, pop: Pop) {
 	const lastPOPRts = Math.floor(new Date(lastPOPR.ReportTimestamp).getTime() / 1000);
 	const nextPOPRts = lastPOPRts + 7 * 24 * 60 * 60;
 
+	let currentPop = 0, lowerPop = 0;
+	if (pop == 'pio')
+		currentPop = lastPOPR.NextPopulationPioneer;
+	else if (pop == 'set') {
+		currentPop = lastPOPR.NextPopulationSettler;
+		lowerPop = lastPOPR.NextPopulationPioneer;
+	}
+	else if (pop == 'tec') {
+		currentPop = lastPOPR.NextPopulationTechnician;
+		lowerPop = lastPOPR.NextPopulationSettler;
+	}
+	else if (pop == 'eng') {
+		currentPop = lastPOPR.NextPopulationEngineer;
+		lowerPop = lastPOPR.NextPopulationTechnician;
+	}
+	else if (pop == 'sci') {
+		currentPop = lastPOPR.NextPopulationScientist;
+		lowerPop = lastPOPR.NextPopulationEngineer;
+	}
+
 	const siteCount = siteCounts[0].Count;
 
 	const totalNeeds: Record<Need, number> = {
@@ -79,14 +101,19 @@ async function _render(planetName: string, pop: Pop) {
 		'education': calcTotalNeeds(lastPOPR, 'education'),
 	};
 
-	const currentPOPIFilled: Map<POPIBuilding, number> = new Map();
+	const currentPOPIFilled: POPIFill = new Map();
 	for (const infra of infras) {
 		const filled = calcPOPIFilled(infra);
 		if (filled !== null)
-			currentPOPIFilled.set(infra.Type, filled);
+			currentPOPIFilled.set(infra.Type, {tier: infra.CurrentLevel, numMats: filled});
 	}
 
-	// gen
+	const prices: Map<string, number> = new Map();
+	for (const price of allPrices)
+		if (price.exchange_code === 'IC1')
+			prices.set(price.ticker, price.vwap_30d || price.ask);
+		else if (price.exchange_code === 'UNIVERSE' && prices.get(price.ticker) === 0) // UNIVERSE always comes after IC1
+			prices.set(price.ticker, price.vwap_30d);
 
 	renderTarget.innerHTML = `last POPR: ${lastPOPR.ReportTimestamp} (${lastPOPRts})
 	<br>next POPR: ${new Date(nextPOPRts * 1000).toISOString()} (${nextPOPRts})
@@ -147,31 +174,44 @@ async function _render(planetName: string, pop: Pop) {
 		</tr>
 	</table>
 
-	<h2>POPI</h2>
+	<h2>current POPI</h2>
 	<table>
 		<tr>
 			${infras.map((infra) => {
 				if (infra.CurrentLevel === 0)
 					return '';
-				const filled = currentPOPIFilled.get(infra.Type)!;
+				const fill = currentPOPIFilled.get(infra.Type)!;
 				return `<tr>
 					<td>${infra.Type} T${infra.CurrentLevel}</td>
-					<td>${filled}/${infra.Upkeeps.length}</td>
+					<td>${fill.numMats}/${infra.Upkeeps.length}</td>
 				</tr>`;
 			}).join('')}
 		</tr>
 	</table>
+	current projected ${pop.toUpperCase()} happiness:
+	${formatPct(projectedHappiness(pop, totalNeeds, siteCount, currentPOPIFilled))}
 
 	<h2>options</h2>
-	current ${pop} happiness: ${formatPct(projectedHappiness(pop, totalNeeds, siteCount, currentPOPIFilled))}
-	<table>
-	${[...popiFillCombinations(currentPOPIFilled)].map((combination) => {
-		const happiness = projectedHappiness(pop, totalNeeds, siteCount, combination);
-		return `<tr>
-			<td>${[...combination.entries()].map(([building, filled]) => `${building}: ${filled}`).join(', ')}</td>
-			<td>${formatPct(happiness)}</td>
-		</tr>`;
-	}).join('')}
+	<table class="options">
+		<tr>
+			<th>config</th>
+			<th>projected happiness</th>
+			<th>projected migration</th>
+			<th>cost/day</th>
+			<th>unit cost</th>
+		</tr>
+		${paretoFront(pop, totalNeeds, siteCount, currentPOPIFilled, currentPop, lowerPop, prices).map((result) => {
+			let unitCost = '';
+			if (result.cost > 0)
+				unitCost = formatNum(result.cost / result.change);
+			return `<tr>
+				<td>${[...result.config.entries()].map(([building, fill]) => `${building}: ${fill.numMats}`).join('<br>')}</td>
+				<td>${formatPct(result.happiness)}</td>
+				<td>${formatDelta(result.change)}</td>
+				<td>${formatNum(result.cost)}</td>
+				<td>${unitCost}</td>
+			</tr>`;
+		}).join('')}
 	</table>
 	`;
 }
@@ -181,9 +221,9 @@ const formatPct = new Intl.NumberFormat(undefined, {style: 'percent', maximumFra
 
 function formatDelta(n: number, withPlus: boolean = true): string {
 	if (n > 0)
-		return `<span class="positive">${withPlus ? '+' : ''}${n}</span>`;
+		return `<span class="positive">${withPlus ? '+' : ''}${formatNum(n)}</span>`;
 	else if (n < 0)
-		return `<span class="negative">${n}</span>`;
+		return `<span class="negative">${formatNum(n)}</span>`;
 	else
 		return n.toString();
 }
@@ -225,49 +265,114 @@ function calcPOPIFilled(infra: Infrastructure): number | null {
 	return filled;
 }
 
-function* popiFillCombinations(currentPOPIFilled: Map<POPIBuilding, number>): Generator<Map<POPIBuilding, number>> {
+function paretoFront(pop: Pop, totalNeeds: Record<Need, number>, siteCount: number, currentPOPIFilled: POPIFill,
+		currentPop: number, lowerPop: number, prices: Map<string, number>): {config: POPIFill, happiness: number, change: number, cost: number}[] {
+	const results: {config: POPIFill, happiness: number, change: number, cost: number}[] = [];
+	let education = 0;
+	if (pop !== 'pio') {
+		education = EDUCATION[pop];
+		education += (currentPOPIFilled.get('PLANETARY_BROADCASTING_HUB')?.tier ?? 0) * 0.001;
+		education += (currentPOPIFilled.get('LIBRARY')?.tier ?? 0) * 0.002;
+		education += (currentPOPIFilled.get('UNIVERSITY')?.tier ?? 0) * 0.004;
+	}
+
+	for (const config of popiFillCombinations(currentPOPIFilled)) {
+		const happiness = projectedHappiness(pop, totalNeeds, siteCount, config);
+		let change = 0;
+		if (happiness > 0.7 && ['pio', 'set', 'tec'].includes(pop))
+			change = currentPop * (happiness - 0.7);
+		else if (happiness < 0.5)
+			change = 0.8 * currentPop * (happiness - 0.5);
+
+		if (pop !== 'pio')
+			change += lowerPop * education * happiness;
+		// TODO: education out
+
+		let cost = calcCost(config, prices);
+		// is any result better than this one?
+		if (results.some((result) => (result.change >= change && result.cost < cost) ||
+				(result.change > change && result.cost <= cost)))
+			continue;
+		// are any results worse than this one?
+		for (let i = results.length - 1; i >= 0; i--)
+			if ((results[i].change <= change && results[i].cost > cost) ||
+					(results[i].change < change && results[i].cost >= cost))
+				results.splice(i, 1);
+		results.push({config, happiness, change, cost});
+	}
+	return results;
+}
+
+function* popiFillCombinations(currentPOPIFilled: POPIFill): Generator<POPIFill> {
 	const entries = [...currentPOPIFilled.keys()];
-	function* helper(index: number, current: Map<POPIBuilding, number>): Generator<Map<POPIBuilding, number>> {
+	function* helper(index: number, current: POPIFill): Generator<POPIFill> {
 		if (index === entries.length) {
 			yield new Map(current);
 			return;
 		}
 		const building = entries[index];
-		for (let i = 0; i <= POPI[building].max; i++) {
-			current.set(building, i);
+		const tier = currentPOPIFilled.get(building)!.tier;
+		const maxBuildingMats = Object.keys(POPI[building].mats).length;
+		for (let i = 0; i <= maxBuildingMats; i++) {
+			current.set(building, {tier, numMats: i});
 			yield* helper(index + 1, current);
 		}
 	}
 	yield* helper(0, new Map());
 }
 
-function projectedHappiness(pop: Pop, totalNeeds: Record<Need, number>, siteCount: number,
-		popiFilled: Map<POPIBuilding, number>): number {
+function projectedHappiness(pop: Pop, totalNeeds: Record<Need, number>, siteCount: number, popiFilled: POPIFill): number {
 	let totalProvided: Record<Need, number> = {'safety': 50 * siteCount, 'health': 50 * siteCount,
 		'comfort': 0, 'culture': 0, 'education': 0};
-	for (const [building, filled] of popiFilled.entries()) {
-		const {max, needs} = POPI[building];
-		for (const {need, satisfaction} of needs) {
-			const provided = filled / max * satisfaction;
+	for (const [building, fill] of popiFilled.entries()) {
+		const {needs} = POPI[building];
+		const maxBuildingMats = Object.keys(POPI[building].mats).length;
+		for (const {need, supplied} of needs) {
+			const provided = fill.numMats / maxBuildingMats * fill.tier * supplied;
 			totalProvided[need] += provided;
 		}
 	}
+
+	const satisfaction: Map<Need, number> = new Map(); // percentage of needs fulfilled
 	for (const _need in totalProvided) {
 		const need = _need as Need;
-		if (totalProvided[need] > totalNeeds[need])
-			totalProvided[need] = totalNeeds[need];
+		satisfaction.set(need, Math.min(totalProvided[need] / totalNeeds[need], 1));
 	}
+	const safetyHealthCap = Math.min(satisfaction.get('safety')!, satisfaction.get('health')!);
+	if (satisfaction.get('comfort')! > safetyHealthCap)
+		satisfaction.set('comfort', safetyHealthCap);
+	if (satisfaction.get('culture')! > safetyHealthCap)
+		satisfaction.set('culture', safetyHealthCap);
+	const comfortCultureCap = Math.min(satisfaction.get('comfort')!, satisfaction.get('culture')!);
+	if (satisfaction.get('education')! > comfortCultureCap)
+		satisfaction.set('education', comfortCultureCap);
 
 	const weights = NEEDS[pop];
 	let happiness = 1 - Object.values(weights).reduce((sum, weight) => sum + weight, 0); // assume 100% life support
-	// TODO: caps
-	for (const [_need, provided] of Object.entries(totalProvided)) {
-		const need = _need as Need;
-		happiness += weights[need] * provided / totalNeeds[need];
-	}
+	for (const [need, s] of satisfaction.entries())
+		happiness += weights[need] * s;
 	return happiness;
 }
 
+function calcCost(config: POPIFill, prices: Map<string, number>): number {
+	let cost = 0;
+	for (const [building, fill] of config.entries()) {
+		const {mats} = POPI[building];
+		const matPrices: {ticker: string, costPerDay: number}[] = [];
+		for (const [mat, amount] of Object.entries(mats)) {
+			const matCost = prices.get(mat)!;
+			if (!matCost)
+				throw new Error('no price for ' + mat);
+			matPrices.push({ticker: mat, costPerDay: matCost * amount});
+		}
+		matPrices.sort((a, b) => a.costPerDay - b.costPerDay);
+
+		for (let i = 0; i < fill.numMats; i++)
+			cost += matPrices[i].costPerDay * fill.tier;
+	}
+	return cost;
+}
+
 const NEEDS: Record<Pop, Record<Need, number>> = {
 	'pio': {'safety': 0.25, 'health': 0.15, 'comfort': 0.03, 'culture': 0.02, 'education': 0.01},
 	'set': {'safety': 0.30, 'health': 0.20, 'comfort': 0.03, 'culture': 0.03, 'education': 0.03},
@@ -275,28 +380,77 @@ const NEEDS: Record<Pop, Record<Need, number>> = {
 	'eng': {'safety': 0.10, 'health': 0.15, 'comfort': 0.35, 'culture': 0.20, 'education': 0.10},
 	'sci': {'safety': 0.10, 'health': 0.10, 'comfort': 0.20, 'culture': 0.25, 'education': 0.30},
 }
-const POPI: Record<POPIBuilding, {max: number, needs: {need: Need, satisfaction: number}[]}> = {
-	'SAFETY_STATION': {max: 3, needs: [{need: 'safety', satisfaction: 2500}]},
-	'SECURITY_DRONE_POST': {max: 4, needs: [{need: 'safety', satisfaction: 5000}]},
-	'EMERGENCY_CENTER': {max: 5, needs: [{need: 'safety', satisfaction: 1000}, {need: 'health', satisfaction: 1000}]},
-	'INFIRMARY': {max: 3, needs: [{need: 'health', satisfaction: 2500}]},
-	'HOSPITAL': {max: 6, needs: [{need: 'health', satisfaction: 5000}]},
-	'WELLNESS_CENTER': {max: 6, needs: [{need: 'health', satisfaction: 1000}, {need: 'comfort', satisfaction: 1000}]},
-	'WILDLIFE_PARK': {max: 5, needs: [{need: 'comfort', satisfaction: 2500}]},
-	'ARCADES': {max: 6, needs: [{need: 'comfort', satisfaction: 5000}]},
-	'ART_CAFE': {max: 6, needs: [{need: 'comfort', satisfaction: 1000}, {need: 'culture', satisfaction: 1000}]},
-	'ART_GALLERY': {max: 4, needs: [{need: 'culture', satisfaction: 2500}]},
-	'THEATER': {max: 6, needs: [{need: 'culture', satisfaction: 5000}]},
-	'PLANETARY_BROADCASTING_HUB': {max: 6, needs: [{need: 'culture', satisfaction: 1000}, {need: 'education', satisfaction: 1000}]},
-	'LIBRARY': {max: 5, needs: [{need: 'education', satisfaction: 2500}]},
-	'UNIVERSITY': {max: 6, needs: [{need: 'education', satisfaction: 5000}]},
+const POPI: Record<POPIBuilding, {needs: {need: Need, supplied: number}[], mats: Record<string, number>}> = {
+	'SAFETY_STATION': {
+		needs: [{need: 'safety', supplied: 2500}],
+		mats: {'DW': 10, 'OFF': 10, 'SUN': 2},
+	},
+	'SECURITY_DRONE_POST': {
+		needs: [{need: 'safety', supplied: 5000}],
+		mats: {'POW': 1, 'RAD': 0.47, 'CCD': 0.07, 'SUD': 0.07},
+	},
+	'EMERGENCY_CENTER': {
+		needs: [{need: 'safety', supplied: 1000}, {need: 'health', supplied: 1000}],
+		mats: {'PK': 2, 'POW': 0.4, 'BND': 4, 'RED': 0.07, 'BSC': 0.07},
+	},
+	'INFIRMARY': {
+		needs: [{need: 'health', supplied: 2500}],
+		mats: {'OFF': 10, 'TUB': 6.67, 'STR': 0.67},
+	},
+	'HOSPITAL': {
+		needs: [{need: 'health', supplied: 5000}],
+		mats: {'PK': 2, 'SEQ': 0.4, 'BND': 4, 'SDR': 0.07, 'RED': 0.07, 'BSC': 0.13},
+	},
+	'WELLNESS_CENTER': {
+		needs: [{need: 'health', supplied: 1000}, {need: 'comfort', supplied: 1000}],
+		mats: {'KOM': 4, 'OLF': 2, 'DW': 6, 'DEC': 0.67, 'PFE': 2.67, 'SOI': 6.67},
+	},
+	'WILDLIFE_PARK': {
+		needs: [{need: 'comfort', supplied: 2500}],
+		mats: {'DW': 10, 'FOD': 6, 'PFE': 2, 'SOI': 3.33, 'DEC': 0.33},
+	},
+	'ARCADES': {
+		needs: [{need: 'comfort', supplied: 5000}],
+		mats: {'POW': 2, 'MHP': 2, 'OLF': 4, 'BID': 0.2, 'HOG': 0.2, 'EDC': 0.2},
+	},
+	'ART_CAFE': {
+		needs: [{need: 'comfort', supplied: 1000}, {need: 'culture', supplied: 1000}],
+		mats: {'MHP': 1, 'HOG': 1, 'UTS': 0.67, 'DEC': 0.67},
+	},
+	'ART_GALLERY': {
+		needs: [{need: 'culture', supplied: 2500}],
+		mats: {'MHP': 1, 'HOG': 1, 'UTS': 0.67, 'DEC': 0.67},
+	},
+	'THEATER': {
+		needs: [{need: 'culture', supplied: 5000}],
+		mats: {'POW': 1.4, 'MHP': 2, 'HOG': 1.4, 'OLF': 4, 'BID': 0.33, 'DEC': 0.67},
+	},
+	'PLANETARY_BROADCASTING_HUB': {
+		needs: [{need: 'culture', supplied: 1000}, {need: 'education', supplied: 1000}],
+		mats: {'OFF': 10, 'MHP': 1, 'SP': 1.33, 'AAR': 0.67, 'EDC': 0.27, 'IDC': 0.13},
+	},
+	'LIBRARY': {
+		needs: [{need: 'education', supplied: 2500}],
+		mats: {'MHP': 1, 'HOG': 1, 'CD': 0.33, 'DIS': 0.33, 'BID': 0.2},
+	},
+	'UNIVERSITY': {
+		needs: [{need: 'education', supplied: 5000}],
+		mats: {'COF': 10, 'REA': 10, 'TUB': 10, 'BID': 0.33, 'HD': 0.67, 'IDC': 0.2},
+	},
 };
+const EDUCATION: Record<Exclude<Pop, 'pio'>, number> = {
+	'set': 0.025,
+	'tec': 0.02,
+	'eng': 0.0125,
+	'sci': 0.0075,
+}
 
 type Pop = 'pio' | 'set' | 'tec' | 'eng' | 'sci';
 type Need = 'safety' | 'health' | 'comfort' | 'culture' | 'education';
 type POPIBuilding = 'SAFETY_STATION' | 'SECURITY_DRONE_POST' | 'EMERGENCY_CENTER' | 'INFIRMARY' | 'HOSPITAL' |
 	'WELLNESS_CENTER' | 'WILDLIFE_PARK' | 'ARCADES' | 'ART_CAFE' | 'ART_GALLERY' | 'THEATER' |
 	'PLANETARY_BROADCASTING_HUB' | 'LIBRARY' | 'UNIVERSITY';
+type POPIFill = Map<POPIBuilding, {tier: number, numMats: number}>;
 
 interface Planet {
 	PopulationReports: POPR[]
@@ -341,3 +495,10 @@ interface Infrastructure {
 	CurrentLevel: number;
 	Upkeeps: {Stored: number, StoreCapacity: number, 'Duration': number}[];
 }
+
+interface Price {
+	ticker: string
+	exchange_code: string
+	vwap_30d: number
+	ask: number
+}

+ 11 - 0
www/style.css

@@ -111,6 +111,17 @@ main.gov {
 	}
 	div#gov {
 		margin-top: 2em;
+
+		table.options {
+			td {
+				padding: 0 6px;
+
+				&:first-child {
+					width: 320px;
+					font-size: 16px;
+				}
+			}
+		}
 	}
 	.positive {
 		color: #0aa;