1
0

3 Коміти aa3b1407a8 ... 46b8437907

Автор SHA1 Опис Дата
  raylu 46b8437907 gov: calc 1 тиждень тому
  raylu aabb2c2f78 gov: needs, base count, POPI 1 тиждень тому
  raylu 21ea7b0290 gov: show pop table with unemployed 2 тижнів тому
2 змінених файлів з 276 додано та 4 видалено
  1. 270 4
      ts/gov.ts
  2. 6 0
      www/style.css

+ 270 - 4
ts/gov.ts

@@ -41,9 +41,22 @@ async function render(planetName: string, pop: Pop) {
 	const loader = document.querySelector('#loader') as HTMLElement;
 	const loader = document.querySelector('#loader') as HTMLElement;
 	loader.style.display = 'block';
 	loader.style.display = 'block';
 	renderTarget.innerHTML = '';
 	renderTarget.innerHTML = '';
+	try {
+		await _render(planetName, pop);
+	} catch (e) {
+		renderTarget.textContent = (e as Error).message;
+	}
+	loader.style.display = 'none';
+}
+
+async function _render(planetName: string, pop: Pop) {
+	const encodedPlanetName = encodeURIComponent(planetName);
+	const [planet, siteCounts, infras]: [Planet, SiteCount[], Infrastructure[]] = 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`)
+	]);
 
 
-	const planet: Planet = await cachedFetchJSON(
-			`https://api.fnar.net/planet/${encodeURIComponent(planetName)}?include_population_reports=true`);
 	let lastPOPR = null;
 	let lastPOPR = null;
 	for (const report of planet.PopulationReports) {
 	for (const report of planet.PopulationReports) {
 		if (!lastPOPR || report.SimulationPeriod > lastPOPR.SimulationPeriod)
 		if (!lastPOPR || report.SimulationPeriod > lastPOPR.SimulationPeriod)
@@ -55,12 +68,235 @@ async function render(planetName: string, pop: Pop) {
 	}
 	}
 	const lastPOPRts = Math.floor(new Date(lastPOPR.ReportTimestamp).getTime() / 1000);
 	const lastPOPRts = Math.floor(new Date(lastPOPR.ReportTimestamp).getTime() / 1000);
 	const nextPOPRts = lastPOPRts + 7 * 24 * 60 * 60;
 	const nextPOPRts = lastPOPRts + 7 * 24 * 60 * 60;
-	renderTarget.innerHTML = `last POPR: ${lastPOPRts}<br>next POPR: ${nextPOPRts}`;
 
 
-	loader.style.display = 'none';
+	const siteCount = siteCounts[0].Count;
+
+	const totalNeeds: Record<Need, number> = {
+		'safety': calcTotalNeeds(lastPOPR, 'safety'),
+		'health': calcTotalNeeds(lastPOPR, 'health'),
+		'comfort': calcTotalNeeds(lastPOPR, 'comfort'),
+		'culture': calcTotalNeeds(lastPOPR, 'culture'),
+		'education': calcTotalNeeds(lastPOPR, 'education'),
+	};
+
+	const currentPOPIFilled: Map<POPIBuilding, number> = new Map();
+	for (const infra of infras) {
+		const filled = calcPOPIFilled(infra);
+		if (filled !== null)
+			currentPOPIFilled.set(infra.Type, filled);
+	}
+
+	// gen
+
+	renderTarget.innerHTML = `last POPR: ${lastPOPR.ReportTimestamp} (${lastPOPRts})
+	<br>next POPR: ${new Date(nextPOPRts * 1000).toISOString()} (${nextPOPRts})
+
+	<table>
+		<tr>
+			<th></th>
+			<th>pio</th>
+			<th>set</th>
+			<th>tec</th>
+			<th>eng</th>
+			<th>sci</th>
+		</tr>
+		<tr>
+			<th>population</th>
+			<td>${lastPOPR.NextPopulationPioneer}<br>${formatDelta(lastPOPR.PopulationDifferencePioneer)}</td>
+			<td>${lastPOPR.NextPopulationSettler}<br>${formatDelta(lastPOPR.PopulationDifferenceSettler)}</td>
+			<td>${lastPOPR.NextPopulationTechnician}<br>${formatDelta(lastPOPR.PopulationDifferenceTechnician)}</td>
+			<td>${lastPOPR.NextPopulationEngineer}<br>${formatDelta(lastPOPR.PopulationDifferenceEngineer)}</td>
+			<td>${lastPOPR.NextPopulationScientist}<br>${formatDelta(lastPOPR.PopulationDifferenceScientist)}</td>
+		</tr>
+		<tr>
+			<th>unemployed</th>
+			<td>${unemployed(lastPOPR.NextPopulationPioneer, lastPOPR.PopulationDifferencePioneer, lastPOPR.OpenJobsPioneer, lastPOPR.UnemploymentRatePioneer)}</td>
+			<td>${unemployed(lastPOPR.NextPopulationSettler, lastPOPR.PopulationDifferenceSettler, lastPOPR.OpenJobsSettler, lastPOPR.UnemploymentRateSettler)}</td>
+			<td>${unemployed(lastPOPR.NextPopulationTechnician, lastPOPR.PopulationDifferenceTechnician, lastPOPR.OpenJobsTechnician, lastPOPR.UnemploymentRateTechnician)}</td>
+			<td>${unemployed(lastPOPR.NextPopulationEngineer, lastPOPR.PopulationDifferenceEngineer, lastPOPR.OpenJobsEngineer, lastPOPR.UnemploymentRateEngineer)}</td>
+			<td>${unemployed(lastPOPR.NextPopulationScientist, lastPOPR.PopulationDifferenceScientist, lastPOPR.OpenJobsScientist, lastPOPR.UnemploymentRateScientist)}</td>
+		</tr>
+	</table>
+
+	<h2>needs</h2>
+	${siteCount} bases
+	<table>
+		<tr>
+			<th></th>
+			<th>safety</th>
+			<th>health</th>
+			<th>comfort</th>
+			<th>culture</th>
+			<th>education</th>
+		</tr>
+		<tr>
+			<td>last POPR</td>
+			<td>${formatPct(lastPOPR.NeedFulfillmentSafety)}</td>
+			<td>${formatPct(lastPOPR.NeedFulfillmentHealth)}</td>
+			<td>${formatPct(lastPOPR.NeedFulfillmentComfort)}</td>
+			<td>${formatPct(lastPOPR.NeedFulfillmentCulture)}</td>
+			<td>${formatPct(lastPOPR.NeedFulfillmentEducation)}</td>
+		</tr>
+		<tr>
+			<td>total needed</td>
+			<td>${formatNum(totalNeeds['safety'])}</td>
+			<td>${formatNum(totalNeeds['health'])}</td>
+			<td>${formatNum(totalNeeds['comfort'])}</td>
+			<td>${formatNum(totalNeeds['culture'])}</td>
+			<td>${formatNum(totalNeeds['education'])}</td>
+		</tr>
+	</table>
+
+	<h2>POPI</h2>
+	<table>
+		<tr>
+			${infras.map((infra) => {
+				if (infra.CurrentLevel === 0)
+					return '';
+				const filled = currentPOPIFilled.get(infra.Type)!;
+				return `<tr>
+					<td>${infra.Type} T${infra.CurrentLevel}</td>
+					<td>${filled}/${infra.Upkeeps.length}</td>
+				</tr>`;
+			}).join('')}
+		</tr>
+	</table>
+
+	<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>
+	`;
+}
+
+const formatNum = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format;
+const formatPct = new Intl.NumberFormat(undefined, {style: 'percent', maximumFractionDigits: 2}).format;
+
+function formatDelta(n: number, withPlus: boolean = true): string {
+	if (n > 0)
+		return `<span class="positive">${withPlus ? '+' : ''}${n}</span>`;
+	else if (n < 0)
+		return `<span class="negative">${n}</span>`;
+	else
+		return n.toString();
+}
+
+function unemployed(people: number, change: number, openJobs: number, unemploymentRate: number): string {
+	let nextUnemployed;
+	if (openJobs <= people + change) {
+		let prevUnemploymentRate = unemploymentRate;
+		if (openJobs > 0)
+			if (people - change > 0)
+				prevUnemploymentRate = -openJobs / (people - change);
+			else
+				prevUnemploymentRate = -1
+		const prevUnemployed = prevUnemploymentRate * (people - change);
+		nextUnemployed = prevUnemployed + change;
+	} else
+		nextUnemployed = -openJobs + change;
+	return formatDelta(Math.round(nextUnemployed), false);
+}
+
+function calcTotalNeeds(popReport: POPR, need: Need): number {
+	return popReport.NextPopulationPioneer * NEEDS.pio[need]
+		+ popReport.NextPopulationSettler * NEEDS.set[need]
+		+ popReport.NextPopulationTechnician * NEEDS.tec[need]
+		+ popReport.NextPopulationEngineer * NEEDS.eng[need]
+		+ popReport.NextPopulationScientist * NEEDS.sci[need];
+}
+
+function calcPOPIFilled(infra: Infrastructure): number | null {
+	if (infra.CurrentLevel === 0)
+		return null;
+
+	let filled = 0;
+	for (const upkeep of infra.Upkeeps) {
+		const nextConsumptionAmount = upkeep.StoreCapacity / 30 * upkeep.Duration; // # capacity is always 30 days
+		if (upkeep.Stored >= nextConsumptionAmount)
+			filled++;
+	}
+	return filled;
 }
 }
 
 
+function* popiFillCombinations(currentPOPIFilled: Map<POPIBuilding, number>): Generator<Map<POPIBuilding, number>> {
+	const entries = [...currentPOPIFilled.keys()];
+	function* helper(index: number, current: Map<POPIBuilding, number>): Generator<Map<POPIBuilding, number>> {
+		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);
+			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 {
+	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;
+			totalProvided[need] += provided;
+		}
+	}
+	for (const _need in totalProvided) {
+		const need = _need as Need;
+		if (totalProvided[need] > totalNeeds[need])
+			totalProvided[need] = totalNeeds[need];
+	}
+
+	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];
+	}
+	return happiness;
+}
+
+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},
+	'tec': {'safety': 0.20, 'health': 0.30, 'comfort': 0.20, 'culture': 0.10, 'education': 0.05},
+	'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}]},
+};
+
 type Pop = 'pio' | 'set' | 'tec' | 'eng' | 'sci';
 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';
 
 
 interface Planet {
 interface Planet {
 	PopulationReports: POPR[]
 	PopulationReports: POPR[]
@@ -69,9 +305,39 @@ interface Planet {
 interface POPR {
 interface POPR {
 	SimulationPeriod: number;
 	SimulationPeriod: number;
 	ReportTimestamp: string;
 	ReportTimestamp: string;
+	NeedFulfillmentSafety: number;
+	NeedFulfillmentHealth: number;
+	NeedFulfillmentComfort: number;
+	NeedFulfillmentCulture: number;
+	NeedFulfillmentEducation: number;
 	NextPopulationPioneer: number;
 	NextPopulationPioneer: number;
 	NextPopulationSettler: number;
 	NextPopulationSettler: number;
 	NextPopulationTechnician: number;
 	NextPopulationTechnician: number;
 	NextPopulationEngineer: number;
 	NextPopulationEngineer: number;
 	NextPopulationScientist: number;
 	NextPopulationScientist: number;
+	PopulationDifferencePioneer: number;
+	PopulationDifferenceSettler: number;
+	PopulationDifferenceTechnician: number;
+	PopulationDifferenceEngineer: number;
+	PopulationDifferenceScientist: number;
+	OpenJobsPioneer: number;
+	OpenJobsSettler: number;
+	OpenJobsTechnician: number;
+	OpenJobsEngineer: number;
+	OpenJobsScientist: number;
+	UnemploymentRatePioneer: number;
+	UnemploymentRateSettler: number;
+	UnemploymentRateTechnician: number;
+	UnemploymentRateEngineer: number;
+	UnemploymentRateScientist: number;
+}
+
+interface SiteCount {
+	Count: number;
+}
+
+interface Infrastructure {
+	Type: POPIBuilding;
+	CurrentLevel: number;
+	Upkeeps: {Stored: number, StoreCapacity: number, 'Duration': number}[];
 }
 }

+ 6 - 0
www/style.css

@@ -112,6 +112,12 @@ main.gov {
 	div#gov {
 	div#gov {
 		margin-top: 2em;
 		margin-top: 2em;
 	}
 	}
+	.positive {
+		color: #0aa;
+	}
+	.negative {
+		color: #f80;
+	}
 }
 }
 
 
 main.mat {
 main.mat {