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) { console.error(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, 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.prunplanner.org/data/exchanges'), ]); 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; 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 = { 'safety': calcTotalNeeds(lastPOPR, 'safety'), 'health': calcTotalNeeds(lastPOPR, 'health'), 'comfort': calcTotalNeeds(lastPOPR, 'comfort'), 'culture': calcTotalNeeds(lastPOPR, 'culture'), 'education': calcTotalNeeds(lastPOPR, 'education'), }; const totalPop = lastPOPR.NextPopulationPioneer + lastPOPR.NextPopulationSettler + lastPOPR.NextPopulationTechnician + lastPOPR.NextPopulationEngineer + lastPOPR.NextPopulationScientist; const currentPOPIFilled: POPIFill = new Map(); for (const infra of infras) { const filled = calcPOPIFilled(infra); if (filled !== null) currentPOPIFilled.set(infra.Type, {tier: infra.CurrentLevel, numMats: filled}); } const prices: Map = 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); const results = paretoFront(pop, totalNeeds, siteCount, currentPOPIFilled, currentPop, lowerPop, totalPop, prices); renderTarget.innerHTML = `last POPR: ${lastPOPR.ReportTimestamp} (${lastPOPRts})
next POPR: ${new Date(nextPOPRts * 1000).toISOString()} (${nextPOPRts})
pio set tec eng sci
population ${lastPOPR.NextPopulationPioneer}
${formatDelta(lastPOPR.PopulationDifferencePioneer)}
${lastPOPR.NextPopulationSettler}
${formatDelta(lastPOPR.PopulationDifferenceSettler)}
${lastPOPR.NextPopulationTechnician}
${formatDelta(lastPOPR.PopulationDifferenceTechnician)}
${lastPOPR.NextPopulationEngineer}
${formatDelta(lastPOPR.PopulationDifferenceEngineer)}
${lastPOPR.NextPopulationScientist}
${formatDelta(lastPOPR.PopulationDifferenceScientist)}
unemployed ${unemployed(lastPOPR.NextPopulationPioneer, lastPOPR.PopulationDifferencePioneer, lastPOPR.OpenJobsPioneer, lastPOPR.UnemploymentRatePioneer)} ${unemployed(lastPOPR.NextPopulationSettler, lastPOPR.PopulationDifferenceSettler, lastPOPR.OpenJobsSettler, lastPOPR.UnemploymentRateSettler)} ${unemployed(lastPOPR.NextPopulationTechnician, lastPOPR.PopulationDifferenceTechnician, lastPOPR.OpenJobsTechnician, lastPOPR.UnemploymentRateTechnician)} ${unemployed(lastPOPR.NextPopulationEngineer, lastPOPR.PopulationDifferenceEngineer, lastPOPR.OpenJobsEngineer, lastPOPR.UnemploymentRateEngineer)} ${unemployed(lastPOPR.NextPopulationScientist, lastPOPR.PopulationDifferenceScientist, lastPOPR.OpenJobsScientist, lastPOPR.UnemploymentRateScientist)}

needs

safety health comfort culture education
last POPR ${formatPct(lastPOPR.NeedFulfillmentSafety)} ${formatPct(lastPOPR.NeedFulfillmentHealth)} ${formatPct(lastPOPR.NeedFulfillmentComfort)} ${formatPct(lastPOPR.NeedFulfillmentCulture)} ${formatPct(lastPOPR.NeedFulfillmentEducation)}
total needed ${formatNum(totalNeeds['safety'])} ${formatNum(totalNeeds['health'])} ${formatNum(totalNeeds['comfort'])} ${formatNum(totalNeeds['culture'])} ${formatNum(totalNeeds['education'])}

current POPI

${siteCount} bases ${infras.map((infra) => { if (infra.CurrentLevel === 0) return ''; const fill = currentPOPIFilled.get(infra.Type)!; return ``; }).join('')}
${infra.Type} T${infra.CurrentLevel} ${fill.numMats}/${infra.Upkeeps.length}
current projected ${pop.toUpperCase()} happiness: ${formatPct(projectedHappiness(pop, totalNeeds, siteCount, currentPOPIFilled, null))}

options

${renderOptions(results, currentPOPIFilled, '')} `; const govProgramSelect = renderTarget.querySelector('#gov_program') as HTMLSelectElement; govProgramSelect.addEventListener('change', (event) => { renderTarget.querySelector('.options')!.outerHTML = renderOptions(results, currentPOPIFilled, govProgramSelect.value); }); } function renderOptions(results: ConfigResult[], currentPOPIFilled: POPIFill, govProgramFilter: string): string { const maxPop = Math.max(...results.map((result) => result.change)); const bestUnitCost = Math.min(...results.filter((result) => result.change > 0).map((result) => result.cost / result.change)); return ` ${results.filter((result) => govProgramFilter === '' || govProgramFilter === (result.govProgram ?? 'no program')).map((result) => { let unitCost = ''; if (result.change !== 0) unitCost = formatNum(result.cost / result.change); return ``; }).join('')}
config projected happiness projected change cost/day unit cost
${[...result.config.entries()].map(([building, fill]) => { let currentBuildingFill = currentPOPIFilled.get(building)!.numMats; let className = ''; if (fill.numMats > currentBuildingFill) className = 'positive'; else if (fill.numMats < currentBuildingFill) className = 'negative'; return `${building}: ${fill.numMats}`; }).join('
')} ${result.govProgram !== null ? `
${result.govProgram}` : ''}
${formatPct(result.happiness)} ${formatNum(result.change)} ${formatNum(result.cost)} ${unitCost}
`; } 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 `${withPlus ? '+' : ''}${formatNum(n)}`; else if (n < 0) return `${formatNum(n)}`; else return n.toString(); } function color(n: number, low: number, high: number): string { // scale n from low..high to 0..1 clamped const scale = Math.min(Math.max((n - low) / (high - low), 0), 1); return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`; } 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 paretoFront(pop: Pop, totalNeeds: Record, siteCount: number, currentPOPIFilled: POPIFill, currentPop: number, lowerPop: number, totalPop: number, prices: Map): ConfigResult[] { const results: ConfigResult[] = []; 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; } const govPrograms: (GovProgram | null)[] = [null]; govPrograms.push(...(Object.keys(GOV_PROGRAMS) as GovProgram[])); for (const config of popiFillCombinations(currentPOPIFilled)) { for (const govProgram of govPrograms) { const happiness = projectedHappiness(pop, totalNeeds, siteCount, config, govProgram); 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 (govProgram === 'pio immigration' && pop === 'pio') change += 500; else if (govProgram === 'set immigration' && pop === 'set') change += 200; else if (govProgram === 'tec immigration' && pop === 'tec') change += 100; else if (govProgram === 'eng immigration' && pop === 'eng') change += 50; else if (govProgram === 'sci immigration' && pop === 'sci') change += 25; if (pop !== 'pio') { let eduIn = lowerPop * education * happiness; if (govProgram === 'education I') eduIn *= 1.5; else if (govProgram === 'education II') eduIn *= 1.75; else if (govProgram === 'education III') eduIn *= 2; change += eduIn; } // TODO: education out let cost = calcCost(config, prices); if (govProgram !== null) { const program = GOV_PROGRAMS[govProgram]; cost += (program.baseCost + program.popCost * totalPop) / 7; } // 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, govProgram, happiness, change, cost}); } } results.sort((a, b) => b.change - a.change); return results; } function* popiFillCombinations(currentPOPIFilled: POPIFill): Generator { const entries = [...currentPOPIFilled.keys()]; function* helper(index: number, current: POPIFill): Generator { if (index === entries.length) { yield new Map(current); return; } const building = entries[index]; 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, siteCount: number, popiFilled: POPIFill, govProgram: GovProgram | null): number { let totalProvided: Record = {'safety': 50 * siteCount, 'health': 50 * siteCount, 'comfort': 0, 'culture': 0, 'education': 0}; 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 = new Map(); // percentage of needs fulfilled for (const _need in totalProvided) { const need = _need as 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 for (const [need, s] of satisfaction.entries()) happiness += weights[need] * s; if (govProgram === 'festivities I') happiness += 0.05; else if (govProgram === 'festivities II') happiness += 0.1; else if (govProgram === 'festivities III') happiness += 0.2; return Math.min(happiness, 1); } function calcCost(config: POPIFill, prices: Map): 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> = { '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}> = { '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, number> = { 'set': 0.025, 'tec': 0.02, 'eng': 0.0125, 'sci': 0.0075, } const GOV_PROGRAMS: Record = { 'festivities I': {baseCost: 5000, popCost: 0.125}, 'festivities II': {baseCost: 10000, popCost: 0.25}, 'festivities III': {baseCost: 20000, popCost: 0.5}, 'education I': {baseCost: 5000, popCost: 0.15}, 'education II': {baseCost: 10000, popCost: 0.25}, 'education III': {baseCost: 20000, popCost: 0.4}, 'pio immigration': {baseCost: 10000, popCost: 0}, 'set immigration': {baseCost: 25000, popCost: 0}, 'tec immigration': {baseCost: 50000, popCost: 0}, 'eng immigration': {baseCost: 80000, popCost: 0}, 'sci immigration': {baseCost: 125000, popCost: 0}, } 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; type GovProgram = 'festivities I' | 'festivities II' | 'festivities III' | 'education I' | 'education II' | 'education III' | 'pio immigration' | 'set immigration' | 'tec immigration' | 'eng immigration' | 'sci immigration'; type ConfigResult = {config: POPIFill, govProgram: GovProgram | null, happiness: number, change: number, cost: number}; 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}[]; } interface Price { ticker: string exchange_code: string vwap_30d: number ask: number }