gov.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import {cachedFetchJSON} from './cache';
  2. const renderTarget = document.querySelector('#gov')!;
  3. const planetInput = document.querySelector('#planet') as HTMLInputElement;
  4. const popSelect = document.querySelector('#pop') as HTMLSelectElement;
  5. function serializeToHash(planet: string, pop: Pop): void {
  6. const params = new URLSearchParams({'planet': planet, 'pop': pop});
  7. document.location.hash = params.toString();
  8. }
  9. function deserializeFromHash(): {planet: string, pop: Pop} | null {
  10. const params = new URLSearchParams(document.location.hash.substring(1));
  11. const planet = params.get('planet');
  12. const pop = params.get('pop');
  13. if (planet && pop && ['pio', 'set', 'tec', 'eng', 'sci'].includes(pop)) {
  14. planetInput.value = planet;
  15. popSelect.value = pop;
  16. return {planet, pop: pop as Pop};
  17. } else
  18. return null;
  19. }
  20. document.querySelector('form')!.addEventListener('submit', async (event) => {
  21. event.preventDefault();
  22. const planet = planetInput.value;
  23. const pop = popSelect.value as Pop;
  24. if (planet && pop) {
  25. await render(planet, pop);
  26. serializeToHash(planet, pop);
  27. }
  28. });
  29. {
  30. const deserialized = deserializeFromHash();
  31. if (deserialized)
  32. void render(deserialized.planet, deserialized.pop);
  33. }
  34. async function render(planetName: string, pop: Pop) {
  35. const loader = document.querySelector('#loader') as HTMLElement;
  36. loader.style.display = 'block';
  37. renderTarget.innerHTML = '';
  38. try {
  39. await _render(planetName, pop);
  40. } catch (e) {
  41. renderTarget.textContent = (e as Error).message;
  42. }
  43. loader.style.display = 'none';
  44. }
  45. async function _render(planetName: string, pop: Pop) {
  46. const encodedPlanetName = encodeURIComponent(planetName);
  47. const [planet, siteCounts, infras, allPrices]: [Planet, SiteCount[], Infrastructure[], Price[]] = await Promise.all([
  48. cachedFetchJSON(`https://api.fnar.net/planet/${encodedPlanetName}?include_population_reports=true`),
  49. cachedFetchJSON(`https://api.fnar.net/planet/sitecount?planet=${encodedPlanetName}`),
  50. cachedFetchJSON(`https://api.fnar.net/infrastructure?infrastructure=${encodedPlanetName}&include_upkeeps=true`),
  51. cachedFetchJSON('https://api.prunplanner.org/data/exchanges'),
  52. ]);
  53. let lastPOPR = null;
  54. for (const report of planet.PopulationReports) {
  55. if (!lastPOPR || report.SimulationPeriod > lastPOPR.SimulationPeriod)
  56. lastPOPR = report;
  57. }
  58. if (lastPOPR === null) {
  59. renderTarget.textContent = `no POPR for ${planetName}`;
  60. return;
  61. }
  62. const lastPOPRts = Math.floor(new Date(lastPOPR.ReportTimestamp).getTime() / 1000);
  63. const nextPOPRts = lastPOPRts + 7 * 24 * 60 * 60;
  64. const siteCount = siteCounts[0].Count;
  65. const totalNeeds: Record<Need, number> = {
  66. 'safety': calcTotalNeeds(lastPOPR, 'safety'),
  67. 'health': calcTotalNeeds(lastPOPR, 'health'),
  68. 'comfort': calcTotalNeeds(lastPOPR, 'comfort'),
  69. 'culture': calcTotalNeeds(lastPOPR, 'culture'),
  70. 'education': calcTotalNeeds(lastPOPR, 'education'),
  71. };
  72. const currentPOPIFilled: POPIFill = new Map();
  73. for (const infra of infras) {
  74. const filled = calcPOPIFilled(infra);
  75. if (filled !== null)
  76. currentPOPIFilled.set(infra.Type, {tier: infra.CurrentLevel, numMats: filled});
  77. }
  78. const prices: Map<string, number> = new Map();
  79. for (const price of allPrices)
  80. if (price.exchange_code === 'IC1')
  81. prices.set(price.ticker, price.vwap_30d);
  82. renderTarget.innerHTML = `last POPR: ${lastPOPR.ReportTimestamp} (${lastPOPRts})
  83. <br>next POPR: ${new Date(nextPOPRts * 1000).toISOString()} (${nextPOPRts})
  84. <table>
  85. <tr>
  86. <th></th>
  87. <th>pio</th>
  88. <th>set</th>
  89. <th>tec</th>
  90. <th>eng</th>
  91. <th>sci</th>
  92. </tr>
  93. <tr>
  94. <th>population</th>
  95. <td>${lastPOPR.NextPopulationPioneer}<br>${formatDelta(lastPOPR.PopulationDifferencePioneer)}</td>
  96. <td>${lastPOPR.NextPopulationSettler}<br>${formatDelta(lastPOPR.PopulationDifferenceSettler)}</td>
  97. <td>${lastPOPR.NextPopulationTechnician}<br>${formatDelta(lastPOPR.PopulationDifferenceTechnician)}</td>
  98. <td>${lastPOPR.NextPopulationEngineer}<br>${formatDelta(lastPOPR.PopulationDifferenceEngineer)}</td>
  99. <td>${lastPOPR.NextPopulationScientist}<br>${formatDelta(lastPOPR.PopulationDifferenceScientist)}</td>
  100. </tr>
  101. <tr>
  102. <th>unemployed</th>
  103. <td>${unemployed(lastPOPR.NextPopulationPioneer, lastPOPR.PopulationDifferencePioneer, lastPOPR.OpenJobsPioneer, lastPOPR.UnemploymentRatePioneer)}</td>
  104. <td>${unemployed(lastPOPR.NextPopulationSettler, lastPOPR.PopulationDifferenceSettler, lastPOPR.OpenJobsSettler, lastPOPR.UnemploymentRateSettler)}</td>
  105. <td>${unemployed(lastPOPR.NextPopulationTechnician, lastPOPR.PopulationDifferenceTechnician, lastPOPR.OpenJobsTechnician, lastPOPR.UnemploymentRateTechnician)}</td>
  106. <td>${unemployed(lastPOPR.NextPopulationEngineer, lastPOPR.PopulationDifferenceEngineer, lastPOPR.OpenJobsEngineer, lastPOPR.UnemploymentRateEngineer)}</td>
  107. <td>${unemployed(lastPOPR.NextPopulationScientist, lastPOPR.PopulationDifferenceScientist, lastPOPR.OpenJobsScientist, lastPOPR.UnemploymentRateScientist)}</td>
  108. </tr>
  109. </table>
  110. <h2>needs</h2>
  111. ${siteCount} bases
  112. <table>
  113. <tr>
  114. <th></th>
  115. <th>safety</th>
  116. <th>health</th>
  117. <th>comfort</th>
  118. <th>culture</th>
  119. <th>education</th>
  120. </tr>
  121. <tr>
  122. <td>last POPR</td>
  123. <td>${formatPct(lastPOPR.NeedFulfillmentSafety)}</td>
  124. <td>${formatPct(lastPOPR.NeedFulfillmentHealth)}</td>
  125. <td>${formatPct(lastPOPR.NeedFulfillmentComfort)}</td>
  126. <td>${formatPct(lastPOPR.NeedFulfillmentCulture)}</td>
  127. <td>${formatPct(lastPOPR.NeedFulfillmentEducation)}</td>
  128. </tr>
  129. <tr>
  130. <td>total needed</td>
  131. <td>${formatNum(totalNeeds['safety'])}</td>
  132. <td>${formatNum(totalNeeds['health'])}</td>
  133. <td>${formatNum(totalNeeds['comfort'])}</td>
  134. <td>${formatNum(totalNeeds['culture'])}</td>
  135. <td>${formatNum(totalNeeds['education'])}</td>
  136. </tr>
  137. </table>
  138. <h2>POPI</h2>
  139. <table>
  140. <tr>
  141. ${infras.map((infra) => {
  142. if (infra.CurrentLevel === 0)
  143. return '';
  144. const fill = currentPOPIFilled.get(infra.Type)!;
  145. return `<tr>
  146. <td>${infra.Type} T${infra.CurrentLevel}</td>
  147. <td>${fill.numMats}/${infra.Upkeeps.length}</td>
  148. </tr>`;
  149. }).join('')}
  150. </tr>
  151. </table>
  152. <h2>options</h2>
  153. current projected ${pop} happiness: ${formatPct(projectedHappiness(pop, totalNeeds, siteCount, currentPOPIFilled))}
  154. <table class="options">
  155. ${paretoFront(pop, totalNeeds, siteCount, currentPOPIFilled, prices).map((result) => {
  156. return `<tr>
  157. <td>${[...result.config.entries()].map(([building, fill]) => `${building}: ${fill.numMats}`).join(', ')}</td>
  158. <td>${formatPct(result.happiness)}</td>
  159. <td>${formatNum(result.cost)}/day</td>
  160. </tr>`;
  161. }).join('')}
  162. </table>
  163. `;
  164. }
  165. const formatNum = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format;
  166. const formatPct = new Intl.NumberFormat(undefined, {style: 'percent', maximumFractionDigits: 2}).format;
  167. function formatDelta(n: number, withPlus: boolean = true): string {
  168. if (n > 0)
  169. return `<span class="positive">${withPlus ? '+' : ''}${n}</span>`;
  170. else if (n < 0)
  171. return `<span class="negative">${n}</span>`;
  172. else
  173. return n.toString();
  174. }
  175. function unemployed(people: number, change: number, openJobs: number, unemploymentRate: number): string {
  176. let nextUnemployed;
  177. if (openJobs <= people + change) {
  178. let prevUnemploymentRate = unemploymentRate;
  179. if (openJobs > 0)
  180. if (people - change > 0)
  181. prevUnemploymentRate = -openJobs / (people - change);
  182. else
  183. prevUnemploymentRate = -1
  184. const prevUnemployed = prevUnemploymentRate * (people - change);
  185. nextUnemployed = prevUnemployed + change;
  186. } else
  187. nextUnemployed = -openJobs + change;
  188. return formatDelta(Math.round(nextUnemployed), false);
  189. }
  190. function calcTotalNeeds(popReport: POPR, need: Need): number {
  191. return popReport.NextPopulationPioneer * NEEDS.pio[need]
  192. + popReport.NextPopulationSettler * NEEDS.set[need]
  193. + popReport.NextPopulationTechnician * NEEDS.tec[need]
  194. + popReport.NextPopulationEngineer * NEEDS.eng[need]
  195. + popReport.NextPopulationScientist * NEEDS.sci[need];
  196. }
  197. function calcPOPIFilled(infra: Infrastructure): number | null {
  198. if (infra.CurrentLevel === 0)
  199. return null;
  200. let filled = 0;
  201. for (const upkeep of infra.Upkeeps) {
  202. const nextConsumptionAmount = upkeep.StoreCapacity / 30 * upkeep.Duration; // # capacity is always 30 days
  203. if (upkeep.Stored >= nextConsumptionAmount)
  204. filled++;
  205. }
  206. return filled;
  207. }
  208. function paretoFront(pop: Pop, totalNeeds: Record<Need, number>, siteCount: number, currentPOPIFilled: POPIFill,
  209. prices: Map<string, number>): {config: POPIFill, happiness: number, cost: number}[] {
  210. const results: {config: POPIFill, happiness: number, cost: number}[] = [];
  211. for (const config of popiFillCombinations(currentPOPIFilled)) {
  212. const happiness = projectedHappiness(pop, totalNeeds, siteCount, config);
  213. let cost = calcCost(config, prices);
  214. // is any result better than this one?
  215. if (results.some((result) => (result.happiness >= happiness && result.cost < cost) ||
  216. (result.happiness > happiness && result.cost <= cost)))
  217. continue;
  218. // are any results worse than this one?
  219. for (let i = results.length - 1; i >= 0; i--)
  220. if ((results[i].happiness <= happiness && results[i].cost > cost) ||
  221. (results[i].happiness < happiness && results[i].cost >= cost))
  222. results.splice(i, 1);
  223. results.push({config, happiness, cost});
  224. }
  225. return results;
  226. }
  227. function* popiFillCombinations(currentPOPIFilled: POPIFill): Generator<POPIFill> {
  228. const entries = [...currentPOPIFilled.keys()];
  229. function* helper(index: number, current: POPIFill): Generator<POPIFill> {
  230. if (index === entries.length) {
  231. yield new Map(current);
  232. return;
  233. }
  234. const building = entries[index];
  235. const tier = currentPOPIFilled.get(building)!.tier;
  236. const maxBuildingMats = Object.keys(POPI[building].mats).length;
  237. for (let i = 0; i <= maxBuildingMats; i++) {
  238. current.set(building, {tier, numMats: i});
  239. yield* helper(index + 1, current);
  240. }
  241. }
  242. yield* helper(0, new Map());
  243. }
  244. function projectedHappiness(pop: Pop, totalNeeds: Record<Need, number>, siteCount: number, popiFilled: POPIFill): number {
  245. let totalProvided: Record<Need, number> = {'safety': 50 * siteCount, 'health': 50 * siteCount,
  246. 'comfort': 0, 'culture': 0, 'education': 0};
  247. for (const [building, fill] of popiFilled.entries()) {
  248. const {needs} = POPI[building];
  249. const maxBuildingMats = Object.keys(POPI[building].mats).length;
  250. for (const {need, supplied} of needs) {
  251. const provided = fill.numMats / maxBuildingMats * fill.tier * supplied;
  252. totalProvided[need] += provided;
  253. }
  254. }
  255. const satisfaction: Map<Need, number> = new Map(); // percentage of needs fulfilled
  256. for (const _need in totalProvided) {
  257. const need = _need as Need;
  258. satisfaction.set(need, Math.min(totalProvided[need] / totalNeeds[need], 1));
  259. }
  260. const safetyHealthCap = Math.min(satisfaction.get('safety')!, satisfaction.get('health')!);
  261. if (satisfaction.get('comfort')! > safetyHealthCap)
  262. satisfaction.set('comfort', safetyHealthCap);
  263. if (satisfaction.get('culture')! > safetyHealthCap)
  264. satisfaction.set('culture', safetyHealthCap);
  265. const comfortCultureCap = Math.min(satisfaction.get('comfort')!, satisfaction.get('culture')!);
  266. if (satisfaction.get('education')! > comfortCultureCap)
  267. satisfaction.set('education', comfortCultureCap);
  268. const weights = NEEDS[pop];
  269. let happiness = 1 - Object.values(weights).reduce((sum, weight) => sum + weight, 0); // assume 100% life support
  270. for (const [need, s] of satisfaction.entries())
  271. happiness += weights[need] * s;
  272. return happiness;
  273. }
  274. function calcCost(config: POPIFill, prices: Map<string, number>): number {
  275. let cost = 0;
  276. for (const [building, fill] of config.entries()) {
  277. const {mats} = POPI[building];
  278. const matPrices: {ticker: string, costPerDay: number}[] = [];
  279. for (const [mat, amount] of Object.entries(mats)) {
  280. const matCost = prices.get(mat)!;
  281. if (matCost === undefined)
  282. throw new Error('no price for ' + mat);
  283. matPrices.push({ticker: mat, costPerDay: matCost * amount});
  284. }
  285. matPrices.sort((a, b) => a.costPerDay - b.costPerDay);
  286. for (let i = 0; i < fill.numMats; i++)
  287. cost += matPrices[i].costPerDay * fill.tier;
  288. }
  289. return cost;
  290. }
  291. const NEEDS: Record<Pop, Record<Need, number>> = {
  292. 'pio': {'safety': 0.25, 'health': 0.15, 'comfort': 0.03, 'culture': 0.02, 'education': 0.01},
  293. 'set': {'safety': 0.30, 'health': 0.20, 'comfort': 0.03, 'culture': 0.03, 'education': 0.03},
  294. 'tec': {'safety': 0.20, 'health': 0.30, 'comfort': 0.20, 'culture': 0.10, 'education': 0.05},
  295. 'eng': {'safety': 0.10, 'health': 0.15, 'comfort': 0.35, 'culture': 0.20, 'education': 0.10},
  296. 'sci': {'safety': 0.10, 'health': 0.10, 'comfort': 0.20, 'culture': 0.25, 'education': 0.30},
  297. }
  298. const POPI: Record<POPIBuilding, {needs: {need: Need, supplied: number}[], mats: Record<string, number>}> = {
  299. 'SAFETY_STATION': {
  300. needs: [{need: 'safety', supplied: 2500}],
  301. mats: {'DW': 10, 'OFF': 10, 'SUN': 2},
  302. },
  303. 'SECURITY_DRONE_POST': {
  304. needs: [{need: 'safety', supplied: 5000}],
  305. mats: {'POW': 1, 'RAD': 0.47, 'CCD': 0.07, 'SUD': 0.07},
  306. },
  307. 'EMERGENCY_CENTER': {
  308. needs: [{need: 'safety', supplied: 1000}, {need: 'health', supplied: 1000}],
  309. mats: {'PK': 2, 'POW': 0.4, 'BND': 4, 'RED': 0.07, 'BSC': 0.07},
  310. },
  311. 'INFIRMARY': {
  312. needs: [{need: 'health', supplied: 2500}],
  313. mats: {'OFF': 10, 'TUB': 6.67, 'STR': 0.67},
  314. },
  315. 'HOSPITAL': {
  316. needs: [{need: 'health', supplied: 5000}],
  317. mats: {'PK': 2, 'SEQ': 0.4, 'BND': 4, 'SDR': 0.07, 'RED': 0.07, 'BSC': 0.13},
  318. },
  319. 'WELLNESS_CENTER': {
  320. needs: [{need: 'health', supplied: 1000}, {need: 'comfort', supplied: 1000}],
  321. mats: {'KOM': 4, 'OLF': 2, 'DW': 6, 'DEC': 0.67, 'PFE': 2.67, 'SOI': 6.67},
  322. },
  323. 'WILDLIFE_PARK': {
  324. needs: [{need: 'comfort', supplied: 2500}],
  325. mats: {'DW': 10, 'FOD': 6, 'PFE': 2, 'SOI': 3.33, 'DEC': 0.33},
  326. },
  327. 'ARCADES': {
  328. needs: [{need: 'comfort', supplied: 5000}],
  329. mats: {'POW': 2, 'MHP': 2, 'OLF': 4, 'BID': 0.2, 'HOG': 0.2, 'EDC': 0.2},
  330. },
  331. 'ART_CAFE': {
  332. needs: [{need: 'comfort', supplied: 1000}, {need: 'culture', supplied: 1000}],
  333. mats: {'MHP': 1, 'HOG': 1, 'UTS': 0.67, 'DEC': 0.67},
  334. },
  335. 'ART_GALLERY': {
  336. needs: [{need: 'culture', supplied: 2500}],
  337. mats: {'MHP': 1, 'HOG': 1, 'UTS': 0.67, 'DEC': 0.67},
  338. },
  339. 'THEATER': {
  340. needs: [{need: 'culture', supplied: 5000}],
  341. mats: {'POW': 1.4, 'MHP': 2, 'HOG': 1.4, 'OLF': 4, 'BID': 0.33, 'DEC': 0.67},
  342. },
  343. 'PLANETARY_BROADCASTING_HUB': {
  344. needs: [{need: 'culture', supplied: 1000}, {need: 'education', supplied: 1000}],
  345. mats: {'OFF': 10, 'MHP': 1, 'SP': 1.33, 'AAR': 0.67, 'EDC': 0.27, 'IDC': 0.13},
  346. },
  347. 'LIBRARY': {
  348. needs: [{need: 'education', supplied: 2500}],
  349. mats: {'MHP': 1, 'HOG': 1, 'CD': 0.33, 'DIS': 0.33, 'BID': 0.2},
  350. },
  351. 'UNIVERSITY': {
  352. needs: [{need: 'education', supplied: 5000}],
  353. mats: {'COF': 10, 'REA': 10, 'TUB': 10, 'BID': 0.33, 'HD': 0.67, 'IDC': 0.2},
  354. },
  355. };
  356. type Pop = 'pio' | 'set' | 'tec' | 'eng' | 'sci';
  357. type Need = 'safety' | 'health' | 'comfort' | 'culture' | 'education';
  358. type POPIBuilding = 'SAFETY_STATION' | 'SECURITY_DRONE_POST' | 'EMERGENCY_CENTER' | 'INFIRMARY' | 'HOSPITAL' |
  359. 'WELLNESS_CENTER' | 'WILDLIFE_PARK' | 'ARCADES' | 'ART_CAFE' | 'ART_GALLERY' | 'THEATER' |
  360. 'PLANETARY_BROADCASTING_HUB' | 'LIBRARY' | 'UNIVERSITY';
  361. type POPIFill = Map<POPIBuilding, {tier: number, numMats: number}>;
  362. interface Planet {
  363. PopulationReports: POPR[]
  364. }
  365. interface POPR {
  366. SimulationPeriod: number;
  367. ReportTimestamp: string;
  368. NeedFulfillmentSafety: number;
  369. NeedFulfillmentHealth: number;
  370. NeedFulfillmentComfort: number;
  371. NeedFulfillmentCulture: number;
  372. NeedFulfillmentEducation: number;
  373. NextPopulationPioneer: number;
  374. NextPopulationSettler: number;
  375. NextPopulationTechnician: number;
  376. NextPopulationEngineer: number;
  377. NextPopulationScientist: number;
  378. PopulationDifferencePioneer: number;
  379. PopulationDifferenceSettler: number;
  380. PopulationDifferenceTechnician: number;
  381. PopulationDifferenceEngineer: number;
  382. PopulationDifferenceScientist: number;
  383. OpenJobsPioneer: number;
  384. OpenJobsSettler: number;
  385. OpenJobsTechnician: number;
  386. OpenJobsEngineer: number;
  387. OpenJobsScientist: number;
  388. UnemploymentRatePioneer: number;
  389. UnemploymentRateSettler: number;
  390. UnemploymentRateTechnician: number;
  391. UnemploymentRateEngineer: number;
  392. UnemploymentRateScientist: number;
  393. }
  394. interface SiteCount {
  395. Count: number;
  396. }
  397. interface Infrastructure {
  398. Type: POPIBuilding;
  399. CurrentLevel: number;
  400. Upkeeps: {Stored: number, StoreCapacity: number, 'Duration': number}[];
  401. }
  402. interface Price {
  403. ticker: string
  404. exchange_code: string
  405. vwap_30d: number
  406. }