|
@@ -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}[];
|
|
|
}
|
|
}
|