| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499 |
- // ts/cache.ts
- var fetchCache = new Map;
- async function cachedFetchJSON(url, options) {
- if (fetchCache.has(url))
- return fetchCache.get(url);
- const response = await fetch(url, options).then((r) => r.json());
- fetchCache.set(url, response);
- return response;
- }
- // ts/gov.ts
- var renderTarget = document.querySelector("#gov");
- var planetInput = document.querySelector("#planet");
- var popSelect = document.querySelector("#pop");
- function serializeToHash(planet, pop) {
- const params = new URLSearchParams({ planet, pop });
- document.location.hash = params.toString();
- }
- function deserializeFromHash() {
- 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 };
- } else
- return null;
- }
- document.querySelector("form").addEventListener("submit", async (event) => {
- event.preventDefault();
- const planet = planetInput.value;
- const pop = popSelect.value;
- if (planet && pop) {
- await render(planet, pop);
- serializeToHash(planet, pop);
- }
- });
- {
- const deserialized = deserializeFromHash();
- if (deserialized)
- render(deserialized.planet, deserialized.pop);
- }
- async function render(planetName, pop) {
- const loader = document.querySelector("#loader");
- loader.style.display = "block";
- renderTarget.innerHTML = "";
- try {
- await _render(planetName, pop);
- } catch (e) {
- console.error(e);
- renderTarget.textContent = e.message;
- }
- loader.style.display = "none";
- }
- async function _render(planetName, pop) {
- const encodedPlanetName = encodeURIComponent(planetName);
- const [planet, siteCounts, infras, allPrices] = 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 = {
- 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 = 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 = 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)
- 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})
- <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>
- <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>current POPI</h2>
- ${siteCount} bases
- <table>
- <tr>
- ${infras.map((infra) => {
- if (infra.CurrentLevel === 0)
- return "";
- const fill = currentPOPIFilled.get(infra.Type);
- return `<tr>
- <td>${infra.Type} T${infra.CurrentLevel}</td>
- <td>${fill.numMats}/${infra.Upkeeps.length}</td>
- </tr>`;
- }).join("")}
- </tr>
- </table>
- current projected ${pop.toUpperCase()} happiness:
- ${formatPct(projectedHappiness(pop, totalNeeds, siteCount, currentPOPIFilled, null))}
- <h2>options</h2>
- <select id="gov_program">
- <option value="">(all)</option>
- ${[...new Set(results.map((result) => result.govProgram))].map((program) => `<option value="${program ?? "no program"}">${program ?? "no program"}</option>`).join("")}
- </select>
- ${renderOptions(results, currentPOPIFilled, "")}
- `;
- const govProgramSelect = renderTarget.querySelector("#gov_program");
- govProgramSelect.addEventListener("change", (event) => {
- renderTarget.querySelector(".options").outerHTML = renderOptions(results, currentPOPIFilled, govProgramSelect.value);
- });
- }
- function renderOptions(results, currentPOPIFilled, govProgramFilter) {
- 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 `
- <table class="options">
- <tr>
- <th>config</th>
- <th>projected happiness</th>
- <th>projected change</th>
- <th>cost/day</th>
- <th>unit cost</th>
- </tr>
- ${results.filter((result) => govProgramFilter === "" || govProgramFilter === (result.govProgram ?? "no program")).map((result) => {
- let unitCost = "";
- if (result.change !== 0)
- unitCost = formatNum(result.cost / result.change);
- return `<tr>
- <td>
- ${[...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}: <span class="${className}">${fill.numMats}</span>`;
- }).join("<br>")}
- ${result.govProgram !== null ? `<br>${result.govProgram}` : ""}
- </td>
- <td>${formatPct(result.happiness)}</td>
- <td style="color: ${color(result.change, 0, maxPop)}">${formatNum(result.change)}</td>
- <td>${formatNum(result.cost)}</td>
- <td style="color: ${result.change > 0 ? color(bestUnitCost - result.cost / result.change, -bestUnitCost, 0) : "#777"}">
- ${unitCost}
- </td>
- </tr>`;
- }).join("")}
- </table>
- `;
- }
- var formatNum = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format;
- var formatPct = new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 2 }).format;
- function formatDelta(n, withPlus = true) {
- if (n > 0)
- return `<span class="positive">${withPlus ? "+" : ""}${formatNum(n)}</span>`;
- else if (n < 0)
- return `<span class="negative">${formatNum(n)}</span>`;
- else
- return n.toString();
- }
- function color(n, low, high) {
- const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
- return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
- }
- function unemployed(people, change, openJobs, unemploymentRate) {
- 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, need) {
- 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) {
- if (infra.CurrentLevel === 0)
- return null;
- let filled = 0;
- for (const upkeep of infra.Upkeeps) {
- const nextConsumptionAmount = upkeep.StoreCapacity / 30 * upkeep.Duration;
- if (upkeep.Stored >= nextConsumptionAmount)
- filled++;
- }
- return filled;
- }
- function paretoFront(pop, totalNeeds, siteCount, currentPOPIFilled, currentPop, lowerPop, totalPop, prices) {
- const results = [];
- 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 = [null];
- govPrograms.push(...Object.keys(GOV_PROGRAMS));
- 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;
- }
- let cost = calcCost(config, prices);
- if (govProgram !== null) {
- const program = GOV_PROGRAMS[govProgram];
- cost += (program.baseCost + program.popCost * totalPop) / 7;
- }
- if (results.some((result) => result.change >= change && result.cost < cost || result.change > change && result.cost <= cost))
- continue;
- 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) {
- const entries = [...currentPOPIFilled.keys()];
- function* helper(index, current) {
- 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, totalNeeds, siteCount, popiFilled, govProgram) {
- let totalProvided = {
- 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 = new Map;
- for (const _need in totalProvided) {
- const need = _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);
- 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, prices) {
- let cost = 0;
- for (const [building, fill] of config.entries()) {
- const { mats } = POPI[building];
- const matPrices = [];
- 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;
- }
- var NEEDS = {
- pio: { safety: 0.25, health: 0.15, comfort: 0.03, culture: 0.02, education: 0.01 },
- set: { safety: 0.3, health: 0.2, comfort: 0.03, culture: 0.03, education: 0.03 },
- tec: { safety: 0.2, health: 0.3, comfort: 0.2, culture: 0.1, education: 0.05 },
- eng: { safety: 0.1, health: 0.15, comfort: 0.35, culture: 0.2, education: 0.1 },
- sci: { safety: 0.1, health: 0.1, comfort: 0.2, culture: 0.25, education: 0.3 }
- };
- var POPI = {
- 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 }
- }
- };
- var EDUCATION = {
- set: 0.025,
- tec: 0.02,
- eng: 0.0125,
- sci: 0.0075
- };
- var GOV_PROGRAMS = {
- "festivities I": { baseCost: 5000, popCost: 0.125 },
- "festivities II": { baseCost: 1e4, popCost: 0.25 },
- "festivities III": { baseCost: 20000, popCost: 0.5 },
- "education I": { baseCost: 5000, popCost: 0.15 },
- "education II": { baseCost: 1e4, popCost: 0.25 },
- "education III": { baseCost: 20000, popCost: 0.4 },
- "pio immigration": { baseCost: 1e4, 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 }
- };
- //# debugId=C8AE7A4D6970DFAA64756E2164756E21
|