gov.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  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]: [Planet, SiteCount[], Infrastructure[]] = 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. ]);
  52. let lastPOPR = null;
  53. for (const report of planet.PopulationReports) {
  54. if (!lastPOPR || report.SimulationPeriod > lastPOPR.SimulationPeriod)
  55. lastPOPR = report;
  56. }
  57. if (lastPOPR === null) {
  58. renderTarget.textContent = `no POPR for ${planetName}`;
  59. return;
  60. }
  61. const lastPOPRts = Math.floor(new Date(lastPOPR.ReportTimestamp).getTime() / 1000);
  62. const nextPOPRts = lastPOPRts + 7 * 24 * 60 * 60;
  63. const siteCount = siteCounts[0].Count;
  64. const totalNeeds: Record<Need, number> = {
  65. 'safety': calcTotalNeeds(lastPOPR, 'safety'),
  66. 'health': calcTotalNeeds(lastPOPR, 'health'),
  67. 'comfort': calcTotalNeeds(lastPOPR, 'comfort'),
  68. 'culture': calcTotalNeeds(lastPOPR, 'culture'),
  69. 'education': calcTotalNeeds(lastPOPR, 'education'),
  70. };
  71. const currentPOPIFilled: POPIFill = new Map();
  72. for (const infra of infras) {
  73. const filled = calcPOPIFilled(infra);
  74. if (filled !== null)
  75. currentPOPIFilled.set(infra.Type, {tier: infra.CurrentLevel, numMats: filled});
  76. }
  77. // gen
  78. renderTarget.innerHTML = `last POPR: ${lastPOPR.ReportTimestamp} (${lastPOPRts})
  79. <br>next POPR: ${new Date(nextPOPRts * 1000).toISOString()} (${nextPOPRts})
  80. <table>
  81. <tr>
  82. <th></th>
  83. <th>pio</th>
  84. <th>set</th>
  85. <th>tec</th>
  86. <th>eng</th>
  87. <th>sci</th>
  88. </tr>
  89. <tr>
  90. <th>population</th>
  91. <td>${lastPOPR.NextPopulationPioneer}<br>${formatDelta(lastPOPR.PopulationDifferencePioneer)}</td>
  92. <td>${lastPOPR.NextPopulationSettler}<br>${formatDelta(lastPOPR.PopulationDifferenceSettler)}</td>
  93. <td>${lastPOPR.NextPopulationTechnician}<br>${formatDelta(lastPOPR.PopulationDifferenceTechnician)}</td>
  94. <td>${lastPOPR.NextPopulationEngineer}<br>${formatDelta(lastPOPR.PopulationDifferenceEngineer)}</td>
  95. <td>${lastPOPR.NextPopulationScientist}<br>${formatDelta(lastPOPR.PopulationDifferenceScientist)}</td>
  96. </tr>
  97. <tr>
  98. <th>unemployed</th>
  99. <td>${unemployed(lastPOPR.NextPopulationPioneer, lastPOPR.PopulationDifferencePioneer, lastPOPR.OpenJobsPioneer, lastPOPR.UnemploymentRatePioneer)}</td>
  100. <td>${unemployed(lastPOPR.NextPopulationSettler, lastPOPR.PopulationDifferenceSettler, lastPOPR.OpenJobsSettler, lastPOPR.UnemploymentRateSettler)}</td>
  101. <td>${unemployed(lastPOPR.NextPopulationTechnician, lastPOPR.PopulationDifferenceTechnician, lastPOPR.OpenJobsTechnician, lastPOPR.UnemploymentRateTechnician)}</td>
  102. <td>${unemployed(lastPOPR.NextPopulationEngineer, lastPOPR.PopulationDifferenceEngineer, lastPOPR.OpenJobsEngineer, lastPOPR.UnemploymentRateEngineer)}</td>
  103. <td>${unemployed(lastPOPR.NextPopulationScientist, lastPOPR.PopulationDifferenceScientist, lastPOPR.OpenJobsScientist, lastPOPR.UnemploymentRateScientist)}</td>
  104. </tr>
  105. </table>
  106. <h2>needs</h2>
  107. ${siteCount} bases
  108. <table>
  109. <tr>
  110. <th></th>
  111. <th>safety</th>
  112. <th>health</th>
  113. <th>comfort</th>
  114. <th>culture</th>
  115. <th>education</th>
  116. </tr>
  117. <tr>
  118. <td>last POPR</td>
  119. <td>${formatPct(lastPOPR.NeedFulfillmentSafety)}</td>
  120. <td>${formatPct(lastPOPR.NeedFulfillmentHealth)}</td>
  121. <td>${formatPct(lastPOPR.NeedFulfillmentComfort)}</td>
  122. <td>${formatPct(lastPOPR.NeedFulfillmentCulture)}</td>
  123. <td>${formatPct(lastPOPR.NeedFulfillmentEducation)}</td>
  124. </tr>
  125. <tr>
  126. <td>total needed</td>
  127. <td>${formatNum(totalNeeds['safety'])}</td>
  128. <td>${formatNum(totalNeeds['health'])}</td>
  129. <td>${formatNum(totalNeeds['comfort'])}</td>
  130. <td>${formatNum(totalNeeds['culture'])}</td>
  131. <td>${formatNum(totalNeeds['education'])}</td>
  132. </tr>
  133. </table>
  134. <h2>POPI</h2>
  135. <table>
  136. <tr>
  137. ${infras.map((infra) => {
  138. if (infra.CurrentLevel === 0)
  139. return '';
  140. const fill = currentPOPIFilled.get(infra.Type)!;
  141. return `<tr>
  142. <td>${infra.Type} T${infra.CurrentLevel}</td>
  143. <td>${fill.numMats}/${infra.Upkeeps.length}</td>
  144. </tr>`;
  145. }).join('')}
  146. </tr>
  147. </table>
  148. <h2>options</h2>
  149. current ${pop} happiness: ${formatPct(projectedHappiness(pop, totalNeeds, siteCount, currentPOPIFilled))}
  150. <table>
  151. ${[...popiFillCombinations(currentPOPIFilled)].map((combination) => {
  152. const happiness = projectedHappiness(pop, totalNeeds, siteCount, combination);
  153. return `<tr>
  154. <td>${[...combination.entries()].map(([building, fill]) => `${building}: ${fill.numMats}`).join(', ')}</td>
  155. <td>${formatPct(happiness)}</td>
  156. </tr>`;
  157. }).join('')}
  158. </table>
  159. `;
  160. }
  161. const formatNum = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format;
  162. const formatPct = new Intl.NumberFormat(undefined, {style: 'percent', maximumFractionDigits: 2}).format;
  163. function formatDelta(n: number, withPlus: boolean = true): string {
  164. if (n > 0)
  165. return `<span class="positive">${withPlus ? '+' : ''}${n}</span>`;
  166. else if (n < 0)
  167. return `<span class="negative">${n}</span>`;
  168. else
  169. return n.toString();
  170. }
  171. function unemployed(people: number, change: number, openJobs: number, unemploymentRate: number): string {
  172. let nextUnemployed;
  173. if (openJobs <= people + change) {
  174. let prevUnemploymentRate = unemploymentRate;
  175. if (openJobs > 0)
  176. if (people - change > 0)
  177. prevUnemploymentRate = -openJobs / (people - change);
  178. else
  179. prevUnemploymentRate = -1
  180. const prevUnemployed = prevUnemploymentRate * (people - change);
  181. nextUnemployed = prevUnemployed + change;
  182. } else
  183. nextUnemployed = -openJobs + change;
  184. return formatDelta(Math.round(nextUnemployed), false);
  185. }
  186. function calcTotalNeeds(popReport: POPR, need: Need): number {
  187. return popReport.NextPopulationPioneer * NEEDS.pio[need]
  188. + popReport.NextPopulationSettler * NEEDS.set[need]
  189. + popReport.NextPopulationTechnician * NEEDS.tec[need]
  190. + popReport.NextPopulationEngineer * NEEDS.eng[need]
  191. + popReport.NextPopulationScientist * NEEDS.sci[need];
  192. }
  193. function calcPOPIFilled(infra: Infrastructure): number | null {
  194. if (infra.CurrentLevel === 0)
  195. return null;
  196. let filled = 0;
  197. for (const upkeep of infra.Upkeeps) {
  198. const nextConsumptionAmount = upkeep.StoreCapacity / 30 * upkeep.Duration; // # capacity is always 30 days
  199. if (upkeep.Stored >= nextConsumptionAmount)
  200. filled++;
  201. }
  202. return filled;
  203. }
  204. function* popiFillCombinations(currentPOPIFilled: POPIFill): Generator<POPIFill> {
  205. const entries = [...currentPOPIFilled.keys()];
  206. function* helper(index: number, current: POPIFill): Generator<POPIFill> {
  207. if (index === entries.length) {
  208. yield new Map(current);
  209. return;
  210. }
  211. const building = entries[index];
  212. const tier = currentPOPIFilled.get(building)!.tier;
  213. for (let i = 0; i <= POPI[building].max; i++) {
  214. current.set(building, {tier, numMats: i});
  215. yield* helper(index + 1, current);
  216. }
  217. }
  218. yield* helper(0, new Map());
  219. }
  220. function projectedHappiness(pop: Pop, totalNeeds: Record<Need, number>, siteCount: number, popiFilled: POPIFill): number {
  221. let totalProvided: Record<Need, number> = {'safety': 50 * siteCount, 'health': 50 * siteCount,
  222. 'comfort': 0, 'culture': 0, 'education': 0};
  223. for (const [building, fill] of popiFilled.entries()) {
  224. const {max, needs} = POPI[building];
  225. for (const {need, satisfaction} of needs) {
  226. const provided = fill.numMats / max * fill.tier * satisfaction;
  227. totalProvided[need] += provided;
  228. }
  229. }
  230. for (const _need in totalProvided) {
  231. const need = _need as Need;
  232. if (totalProvided[need] > totalNeeds[need])
  233. totalProvided[need] = totalNeeds[need];
  234. }
  235. const weights = NEEDS[pop];
  236. let happiness = 1 - Object.values(weights).reduce((sum, weight) => sum + weight, 0); // assume 100% life support
  237. // TODO: caps
  238. for (const [_need, provided] of Object.entries(totalProvided)) {
  239. const need = _need as Need;
  240. happiness += weights[need] * provided / totalNeeds[need];
  241. }
  242. return happiness;
  243. }
  244. const NEEDS: Record<Pop, Record<Need, number>> = {
  245. 'pio': {'safety': 0.25, 'health': 0.15, 'comfort': 0.03, 'culture': 0.02, 'education': 0.01},
  246. 'set': {'safety': 0.30, 'health': 0.20, 'comfort': 0.03, 'culture': 0.03, 'education': 0.03},
  247. 'tec': {'safety': 0.20, 'health': 0.30, 'comfort': 0.20, 'culture': 0.10, 'education': 0.05},
  248. 'eng': {'safety': 0.10, 'health': 0.15, 'comfort': 0.35, 'culture': 0.20, 'education': 0.10},
  249. 'sci': {'safety': 0.10, 'health': 0.10, 'comfort': 0.20, 'culture': 0.25, 'education': 0.30},
  250. }
  251. const POPI: Record<POPIBuilding, {max: number, needs: {need: Need, satisfaction: number}[]}> = {
  252. 'SAFETY_STATION': {max: 3, needs: [{need: 'safety', satisfaction: 2500}]},
  253. 'SECURITY_DRONE_POST': {max: 4, needs: [{need: 'safety', satisfaction: 5000}]},
  254. 'EMERGENCY_CENTER': {max: 5, needs: [{need: 'safety', satisfaction: 1000}, {need: 'health', satisfaction: 1000}]},
  255. 'INFIRMARY': {max: 3, needs: [{need: 'health', satisfaction: 2500}]},
  256. 'HOSPITAL': {max: 6, needs: [{need: 'health', satisfaction: 5000}]},
  257. 'WELLNESS_CENTER': {max: 6, needs: [{need: 'health', satisfaction: 1000}, {need: 'comfort', satisfaction: 1000}]},
  258. 'WILDLIFE_PARK': {max: 5, needs: [{need: 'comfort', satisfaction: 2500}]},
  259. 'ARCADES': {max: 6, needs: [{need: 'comfort', satisfaction: 5000}]},
  260. 'ART_CAFE': {max: 6, needs: [{need: 'comfort', satisfaction: 1000}, {need: 'culture', satisfaction: 1000}]},
  261. 'ART_GALLERY': {max: 4, needs: [{need: 'culture', satisfaction: 2500}]},
  262. 'THEATER': {max: 6, needs: [{need: 'culture', satisfaction: 5000}]},
  263. 'PLANETARY_BROADCASTING_HUB': {max: 6, needs: [{need: 'culture', satisfaction: 1000}, {need: 'education', satisfaction: 1000}]},
  264. 'LIBRARY': {max: 5, needs: [{need: 'education', satisfaction: 2500}]},
  265. 'UNIVERSITY': {max: 6, needs: [{need: 'education', satisfaction: 5000}]},
  266. };
  267. type Pop = 'pio' | 'set' | 'tec' | 'eng' | 'sci';
  268. type Need = 'safety' | 'health' | 'comfort' | 'culture' | 'education';
  269. type POPIBuilding = 'SAFETY_STATION' | 'SECURITY_DRONE_POST' | 'EMERGENCY_CENTER' | 'INFIRMARY' | 'HOSPITAL' |
  270. 'WELLNESS_CENTER' | 'WILDLIFE_PARK' | 'ARCADES' | 'ART_CAFE' | 'ART_GALLERY' | 'THEATER' |
  271. 'PLANETARY_BROADCASTING_HUB' | 'LIBRARY' | 'UNIVERSITY';
  272. type POPIFill = Map<POPIBuilding, {tier: number, numMats: number}>;
  273. interface Planet {
  274. PopulationReports: POPR[]
  275. }
  276. interface POPR {
  277. SimulationPeriod: number;
  278. ReportTimestamp: string;
  279. NeedFulfillmentSafety: number;
  280. NeedFulfillmentHealth: number;
  281. NeedFulfillmentComfort: number;
  282. NeedFulfillmentCulture: number;
  283. NeedFulfillmentEducation: number;
  284. NextPopulationPioneer: number;
  285. NextPopulationSettler: number;
  286. NextPopulationTechnician: number;
  287. NextPopulationEngineer: number;
  288. NextPopulationScientist: number;
  289. PopulationDifferencePioneer: number;
  290. PopulationDifferenceSettler: number;
  291. PopulationDifferenceTechnician: number;
  292. PopulationDifferenceEngineer: number;
  293. PopulationDifferenceScientist: number;
  294. OpenJobsPioneer: number;
  295. OpenJobsSettler: number;
  296. OpenJobsTechnician: number;
  297. OpenJobsEngineer: number;
  298. OpenJobsScientist: number;
  299. UnemploymentRatePioneer: number;
  300. UnemploymentRateSettler: number;
  301. UnemploymentRateTechnician: number;
  302. UnemploymentRateEngineer: number;
  303. UnemploymentRateScientist: number;
  304. }
  305. interface SiteCount {
  306. Count: number;
  307. }
  308. interface Infrastructure {
  309. Type: POPIBuilding;
  310. CurrentLevel: number;
  311. Upkeeps: {Stored: number, StoreCapacity: number, 'Duration': number}[];
  312. }