| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343 |
- import {cachedFetchJSON} from './cache';
- const renderTarget = document.querySelector('#gov')!;
- const planetInput = document.querySelector('#planet') as HTMLInputElement;
- const popSelect = document.querySelector('#pop') as HTMLSelectElement;
- function serializeToHash(planet: string, pop: Pop): void {
- const params = new URLSearchParams({'planet': planet, 'pop': pop});
- document.location.hash = params.toString();
- }
- function deserializeFromHash(): {planet: string, pop: Pop} | null {
- const params = new URLSearchParams(document.location.hash.substring(1));
- const planet = params.get('planet');
- const pop = params.get('pop');
- if (planet && pop && ['pio', 'set', 'tec', 'eng', 'sci'].includes(pop)) {
- planetInput.value = planet;
- popSelect.value = pop;
- return {planet, pop: pop as Pop};
- } else
- return null;
- }
- document.querySelector('form')!.addEventListener('submit', async (event) => {
- event.preventDefault();
- const planet = planetInput.value;
- const pop = popSelect.value as Pop;
- if (planet && pop) {
- await render(planet, pop);
- serializeToHash(planet, pop);
- }
- });
- {
- const deserialized = deserializeFromHash();
- if (deserialized)
- void render(deserialized.planet, deserialized.pop);
- }
- async function render(planetName: string, pop: Pop) {
- const loader = document.querySelector('#loader') as HTMLElement;
- loader.style.display = 'block';
- 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`)
- ]);
- let lastPOPR = null;
- for (const report of planet.PopulationReports) {
- if (!lastPOPR || report.SimulationPeriod > lastPOPR.SimulationPeriod)
- lastPOPR = report;
- }
- if (lastPOPR === null) {
- renderTarget.textContent = `no POPR for ${planetName}`;
- return;
- }
- const lastPOPRts = Math.floor(new Date(lastPOPR.ReportTimestamp).getTime() / 1000);
- const nextPOPRts = lastPOPRts + 7 * 24 * 60 * 60;
- 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 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 {
- PopulationReports: POPR[]
- }
- interface POPR {
- SimulationPeriod: number;
- ReportTimestamp: string;
- NeedFulfillmentSafety: number;
- NeedFulfillmentHealth: number;
- NeedFulfillmentComfort: number;
- NeedFulfillmentCulture: number;
- NeedFulfillmentEducation: number;
- NextPopulationPioneer: number;
- NextPopulationSettler: number;
- NextPopulationTechnician: number;
- NextPopulationEngineer: 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}[];
- }
|