Spaces:
Paused
Paused
File size: 88,695 Bytes
cae0952 7e2a606 cae0952 6261999 cae0952 5df8767 7e2a606 f12f730 051d7f7 6f39bc5 24f7ec5 016957e 9f4b083 84220dc 6325132 cae0952 016957e cae0952 b88cc9c 9f4b083 cae0952 0735d65 cae0952 dadc0b2 cae0952 0735d65 cae0952 f12f730 cae0952 0735d65 dadc0b2 7e2a606 a4bbde9 7e2a606 947d9db ad2fc1c bd7958c 84220dc cae0952 b88cc9c cae0952 5df8767 f12f730 b1db01d ed7688d b1db01d ed7688d b1db01d 3ac784d b1db01d ed7688d cae0952 5df8767 9f4b083 5df8767 b88cc9c cae0952 b88cc9c 9f4b083 cae0952 9f4b083 dadc0b2 cae0952 5df8767 11037b3 76e5854 cae0952 6261999 b1db01d 7e2a606 b1db01d 11037b3 b1db01d ed7688d 3ac784d 7e2a606 e692ca2 7e2a606 11037b3 ad2fc1c e692ca2 11037b3 7e2a606 11037b3 cae0952 5df8767 7e2a606 003c805 7e2a606 a4bbde9 7e2a606 003c805 7e2a606 003c805 7e2a606 84220dc 4559046 bd7958c 051d7f7 4559046 f75beff 4559046 84220dc 4559046 51531fb 4559046 51531fb 76e5854 a4bbde9 76e5854 51531fb 76e5854 51531fb 76e5854 51531fb a4bbde9 76e5854 15e011a 947d9db 2b50b54 947d9db 15e011a 947d9db 7acc706 947d9db 7acc706 947d9db 7acc706 947d9db 7acc706 24f7ec5 7acc706 24f7ec5 7acc706 051d7f7 947d9db 051d7f7 7acc706 051d7f7 7acc706 051d7f7 7acc706 947d9db e692ca2 7e2a606 bd7958c 785c4de bd7958c 051d7f7 f75beff 9300cbe 0974cb1 f75beff 9300cbe f75beff 9300cbe f75beff 9300cbe f75beff 842eae1 ad2fc1c 842eae1 ad2fc1c 842eae1 7acc706 f75beff 842eae1 ad2fc1c b1db01d 7acc706 96c6d29 b1db01d 96c6d29 b1db01d 96c6d29 016957e bd7958c 842eae1 a6b556b 4cfb896 5df8767 4cfb896 947d9db 8583c67 f3ed241 d1cc6d6 f3ed241 a6b556b f3ed241 a6b556b f3ed241 4cfb896 51531fb 4cfb896 f3ed241 51531fb 6261999 ed7688d 8624f8e 51531fb ed7688d 8624f8e 51531fb ad2fc1c ed7688d 8624f8e 5df8767 7e2a606 84220dc 7e2a606 947d9db f3ed241 84220dc f3ed241 97f8989 ed7688d 84220dc f13e05b 97f8989 84220dc 97f8989 f13e05b 84220dc f13e05b 84220dc 6f39bc5 6325132 f6a4da9 6325132 f6a4da9 6325132 f13e05b d1cc6d6 96c6d29 d1cc6d6 96c6d29 d1cc6d6 96c6d29 d1cc6d6 6261999 7c036e8 6261999 d1cc6d6 6261999 d1cc6d6 5df8767 6261999 f3ed241 6261999 f3ed241 51531fb 6261999 f3ed241 7c036e8 f3ed241 51531fb 5df8767 d1cc6d6 5df8767 d1cc6d6 5df8767 d1cc6d6 5df8767 f6a4da9 f13e05b 5df8767 ad2fc1c 5df8767 947d9db 5df8767 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 | /* ============================================================
The Odyssey galaxy β the open-world meta-layer over the per-planet sim. A
galaxy holds one engine game state per planet (each a normal createGameState);
one is "active" (rendered + controlled by the player) and ticks at full rate,
while the worlds you've left keep evolving in the background on a coarser
schedule (stepGalaxy). The player has a single, relocatable Command Center:
their capital seat travels with them via a Spaceport (jumpCapital), and a world
they leave stays a colony that keeps producing and sends home passive income.
This module owns everything meta: the world roster, universal credits, the
active planet, the per-frame advance (stepGalaxy), background-colony upkeep +
notifications (sweepColonies), a status snapshot for the starmap (galaxyStatus),
and the jump. The per-planet engine is untouched β a planet is just a normal
createGameState flagged `endless` (no victory, no clock β see engine/victory.js).
Determinism: the start world and every planet's map are derived from the galaxy
seed, and the background schedule is keyed to the integer `galaxy.tick`, so the
same seed replays the same galaxy β the same guarantee as the skirmish sim.
============================================================ */
"use strict";
import { createGameState, makeUnit, peekEntityId, restoreEntityId } from "./state.js";
import { mulberry32 } from "./rng.js";
import { updateFog } from "./fog.js";
import { tick } from "./sim.js";
import { createMarket } from "./market.js";
import { createDiplomacy, aiDevelopment, PEACE_THRESHOLD, ALLIED_THRESHOLD,
FACTION_ECHO_PENALTY, FACTION_ECHO_DURATION } from "./diplomacy.js";
import { UNITS, BUILDINGS, freightUsed, freightRoom } from "./entities.js";
import { hasColonyShip } from "./colony.js";
import { PLANET_ARCHETYPE, ODYSSEY_EXTRA_ARCHETYPE, archetypeFor } from "./aiArchetypes.js";
import { DIFFICULTY_OPTIONS } from "./aiDifficulty.js";
import { STRATEGIES } from "./aiStrategy.js";
import { runColonyPolicies } from "./colonyPolicy.js";
import { PLANETS, COM } from "../data.js";
// The worlds an Odyssey can settle: the skirmish nine PLUS the Odyssey-only extras
// (a research capital, an agri world) β appended AFTER the nine so the skirmish
// worlds keep their roster index, which keeps the background-tick schedule (keyed
// on worlds.indexOf) and every same-seed replay stable.
export const ODYSSEY_WORLDS = [...Object.keys(PLANET_ARCHETYPE), ...Object.keys(ODYSSEY_EXTRA_ARCHETYPE)];
// A stable per-planet seed derived from the galaxy seed + the world id, so every
// world generates its own deterministic map and two galaxies with the same seed
// are byte-identical.
function planetSeed(seed, planetId) {
let h = seed >>> 0;
for (let i = 0; i < planetId.length; i++) h = (Math.imul(h ^ planetId.charCodeAt(i), 0x01000193)) >>> 0;
return h >>> 0;
}
// LIVING GALAXY, BOUNDED. The galaxy is alive before you ever visit it β but not ALL of it at
// once: only this many of the OTHER worlds actually exist and simulate from turn one. The whole
// roster stays on the starmap and stays jumpable; an undrawn world is simply DORMANT until the
// player arrives, at which point jumpCapital instantiates it exactly as it always did for a
// never-visited destination. Bounding the live set keeps the per-frame background scheduler's
// work (and a save's size) proportional to a handful of worlds instead of the whole roster,
// while the galaxy still develops, spreads factions and races its Gate behind your back.
export const BACKGROUND_WORLDS = 3;
// WHICH worlds those are: a seeded, pseudo-random draw from the roster minus the start seat, so
// two runs from the same seed bring up the same neighbours (the determinism guarantee in this
// file's header) while two different seeds explore different pockets of the galaxy β the live set
// is part of what a seed means, not a fixed prefix of ODYSSEY_WORLDS.
//
// Drawn from its OWN distinctly-keyed stream (planetSeed with a ":backgroundWorlds" suffix, the
// same idiom neighbourAiProfile uses below) rather than createGalaxy's `pick`, so adding or
// changing this draw can never shift the start-world roll or any other seeded stream.
//
// Returned in fixed ROSTER order, not draw order: the background scheduler (stepGalaxy) keys on
// each world's roster index and every galaxy-wide scan walks `galaxy.worlds`, so keeping the
// creation order roster-ordered leaves those paths reading exactly as they did when every world
// was instantiated.
export function backgroundWorldIds(seed, startId, count = BACKGROUND_WORLDS) {
const pool = ODYSSEY_WORLDS.filter(id => id !== startId);
const n = Math.max(0, Math.min(count | 0, pool.length));
const pick = mulberry32(planetSeed(seed >>> 0, ":backgroundWorlds"));
// Partial Fisher-Yates: draw n distinct worlds without the retry loop a naive
// "roll until it's new" pick would need (and without its unbounded worst case).
for (let i = 0; i < n; i++) {
const j = i + Math.floor(pick() * (pool.length - i));
const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
}
const drawn = new Set(pool.slice(0, n));
return ODYSSEY_WORLDS.filter(id => drawn.has(id));
}
// VARIED NEIGHBOURS: every world but the player's start seat carries its OWN difficulty and AI
// Strategy (personality) β a distribution across the galaxy, some easier, some harder β instead
// of mirroring the player's own splash-screen pick everywhere. Pure function of the galaxy seed +
// planetId (a distinctly-keyed planetSeed, so this draws an independent stream from map
// generation's own rng), so the same galaxy seed always assigns the same profile to the same
// world: exploring is how you learn a neighbour's temperament, not a settings screen.
// aiApm/aiMicro are read off the SAME resolved difficulty entry, never left mismatched with a
// different world's dials (addPlanet threads all four through together).
export function neighbourAiProfile(seed, planetId) {
const pick = mulberry32(planetSeed(seed, planetId + ":neighbourProfile"));
const diffOpt = DIFFICULTY_OPTIONS[Math.floor(pick() * DIFFICULTY_OPTIONS.length)];
const strategyKeys = Object.keys(STRATEGIES);
const aiStrategy = strategyKeys[Math.floor(pick() * strategyKeys.length)];
return { difficulty: diffOpt.mult, aiApm: diffOpt.aiApm, aiMicro: diffOpt.aiMicro, aiStrategy };
}
// Create an Odyssey galaxy. Phase 1: a single active planet (the player's
// randomly-chosen β or explicitly PICKED β starting world) plus the meta-fields
// (credits, activeId, the world roster) the later phases grow into.
export function createGalaxy({ seed = 1, difficulty = "medium", sizeMult = 1,
resourceMult = 1, playerFaction = "frontier", aiApm, aiMicro, aiStrategy, startId: startIdOpt, popCap = null } = {}) {
seed = seed >>> 0;
const pick = mulberry32(seed);
// Always draw, even when the caller supplies an explicit startId: consuming this draw keeps
// every OTHER seed-derived RNG stream byte-identical whether the player picked a world or left
// it to chance (CONTRIBUTING.md's determinism guarantee) β skipping the draw on the "explicit
// pick" path would silently shift whatever a later change might draw from this same `pick`.
const drawnId = ODYSSEY_WORLDS[Math.floor(pick() * ODYSSEY_WORLDS.length)];
const startId = (startIdOpt != null && ODYSSEY_WORLDS.includes(startIdOpt)) ? startIdOpt : drawnId;
const galaxy = {
seed,
credits: 500, // universal credits β galaxy-wide, transportable; fund jumps + trade
activeId: startId, // the world the player is currently on
worlds: ODYSSEY_WORLDS.slice(),
planets: new Map(), // planetId -> engine game state
// `startId` records which world was actually landed on (the player's pick, or the seed's own
// draw) β informational, not read back to reconstruct anything (activeId already IS that
// world, and persists on its own); rides along for free since `settings` is saved whole.
settings: { difficulty, sizeMult, resourceMult, playerFaction, aiApm, aiMicro, aiStrategy, startId, popCap },
tick: 0, // integer galaxy-tick counter (drives the background-world schedule)
time: 0, // galaxy-wide sim clock (seconds) β monotonic across jumps; keys the relief cooldown
entitySeq: 0, // fresh-id counter for entities relocated across worlds by a jump
colonyNotes: new Map(), // per-planet UI notification bookkeeping (galaxy-side, not sim state)
pacified: new Set(), // worlds where you've razed the neighbour's Command Center (a conquest milestone)
pacifyNotes: [], // freshly-pacified world ids awaiting a UI toast + firework (transient, drained by boot.js)
reached: new Set(), // progress milestones already celebrated (see checkGalaxyProgress) β persisted so a reload doesn't re-fire them
milestones: [], // freshly-reached milestones awaiting a UI firework (transient, drained by boot.js)
wonBy: null, // legacy: no Odyssey win any more (play-forever) β kept null for save/skirmish compat
discovered: new Set([startId]), // LIVING GALAXY: worlds the player has actually reached (starmap "explored" + free return-jump). Every world SIMULATES from the start, but the player only SEES a world once they've been there.
claims: new Map(), // faction spread: worldId β controlling faction (checkExpansion), shown on the starmap
expansionNotes: [], // freshly-claimed/expanded worlds awaiting a UI toast (transient, drained by boot.js)
lanes: [], // Freight Lanes (runLanes below): standing shipping routes between held worlds
laneSeq: 0, // fresh lane-id counter (createLane)
};
addPlanet(galaxy, startId);
// LIVING GALAXY: a seeded handful of the other worlds (backgroundWorldIds, above) already exist and
// simulate in the background from turn one β each with its own AI faction founding a base and
// developing (engine/ai.js aiIndustry), its economy growing and its diplomacy drifting β so the
// galaxy is alive before you ever visit, and (checkExpansion) factions spread across it over time.
// Added `unsettled` (no player presence until you jump in). The rest of the roster stays DORMANT
// and is built in on arrival (jumpCapital), so every world remains a destination. The BG scheduler
// (stepGalaxy) spreads the live ones' ticks round-robin. Deterministic: the draw and each world's
// map both come from the galaxy seed, so two same-seed galaxies are byte-identical.
for (const id of backgroundWorldIds(seed, startId)) addPlanet(galaxy, id, { unsettled: true });
return galaxy;
}
// Units staged within this radius of a Spaceport ride along with the capital on
// a jump β you assemble your expedition at the pad, then launch.
export const JUMP_LOAD_RADIUS = 150;
// Credits a jump costs (fuel). Funded by trading at the market, so exploration
// draws on your economy rather than being free.
export const JUMP_COST = 400;
// A held background colony sends home this many credits per second per surviving
// income-earning player building β passive income, so the worlds you leave keep working
// for you (and a razed colony, down to no buildings, quietly stops paying).
export const COLONY_INCOME_PER_BUILDING = 0.3;
// β¦but only up to this many buildings per world. Without a cap, spamming the cheapest
// building (a 75-ore Habitat, a turret) on a pacified world β where the razed neighbour
// can never strike back β was an unbounded credit annuity that made the counter a
// meaningless up-only number. The cap bounds a colony's yield to a real economy's worth.
export const COLONY_INCOME_CAP = 6;
// DOMINATION WITH TEETH, optional occupation dividend (docs/improvement-proposals.md 663): a
// pacified world pays a small flat credits/sec just for being held down β no player building
// required, since the point is that razing the neighbour's capital and keeping it razed is worth
// something on its own, distinct from (and additive with) whatever a colony you've also founded
// there separately earns above. Same universal-credits currency as COLONY_INCOME_PER_BUILDING and
// a small fraction of what a single well-built colony can pay at its cap (COLONY_INCOME_PER_BUILDING
// * COLONY_INCOME_CAP = 1.8/s), so conquest is a genuine but modest reward, not a better economy
// than actually building one.
export const PACIFIED_INCOME = 0.1; // credits/sec per pacified world (~6/min)
const playerBuildingCount = state => {
let n = 0;
for (const b of state.buildings.values()) if (b.owner === "player") n++;
return n;
};
// Buildings that count toward passive income: everything except the pure-defensive
// turret (a turret wall isn't an economy), capped per world (see COLONY_INCOME_CAP).
const incomeBuildingCount = state => {
let n = 0;
for (const b of state.buildings.values()) if (b.owner === "player" && b.type !== "turret") n++;
return Math.min(COLONY_INCOME_CAP, n);
};
// Build (or rebuild) a planet's engine state into the galaxy. Reuses the exact
// skirmish scaffold β economy, both players' bases, fog β but flagged `endless`
// so it never resolves by conquest or clock (see engine/victory.js).
//
// `unsettled` strips the auto-seeded player presence: a jump DESTINATION you
// haven't settled yet has only its neighbour β your capital + forces arrive via
// the jump, not from map generation.
//
// AI dials: the player's OWN splash-screen difficulty/strategy pick (galaxy.settings) applies
// only to the start seat (planetId === galaxy.activeId, true for every world at the point
// createGalaxy calls this). Every other world resolves its own varied profile instead
// (neighbourAiProfile, above) β a distribution across the galaxy, not one setting everywhere.
export function addPlanet(galaxy, planetId, { unsettled = false } = {}) {
const state = buildPlanetState(galaxy, planetId, unsettled);
galaxy.planets.set(planetId, state);
bumpEntityCounterPastGalaxy(galaxy); // keep future live-built ids galaxy-unique (see below)
return state;
}
// A DORMANT world's state, built to LOOK at without waking it. The landing picker
// (landingPicker.js) has to draw the destination's map before the player commits to the jump, and
// only a seeded handful of worlds are alive from turn one (BACKGROUND_WORLDS) β so merely opening
// that modal, or backing out of it, must not add a world to the live set. Built from the same
// (galaxy seed, planetId) inputs addPlanet uses and never ticked, so the state the jump really
// creates on arrival is identical to the one previewed. A world that's already up IS its own
// preview and is returned as-is.
export function previewPlanet(galaxy, planetId) {
const live = galaxy.planets.get(planetId);
if (live) return live;
const state = buildPlanetState(galaxy, planetId, true);
// Even a throwaway world moves the GLOBAL entity-id counter (createGameState re-seeds it from 1
// per world β see bumpEntityCounterPastGalaxy below), so the same bump the registering path does
// is mandatory here too: without it the next thing built anywhere could reuse a live id.
bumpEntityCounterPastGalaxy(galaxy);
return state;
}
// The shared core of both: a planet's engine state, built but not registered.
function buildPlanetState(galaxy, planetId, unsettled) {
const s = galaxy.settings;
const seed = planetSeed(galaxy.seed, planetId);
// A DORMANT world (one the background draw didn't bring up) can be CLAIMED by a spreading faction
// before it ever exists β checkExpansion's sweep walks the whole roster, and the starmap flies the
// claim on it. So a world built in later takes the claimed colours over its own archetype's, exactly
// as checkExpansion flips an already-instantiated world's AI faction on the spot: the flag the
// player has been looking at is the one they meet on landing.
const claimed = galaxy.claims && galaxy.claims.get(planetId);
const aiFaction = claimed || archetypeFor(planetId).faction || "neutral";
const profile = planetId === galaxy.activeId
? { difficulty: s.difficulty, aiApm: s.aiApm, aiMicro: s.aiMicro, aiStrategy: s.aiStrategy }
: neighbourAiProfile(galaxy.seed, planetId);
const state = createGameState({
planetId, seed, rng: mulberry32(seed),
aiApm: profile.aiApm, aiMicro: profile.aiMicro, aiStrategy: profile.aiStrategy, difficulty: profile.difficulty,
sizeMult: s.sizeMult, resourceMult: s.resourceMult, popCap: s.popCap,
playerFaction: s.playerFaction, aiFaction, endless: true,
});
if (unsettled) {
for (const [id, u] of [...state.units]) if (u.owner === "player") state.units.delete(id);
for (const [id, b] of [...state.buildings]) if (b.owner === "player") state.buildings.delete(id);
state.background = true; // not the active seat until you land here
updateFog(state, state.fog, "player");
}
state.market = createMarket(state); // every world has its own price book
state.diplomacy = createDiplomacy(); // and its own neighbour's stance toward you
state.inGalaxy = true; // part of a galaxy β the per-world defeat check is off (engine/victory.js);
// the galaxy never loses (checkGalaxyRescue), it only ends by surrender
return state;
}
// createGameState (engine/state.js) resets the GLOBAL entity-id counter to 1 for each world's
// deterministic seeding β which, once a galaxy holds more than one world, would leave the counter
// BELOW ids already live on other worlds. The next thing the player then built or produced could
// reuse an id and silently overwrite an existing entity (e.g. return to a built-up world after
// visiting a new one and lay down a Spaceport β it clobbers a building that shared the id). So
// after each world is (re)built in, bump the counter past every b/u id anywhere in the galaxy, so
// live mints stay unique galaxy-wide. Deterministic β reads only entity ids (no clock/RNG); the
// seeded ids themselves are untouched, so per-world determinism is preserved.
function idNum(id) {
const n = parseInt(String(id).replace(/^\D+/, ""), 10); // "b12" | "u7" | "g3" β 12 | 7 | 3
return Number.isFinite(n) ? n : 0;
}
function bumpEntityCounterPastGalaxy(galaxy) {
let max = peekEntityId() - 1; // whatever this world's own seeding reached
for (const state of galaxy.planets.values()) {
for (const id of state.units.keys()) max = Math.max(max, idNum(id));
for (const id of state.buildings.keys()) max = Math.max(max, idNum(id));
}
restoreEntityId(max + 1);
}
// Run the background colonies each tick: bank their passive income, watch for
// trouble, and return notifications for the UI. Also the single place their sim
// events are drained β a colony isn't rendered or heard, so nothing else consumes
// them (left alone they would grow without bound). Reports a colony coming under
// attack (a player asset destroyed there) and a colony being lost (its last
// player building razed), each at most once per state transition. The "already
// notified" flags live on the galaxy (colonyNotes), not on the deterministic
// engine state β they're transient UI bookkeeping, re-derived harmlessly on load.
export function sweepColonies(galaxy, dt = 0) {
const out = [];
for (const [id, state] of galaxy.planets) {
if (!state.background) continue;
const buildings = playerBuildingCount(state);
galaxy.credits += incomeBuildingCount(state) * COLONY_INCOME_PER_BUILDING * dt; // capped, turret-excluded passive income
if (galaxy.pacified && galaxy.pacified.has(id)) galaxy.credits += PACIFIED_INCOME * dt; // occupation dividend, additive
const rec = galaxy.colonyNotes.get(id) || { hadColony: false, colonyLost: false };
// A standing colony resets the lost latch, so retaking and rebuilding a world re-arms
// its alerts β without this, a world lost once was muted forever (a second razing never
// re-announced, and the rebuilt colony's under-attack pings stayed suppressed).
if (buildings > 0) { rec.hadColony = true; rec.colonyLost = false; }
if (rec.hadColony && buildings === 0 && !rec.colonyLost) {
rec.colonyLost = true;
out.push({ type: "lost", planetId: id });
} else if (rec.hadColony && !rec.colonyLost) {
// Only worlds the player actually colonised raise hostile/attacked alerts β the living galaxy
// now background-simulates every world (its AI can drift to war on its own), but a neighbour
// turning on a world you've never set foot on isn't your problem and mustn't ping you.
// A background world's diplomacy keeps drifting (diplomacy.js CREEP_RATE), so a
// neighbour eventually declares war β but its neighbourHostile event has no other
// consumer here and used to be silently drained, so the FIRST warning was the colony
// already dying. Surface the declaration (once β diplomacy latches warAnnounced), else
// the ongoing raid (fresh player losses this tick).
if (state.events.some(e => e.type === "neighbourHostile")) out.push({ type: "hostile", planetId: id });
else if (state.events.some(e => e.type === "entityKilled" && e.owner === "player")) out.push({ type: "attacked", planetId: id });
}
galaxy.colonyNotes.set(id, rec);
state.events.length = 0; // drain: a background colony's events have no other consumer
}
return out;
}
// The game state the player is currently on β what boot.js renders and drives.
export function activeState(galaxy) {
return galaxy.planets.get(galaxy.activeId);
}
// Background worlds tick once every BG_STEP galaxy-ticks, each time by BG_STEPΓ the
// step, so a colony advances the same amount of sim time as the active world over
// any span β just in coarser, cheaper increments.
export const BG_STEP = 4;
// id -> roster-index lookup for `galaxy.worlds`, cached by the ARRAY's own identity rather than
// on the galaxy object: `worlds` is built in two places (createGalaxy above, and
// deserializeGalaxy in engine/persist.js), and both set it exactly once and never mutate it in
// place afterwards (append-only roster growth on load rebuilds the array β see persist.js β
// it doesn't push/splice the live `galaxy.worlds` reference), so a WeakMap keyed on that
// reference is a safe, self-invalidating O(1) replacement for the old per-frame
// `galaxy.worlds.indexOf(id)` scan (stepGalaxy runs this once per background world every
// frame, and per its header comment the whole scheduler is meant to stay well under 1ms/frame).
const worldIndexCache = new WeakMap();
function worldIndexMap(worlds) {
let idx = worldIndexCache.get(worlds);
if (!idx) { idx = new Map(worlds.map((id, i) => [id, i])); worldIndexCache.set(worlds, idx); }
return idx;
}
// Advance the whole galaxy by one frame. The active world ticks every frame at
// full cadence (it's rendered and controlled). Every background colony ticks on a
// coarser fixed step, spread round-robin across BG_STEP frames by its fixed roster
// index, so per-frame background work is ~ceil(N/BG_STEP) worlds instead of N.
// Deterministic by construction: the schedule is pure integer arithmetic on the
// galaxy tick and the world's roster position (no wall-clock, no Map-order
// dependence), and each background tick uses the exact constant dtBg so total sim
// time is conserved regardless of cadence.
export function stepGalaxy(galaxy, dt) {
const t = (galaxy.tick = (galaxy.tick | 0) + 1);
galaxy.time = (galaxy.time || 0) + dt; // galaxy-wide clock (deterministic: dt is the fixed step)
tick(activeState(galaxy), dt); // active world: full rate
const dtBg = dt * BG_STEP;
const worldIndex = worldIndexMap(galaxy.worlds);
for (const [id, state] of galaxy.planets) {
if (id === galaxy.activeId || !state.background) continue;
if (t % BG_STEP === worldIndex.get(id) % BG_STEP) tick(state, dtBg);
}
// Freight Lanes: a standing shipping drip, on its own integer-tick schedule (LANE_PERIOD) β
// independent of the ~1/sec scan below (lanes move real cargo, so they're worth their own,
// coarser cadence rather than piggybacking on a scan meant for cheap galaxy-wide bookkeeping).
if (t % LANE_PERIOD === 0) runLanes(galaxy);
// These galaxy-wide scans (conquest progress, milestones, no-foothold relief) all change on a
// minutes timescale, so running them every frame (20 Hz) is wasted work that grows with the
// colony count. Throttle to ~1/sec on the same deterministic integer schedule the BG round-
// robin uses β a milestone/pacification/rescue firing up to a second later is imperceptible,
// and RELIEF_COOLDOWN (20 s) dwarfs it. Runs on tick 1 too, so a freshly-stepped galaxy is
// checked immediately. Pure integer arithmetic on galaxy.tick β no wall-clock, deterministic.
if (t === 1 || t % PROGRESS_CHECK_EVERY === 0) {
checkDomination(galaxy); // conquest progress: pacified worlds (per-world toast) + a milestone at the target
checkExpansion(galaxy); // faction spread: developed AI worlds claim + colonise across the starmap
updateFactionWarmth(galaxy); // faction memory (allied direction): refresh each world's Allied-faction-mate count
checkGalaxyProgress(galaxy); // milestones: colonies founded, the Antimatter Gate coming online β fireworks, not wins
checkRivalGate(galaxy); // the rival Gate: track the galaxy's own charging threat, ascend it on completion
checkGalaxyRescue(galaxy); // NEVER auto-defeat: a total wipeout sends a relief colony ship so life goes on
runColonyPolicies(galaxy); // colony standing orders (engine/colonyPolicy.js): auto-sell + sustain workers, background worlds only
}
}
const PROGRESS_CHECK_EVERY = 20; // galaxy-wide scans run ~once per second (20 Hz sim), not every frame
// The Odyssey NEVER ends in defeat β as long as you haven't surrendered, life goes on
// (surrenderGalaxy is the only terminal state). So there's no galaxy-wide loss; instead, if you
// ever hold NO foothold anywhere (every Command Center razed and no colony ship left), a RELIEF
// colony ship is dispatched to your active world's landing zone so you can re-found and fight on.
// A short cooldown bounds the churn if relief keeps getting farmed, and its arrival is flagged for
// a UI toast. Once you hold the ship you have a foothold again, so it won't re-send. The per-world
// checkEndlessLoss is suppressed for galaxy states (state.inGalaxy, engine/victory.js), so this is
// the sole authority. The cooldown is keyed on the GALAXY-WIDE clock (galaxy.time), not any one
// world's local time β a jump swaps the active world, and each world's clock advances on its own,
// so comparing lastReliefTime (set on world A) to world B's time could read as "cooldown elapsed"
// (B younger than A β negative delta) or "never elapses", either of which breaks the anti-farm
// bound and could dead-end the no-defeat guarantee. galaxy.time is monotonic across the whole run.
// Pure + deterministic (galaxy.time accumulates the fixed dt in stepGalaxy).
export const RELIEF_COOLDOWN = 20; // sim seconds between relief drops (anti-farm)
export function checkGalaxyRescue(galaxy) {
const active = activeState(galaxy);
if (active.over) return; // a surrender already ended it
for (const state of galaxy.planets.values()) {
for (const b of state.buildings.values())
if (b.owner === "player" && b.type === "command") return; // a base somewhere β still going
if (hasColonyShip(state, "player")) return; // β¦or a colony ship to re-found from
}
const now = galaxy.time || 0;
if (now - (galaxy.lastReliefTime ?? -Infinity) < RELIEF_COOLDOWN) return; // still on cooldown
galaxy.lastReliefTime = now;
const lz = active.map.bases.player;
const ship = makeUnit("colonyship", "player", lz.x, lz.y);
ship.id = "g" + (galaxy.entitySeq = (galaxy.entitySeq || 0) + 1); // galaxy id scheme (as in jumpCapital)
active.units.set(ship.id, ship);
galaxy.reliefNote = true; // drained by boot.js for a toast
}
// The one terminal state: the player voluntarily gives up. Ends the run on the active seat (the
// boot.js over-poll then shows the game-over screen with the surrender copy). A wipeout alone
// never triggers this β only an explicit surrender does.
export function surrenderGalaxy(galaxy) {
const active = activeState(galaxy);
if (active.over) return;
active.over = true; active.winner = "ai";
galaxy.surrendered = true;
}
// Record a one-time progress milestone β a firework, drained UI-side by boot.js. Idempotent
// per id, and `reached` persists (engine/persist.js) so a reload never replays a milestone
// you've already celebrated.
// The complete milestone vocabulary β the ONLY ids reachMilestone ever mints. Exported so the load
// path can filter a saved `reached` set against it (engine/persist.js): an unfiltered set let a
// hand-edited save inject junk, and β more importantly β filtering against the WORLD roster instead
// would silently drop every real milestone and replay its fireworks on the next load.
export const MILESTONE_IDS = ["capital", "gate", "domination", "domination:all", "rival-gate"];
/** True for any id reachMilestone can produce, including the per-world `world:N` family. */
export function isMilestoneId(id) {
return typeof id === "string" && (MILESTONE_IDS.includes(id) || /^world:\d+$/.test(id));
}
function reachMilestone(galaxy, id) {
if (galaxy.reached.has(id)) return;
galaxy.reached.add(id);
galaxy.milestones.push(id);
}
// Progress milestones for the play-forever sandbox β the fireworks that mark how far you've
// come, in place of a victory that would end the run. Swept galaxy-wide each tick, each fired
// once (reachMilestone): founding your first base and each further WORLD you settle
// ("world:N"), fortifying your first Capital ("capital"), and bringing an Antimatter Gate
// online anywhere ("gate" β the former economic victory, now a triumph you keep playing past).
// Conquest milestones live in checkDomination. Pure β reads only entity state; the firework
// itself is fired UI-side (boot.js), keeping the engine DOM-free.
export function checkGalaxyProgress(galaxy) {
let settledWorlds = 0, hasCapital = false, gateOnline = false;
for (const state of galaxy.planets.values()) {
let heldHere = false;
for (const b of state.buildings.values()) {
if (b.owner !== "player") continue;
if (b.type === "command" && !b.constructing) { heldHere = true; if (b.capital) hasCapital = true; }
if (BUILDINGS[b.type]?.wonder && (b.charge || 0) >= 1) gateOnline = true;
}
if (heldHere) settledWorlds++;
}
for (let n = 1; n <= settledWorlds; n++) reachMilestone(galaxy, "world:" + n);
if (hasCapital) reachMilestone(galaxy, "capital");
if (gateOnline) reachMilestone(galaxy, "gate");
}
// Worlds to pacify (raze the neighbour's Command Center on) for the grand CONQUEST
// milestone β the military, multi-world firework. No longer a win: the galaxy keeps
// running past it (play-forever).
export const DOMINATION_TARGET = 4;
// The AI's foothold on a world β a Command Center OR an undeployed colony ship. The
// colony-ship clause is what stops checkDomination false-pacifying every world at
// tick 0 (both sides now START with a CC-less colony ship), and keeps "pacified"
// meaning "you actually drove them off" β a neighbour reduced to a lone ship can still
// re-found, so it isn't conquered yet. hasColonyShip is false in a skirmish.
const hasAiCommand = state => {
for (const b of state.buildings.values()) if (b.owner === "ai" && b.type === "command" && !b.constructing) return true;
return hasColonyShip(state, "ai");
};
// The faction a world answers to for cross-world faction-memory purposes (the grievance echo
// below, and updateFactionWarmth's Allied lift): its DYNAMIC controlling faction (checkExpansion's
// claims, once spread) when it has one, else its own neighbour's faction (state.players.ai.faction)
// β the same claim-first, AI-faction-fallback resolution checkExpansion's own self-claim step
// effectively establishes. Null for a state with no AI player at all.
function worldFaction(galaxy, id, state) {
const claimed = galaxy.claims && galaxy.claims.get(id);
if (claimed) return claimed;
return (state && state.players.ai && state.players.ai.faction) || null;
}
// Conquest progress. A world is "pacified" the moment its neighbour has no standing
// Command Center β you razed it (only two sides fight, and every world is seeded with an
// AI capital). Pacification is STICKY (recorded on the galaxy, so a neighbour rebuilding
// can't un-pacify it); each freshly-pacified world is queued for a UI toast + firework
// (pacifyNotes), and reaching DOMINATION_TARGET fires the grand "domination" milestone β
// a firework, NOT a win, so the sandbox plays on. Deterministic β reads only entity state.
//
// DOMINATION WITH TEETH (docs/improvement-proposals.md 657-665): also stamp the diplomacy-side
// floor flag (state.diplomacy.pacified) on the razed world itself β checkDomination already holds
// that state in this loop. engine/diplomacy.js's updateDiplomacy reads the flag to floor the
// world's drift target at Neutral permanently, so a conquered neighbour rebuilds and defends
// itself but never re-initiates.
//
// FACTION MEMORY (docs/improvement-proposals.md 519-527), tuned as ONE system with the floor
// above: once every fresh pacification THIS TICK is recorded (so two faction-mates pacified in
// the same tick correctly exclude each other below, regardless of which one Map iteration visits
// first β see the second loop), echo a bounded, ONE-TIME stance penalty onto each pacified world's
// remaining UNPACIFIED faction-mates (matched by claim or AI faction β worldFaction, above), plus a
// starmap toast queued the same way checkExpansion already queues its own (expansionNotes). An
// already-pacified faction-mate is excluded β its own floor (this same function, an earlier tick)
// already holds it at Neutral-or-better, so a further hit would only fight that floor for no
// visible effect; a domination spree instead snowballs resistance across whichever of the
// faction's worlds AREN'T yet conquered.
export function checkDomination(galaxy) {
const active = activeState(galaxy);
if (active.over) return;
const freshlyPacified = [];
for (const [id, state] of galaxy.planets) {
if (galaxy.pacified.has(id) || hasAiCommand(state)) continue;
galaxy.pacified.add(id);
galaxy.pacifyNotes.push(id);
state.diplomacy.pacified = true;
freshlyPacified.push({ id, state });
}
for (const { id, state } of freshlyPacified) {
const faction = worldFaction(galaxy, id, state);
if (!faction || faction === "neutral") continue;
for (const [mateId, mateState] of galaxy.planets) {
if (mateId === id || galaxy.pacified.has(mateId)) continue;
if (worldFaction(galaxy, mateId, mateState) !== faction) continue;
const dip = mateState.diplomacy;
if (!dip) continue;
dip.stance = Math.max(-1, Math.min(1, dip.stance - FACTION_ECHO_PENALTY));
dip.factionEchoUntil = mateState.time + FACTION_ECHO_DURATION;
}
galaxy.expansionNotes.push({ type: "factionEcho", planetId: id, faction });
}
if (galaxy.pacified.size >= DOMINATION_TARGET) reachMilestone(galaxy, "domination");
// The maximal achievement in a play-forever sandbox β pacifying EVERY world β gets its
// own grander milestone, so the conquest-minded player who pushes hours past the 4-world
// target isn't met with silence (and a per-conquest toast reading "Conquered 11/4").
if (galaxy.pacified.size >= galaxy.worlds.length) reachMilestone(galaxy, "domination:all");
}
// FACTION SPREAD across the galaxy. The living galaxy fills up with developing AI factions
// (engine/ai.js aiIndustry); this is how their influence spreads between planets over a long game:
// β’ SELF-CLAIM β a world whose AI has developed past CLAIM_DEV flies its own faction's flag.
// β’ EXPAND β the single most-developed claimed world (past EXPAND_DEV) colonises the NEAREST still-
// unclaimed world, claiming it and flipping that world's AI to its colours β so a thriving faction
// absorbs the fringe and its territory grows across the starmap.
// One cross-planet claim per scan keeps the spread gradual; it's paced by how fast worlds develop
// (minutes), and converges once every world is claimed. Deterministic: development is an entity count,
// distance is fixed planet-x, order is the fixed roster, ties break by world id β no clock, no RNG.
// Reads/writes only galaxy.claims (persisted) + the AI's faction (persisted), so it's save/load-safe.
export const CLAIM_DEV = 3; // industrial development at which a world's AI faction claims its homeworld
export const EXPAND_DEV = 6; // ...and at which a thriving world reaches out to colonise a neighbour
export function checkExpansion(galaxy) {
const claims = galaxy.claims || (galaxy.claims = new Map());
const notes = galaxy.expansionNotes || (galaxy.expansionNotes = []);
// SELF-CLAIM: every developed homeworld with a standing base flies its faction's flag (roster order).
for (const id of galaxy.worlds) {
if (claims.has(id)) continue;
const s = galaxy.planets.get(id);
if (!s || !hasAiCommand(s) || aiDevelopment(s) < CLAIM_DEV) continue;
const faction = s.players.ai ? s.players.ai.faction : "neutral";
claims.set(id, faction);
notes.push({ type: "claim", planetId: id, faction });
}
// EXPAND: the single most-developed claimed world past EXPAND_DEV (tie by id) colonises the nearest
// still-unclaimed world (tie by id). One per scan β gradual spread.
let exp = null, expDev = EXPAND_DEV - 1e-9;
for (const id of galaxy.worlds) {
if (!claims.has(id)) continue;
const s = galaxy.planets.get(id);
if (!s) continue;
const dev = aiDevelopment(s);
if (dev > expDev || (dev === expDev && exp !== null && id < exp)) { expDev = dev; exp = id; }
}
if (exp === null) return;
let best = null, bestD = Infinity;
for (const other of galaxy.worlds) {
if (claims.has(other)) continue;
// Never colonise the world the player is on, or one they hold a base on. Expansion flips the
// target world's AI faction (below), and faction feeds LIVE combat/economy multipliers
// (factions.js β sideMod, read on every hit and haul) β so flipping a world the player is
// actively fighting on would silently change the enemy's damage/sight/speed/gather mid-battle,
// with only a starmap toast to show for it. Spread only reaches the unclaimed, unheld fringe.
if (other === galaxy.activeId || playerFoothold(galaxy.planets.get(other))) continue;
const d = Math.abs(planetX(other) - planetX(exp));
if (d < bestD || (d === bestD && best !== null && other < best)) { bestD = d; best = other; }
}
if (best === null) return;
const faction = claims.get(exp);
claims.set(best, faction);
const bs = galaxy.planets.get(best);
if (bs && bs.players.ai) bs.players.ai.faction = faction; // the colonised world flies the expander's colours
notes.push({ type: "expand", from: exp, planetId: best, faction });
}
// FACTION MEMORY, allied direction (docs/improvement-proposals.md 519-527) β the mirror of
// checkDomination's grievance echo above: holding an Allied stance on one world lifts the drift
// TARGET of its faction-mates a little (engine/diplomacy.js updateDiplomacy), so befriending a
// bloc pays across its whole territory. Unlike the grievance echo this isn't a one-time event β
// it's a continuously-held CONDITION β so it's recomputed here on the same throttled ~1/sec scan
// stepGalaxy already runs checkDomination/checkExpansion on, rather than latched by an event: a
// world that drifts back down out of Allied stops granting the lift again within about a second.
//
// Stores a RAW COUNT per world (dip.factionWarmth: how many of ITS OTHER faction-mates are
// currently Allied), mirroring aiDevelopment's own raw-count shape β updateDiplomacy applies the
// per-unit weight and cap at the point of use, exactly like aiDevelopment's own
// DEV_SOFT_PER/DEV_SOFT_CAP. Pure (reads only persisted galaxy/diplomacy state) and deterministic
// (fixed roster order, no RNG, no wall clock).
export function updateFactionWarmth(galaxy) {
const alliedByFaction = new Map(); // faction -> how many of its worlds are currently Allied
for (const id of galaxy.worlds) {
const state = galaxy.planets.get(id);
const dip = state && state.diplomacy;
if (!dip || dip.stance < ALLIED_THRESHOLD) continue;
const faction = worldFaction(galaxy, id, state);
if (!faction || faction === "neutral") continue;
alliedByFaction.set(faction, (alliedByFaction.get(faction) || 0) + 1);
}
for (const id of galaxy.worlds) {
const state = galaxy.planets.get(id);
const dip = state && state.diplomacy;
if (!dip) continue;
const faction = worldFaction(galaxy, id, state);
let count = (faction && alliedByFaction.get(faction)) || 0;
if (dip.stance >= ALLIED_THRESHOLD) count--; // a world's own Allied stance doesn't lift itself
dip.factionWarmth = Math.max(0, count);
}
}
/* ---------- THE RIVAL GATE (docs/improvement-proposals.md "the galaxy's strongest faction races
its own wonder" + "a fully-teched neighbour races its own Antimatter Gate", merged per the
Phase 7 plan β two independently-written proposals describing the same feature, reconciled
into one mechanism here).
The TRIGGER (whether a world raises and charges its own Gate at all) lives entirely in
engine/aiIndustry.js's rivalGateEligible/RIVAL_GATE_BUFFER β every qualifying world builds its
own Gate independently, self-contained, on its own think cycle, exactly like every other
aiIndustry.js building decision. This module's job is narrower: pick which ONE currently-
charging world is surfaced to the player as THE Rival Gate (checkExpansion's own most-
developed, tie-by-id idiom, just above β deterministic, no clock/RNG), track its live charge
for the starmap, and apply the 'ascension' consequence to EVERY world whose Gate completes β
not only the tracked one, so two simultaneously-eligible worlds (a rare but real possibility on
a Hard-heavy galaxy) can't let a second Gate complete unnoticed just because this scan happened
to be watching a different one.
THE ASCENSION CONSEQUENCE deliberately COMBINES both twins' proposals rather than picking one:
the claimed-neighbours burst (Defense twin) AND the hardEdge upgrade + a permanent stance
ceiling (AI twin) are not mutually exclusive, and the trigger is already double-gated (the
Strategic tier banked AND Hard/wantsDeepIndustry) β a genuinely rare, hard-earned event, so
escalating hard on it reads as "the galaxy changed under you" (the Defense twin's own framing)
rather than as pile-on. Never a defeat: state.over is never touched by any of this. */
// The AI twin's proposal literally says "a permanent stance floor" for the ascension; taken
// literally that would HELP the player (the same shape as diplomacy.js's own APPEASE_FLOOR truce
// line), which contradicts its own framing as a "penalty toward the player" β so this reads it as
// a permanent CEILING instead: the ascended world's stance can never rise back past Wary, no
// matter how the scarcity/development pull on it evolves. Reapplied every scan (below), not a
// one-time snap β updateDiplomacy's own drift would otherwise claw it back up, and the AI twin's
// own "Where" note explicitly keeps this out of engine/diplomacy.js ("needs no change").
//
// EXCEPT once the player has personally pacified that same world (Domination with teeth,
// checkDomination/dip.pacified below): conquest is the more direct, more recent player action, so
// it wins over a stale ascension from before the capital fell. Without this guard the ceiling and
// the pacified floor fight forever β the ceiling reasserts every scan and always wins the raw
// stance value between updateDiplomacy's slower drift ticks, so razing an ascended neighbour's
// Command Center would have no visible effect on its stance, which reads as broken in play.
export const RIVAL_ASCENSION_STANCE_CEILING = PEACE_THRESHOLD;
// The currently-charging AI-owned wonder building on `state`, or null β the same predicate
// aiIndustry.js's own `hasGate` check uses, just read from the outside.
function aiWonderOn(state) {
for (const b of state.buildings.values())
if (b.owner === "ai" && BUILDINGS[b.type]?.wonder) return b;
return null;
}
// Apply the ascension consequence to `worldId` exactly once. Combines both twins' proposals (see
// the header comment above): hardEdge + a permanent stance ceiling, AND an all-at-once claims
// burst β every still-unclaimed world the player hasn't founded/settled on and isn't currently
// standing on (the exact same safety exclusion checkExpansion's own EXPAND step already applies,
// for the exact same reason: flipping a world's faction out from under live combat/economy
// multipliers). Deliberately ALL AT ONCE rather than checkExpansion's own one-per-scan trickle β
// an ascension is a single dramatic event, not a gradual spread.
function applyRivalAscension(galaxy, worldId, state) {
if (state.players.ai) state.players.ai.upgrades.hardEdge = true;
(galaxy.rivalAscended || (galaxy.rivalAscended = new Set())).add(worldId);
if (state.diplomacy && !state.diplomacy.pacified)
state.diplomacy.stance = Math.min(state.diplomacy.stance, RIVAL_ASCENSION_STANCE_CEILING);
const claims = galaxy.claims || (galaxy.claims = new Map());
const notes = galaxy.expansionNotes || (galaxy.expansionNotes = []);
const faction = state.players.ai ? state.players.ai.faction : "neutral";
claims.set(worldId, faction);
for (const other of galaxy.worlds) {
if (other === worldId || claims.has(other)) continue;
if (other === galaxy.activeId || playerFoothold(galaxy.planets.get(other))) continue;
claims.set(other, faction);
const os = galaxy.planets.get(other);
if (os && os.players.ai) os.players.ai.faction = faction;
notes.push({ type: "expand", from: worldId, planetId: other, faction });
}
}
// The rival-gate scan, run at PROGRESS_CHECK_EVERY cadence from stepGalaxy β the same throttled
// schedule checkDomination/checkExpansion/checkGalaxyProgress already share. Three jobs, in order:
// 1. Ascension sweep β any AI-owned wonder anywhere at full charge ascends exactly once
// (galaxy.rivalAscended is the idempotency latch), regardless of whether it was the tracked
// world. Fires the singular "rival-gate" milestone the first time this happens anywhere.
// 2. Reapply the stance ceiling for every already-ascended world β "permanent" in the same sense
// DOMINATION_TARGET's pacify flag is permanent: reasserted every scan, not a single mutation.
// 3. Selection β if nothing is currently tracked, pick the single most-developed world with a
// live (charging, not yet ascended) Gate to surface as THE Rival Gate (galaxyStatus below).
// Iterates `galaxy.worlds` (the fixed roster order), never the Map, so neither insertion order nor
// Map iteration can affect the outcome β only aiDevelopment values and the id tie-break can.
export function checkRivalGate(galaxy) {
const ascended = galaxy.rivalAscended || (galaxy.rivalAscended = new Set());
const notes = galaxy.rivalGateNotes || (galaxy.rivalGateNotes = []);
// 1) ASCENSION SWEEP.
for (const worldId of galaxy.worlds) {
if (ascended.has(worldId)) continue;
const state = galaxy.planets.get(worldId);
if (!state) continue;
const b = aiWonderOn(state);
if (!b || (b.charge || 0) < 1) continue;
applyRivalAscension(galaxy, worldId, state);
notes.push({ type: "ascended", planetId: worldId });
reachMilestone(galaxy, "rival-gate");
if (galaxy.rivalGate && galaxy.rivalGate.worldId === worldId) galaxy.rivalGate = null;
}
// 2) REAPPLY the permanent stance ceiling on every ascended world β see the header comment on
// why this lives here rather than in engine/diplomacy.js's own drift. Skips a world the player
// has since personally pacified (see the header comment on RIVAL_ASCENSION_STANCE_CEILING) β
// conquest overrides a stale ascension, not the other way around.
for (const worldId of ascended) {
const state = galaxy.planets.get(worldId);
if (state && state.diplomacy && !state.diplomacy.pacified)
state.diplomacy.stance = Math.min(state.diplomacy.stance, RIVAL_ASCENSION_STANCE_CEILING);
}
// 3) SELECTION β only when nothing is currently tracked. Clear a stale tracked reference first
// (its building razed out from under it without ascending).
if (galaxy.rivalGate) {
const state = galaxy.planets.get(galaxy.rivalGate.worldId);
const b = state && state.buildings.get(galaxy.rivalGate.buildingId);
if (!b || b.owner !== "ai" || !BUILDINGS[b.type]?.wonder || (b.charge || 0) >= 1) {
galaxy.rivalGate = null;
}
}
if (!galaxy.rivalGate) {
let best = null, bestDev = -Infinity, bestBuilding = null;
for (const worldId of galaxy.worlds) {
if (ascended.has(worldId)) continue;
const state = galaxy.planets.get(worldId);
if (!state) continue;
const b = aiWonderOn(state);
if (!b || (b.charge || 0) >= 1) continue;
const dev = aiDevelopment(state);
if (dev > bestDev || (dev === bestDev && best !== null && worldId < best)) { bestDev = dev; best = worldId; bestBuilding = b; }
}
if (best !== null) {
galaxy.rivalGate = { worldId: best, buildingId: bestBuilding.id };
notes.push({ type: "spotted", planetId: best });
}
}
}
// The tracked Rival Gate's live status for the starmap β { worldId, charge } or null. Reads the
// CURRENT charge straight off the building (not a stale snapshot), so a caller polling this every
// frame always sees the live number.
function rivalGateStatus(galaxy) {
if (!galaxy.rivalGate) return null;
const state = galaxy.planets.get(galaxy.rivalGate.worldId);
const b = state && state.buildings.get(galaxy.rivalGate.buildingId);
if (!b) return null;
return { worldId: galaxy.rivalGate.worldId, charge: b.charge || 0 };
}
// A pure snapshot of the galaxy for the starmap: per-world status (your active
// seat / a colony you hold / unexplored) and, for worlds you've been to, the
// neighbour's stance. Plus the visited count and credits.
export function galaxyStatus(galaxy) {
// Every world simulates, but the player only SEES one they've REACHED (`discovered`). An
// undiscovered world reads "unexplored" and hides its neighbour's stance, exactly as before the
// living galaxy β the difference is now invisible until you visit. Old saves default to the
// instantiated set (which for them was precisely the visited worlds).
const discovered = galaxy.discovered || new Set(galaxy.planets.keys());
return {
credits: galaxy.credits,
activeId: galaxy.activeId,
visited: discovered.size,
total: galaxy.worlds.length,
pacified: galaxy.pacified ? galaxy.pacified.size : 0, // Domination progress: worlds conquered
dominationTarget: DOMINATION_TARGET,
rivalGate: rivalGateStatus(galaxy), // the tracked Rival Gate's { worldId, charge }, or null β starmap alert
worlds: galaxy.worlds.map(id => {
const seen = discovered.has(id);
const s = seen ? galaxy.planets.get(id) : null;
let status = "unexplored", income = 0;
const pacified = !!(galaxy.pacified && galaxy.pacified.has(id));
// LABEL AND INCOME ARE DECIDED INDEPENDENTLY. "pacified" outranks colony/contested as a
// label, but it is NOT a reason to report no income: sweepColonies pays a conquered world
// BOTH halves (its buildings' passive income AND the occupation dividend, additively), so
// a `pacified` arm that short-circuited before the income line made the player's best-paying
// world read 0 on the starmap. What's reported here mirrors what sweepColonies actually
// banks, dividend included and background-only β the seat earns nothing there
// (`if (!state.background) continue;`) and so must report nothing here.
if (id === galaxy.activeId) status = "seat";
else if (s) {
const buildings = playerBuildingCount(s);
// A world you've been to is a "colony" only while you still hold a
// building there; once razed it's "contested" β visited but no longer
// yours (so the map doesn't keep calling a lost world your colony).
status = pacified ? "pacified" : (buildings > 0 ? "colony" : "contested");
income = s.background // credits/min (capped, turret-excluded)
? Math.round((incomeBuildingCount(s) * COLONY_INCOME_PER_BUILDING + (pacified ? PACIFIED_INCOME : 0)) * 60)
: 0;
}
else if (pacified) status = "pacified"; // conquered but not in `discovered` (so `s` is null) β still label it
// Industry/Tech ratings (data.js) drive factory speed + research speed and
// finished-good prices β surfaced so "where to settle/jump" is an informed call.
const p = PLANETS.find(pl => pl.id === id);
// `faction` is the world's fixed cultural origin (data.js PLANETS); `controlledBy` is the DYNAMIC
// faction that has claimed it via expansion (checkExpansion) β the spreading galactic politics
// shown on the starmap. Visible galaxy-wide (it's sphere-of-influence news, not tactical intel).
return { id, status, income, pacified, stance: seen && s && s.diplomacy ? s.diplomacy.stance : null,
industry: p ? p.industry : 5, tech: p ? p.tech : 5, faction: p ? p.faction : null,
controlledBy: (galaxy.claims && galaxy.claims.get(id)) || null };
}),
};
}
// The fortified Capital: an upgraded Command Center with double HP. Like every deployed
// base it is permanent β it never travels (interplanetary jumps carry a colony ship, not
// a base; jumpVessel/jumpCapital) β so the Capital is your hardened anchor world. One
// per owner; the flag also drives its gold ring on the map (render.js).
export const CAPITAL_UPGRADE_COST = { ore: 400 };
export const CAPITAL_HP_MULT = 2;
// Improve a Command Center into your Capital: pay the cost, mark it, and scale its HP
// (and current HP, preserving any battle damage as a fraction) by CAPITAL_HP_MULT.
// Odyssey-only, one Capital per owner, not on a still-constructing CC. Deterministic β
// pure state mutation, no clock/RNG. Returns whether the upgrade happened.
export function upgradeToCapital(state, building) {
if (!building || building.type !== "command" || building.constructing || building.capital) return false;
const owner = building.owner;
for (const b of state.buildings.values())
if (b.owner === owner && b.capital) return false; // only one Capital
const res = state.players[owner].resources;
for (const com in CAPITAL_UPGRADE_COST) if ((res[com] || 0) < CAPITAL_UPGRADE_COST[com]) return false;
for (const com in CAPITAL_UPGRADE_COST) res[com] -= CAPITAL_UPGRADE_COST[com];
building.capital = true;
building.maxHp = Math.round(building.maxHp * CAPITAL_HP_MULT);
building.hp = Math.round(building.hp * CAPITAL_HP_MULT); // keeps the damage fraction
return true;
}
// The Spaceport comes in THREE tiers. Its jump capacity β how much fleet it can launch
// at once, measured in ship POPULATION (supply cost), not head-count β scales with the
// tier, so a very large army has to cross in several jumps through a small pad, or you
// upgrade to a bigger one. Capacity is indexed by tier (1..3); index 0 is unused.
export const SPACEPORT_MAX_TIER = 3;
export const SPACEPORT_CAPACITY = [0, 12, 24, 40]; // supply carried per jump, by tier
export const SPACEPORT_UPGRADE_COST = { 2: { ore: 250 }, 3: { ore: 500 } }; // ore to reach a tier (escalating)
// A Spaceport's tier (defaults to 1 for a fresh pad or a pre-tier save) and the per-jump
// capacity it grants.
export const spaceportTier = b => Math.min(SPACEPORT_MAX_TIER, Math.max(1, b.tier || 1));
export const jumpCapacity = b => SPACEPORT_CAPACITY[spaceportTier(b)];
// Upgrade a Spaceport one tier (max 3): pay the ore, bump the tier, raising its per-jump
// capacity. Odyssey-only, deterministic (pure state mutation β no clock/RNG), like the
// Capital fortification. Returns whether it happened (refused when already max, still
// under construction, or unaffordable).
export function upgradeSpaceport(state, building) {
if (!building || building.type !== "spaceport" || building.constructing) return false;
const tier = spaceportTier(building);
if (tier >= SPACEPORT_MAX_TIER) return false;
const cost = SPACEPORT_UPGRADE_COST[tier + 1];
const res = state.players[building.owner].resources;
for (const com in cost) if ((res[com] || 0) < cost[com]) return false;
for (const com in cost) res[com] -= cost[com];
building.tier = tier + 1;
return true;
}
const unitSupply = u => UNITS[u.type]?.supplyCost || 0;
// What counts as a launch-ready pad: the player's own, finished Spaceport. Defined once so
// jumpVessel/canJump/jumpCapital can't drift on the predicate (owner + type + built).
const isPlayerSpaceport = b => b.owner === "player" && b.type === "spaceport" && !b.constructing;
// EVERY launch-ready pad the player holds on this world, not just one β a world can carry
// more than one completed Spaceport (a second pad built for extra jump throughput, or while
// upgrading the first). Sorted by id for a stable, deterministic iteration order (matches the
// codebase's usual entity tie-break), so which pad "goes first" below can never depend on Map
// insertion order.
export const playerSpaceports = state => [...state.buildings.values()].filter(isPlayerSpaceport).sort((a, b) => (a.id < b.id ? -1 : 1));
// The colony ship that would carry an interplanetary jump: a player colony ship staged within
// JUMP_LOAD_RADIUS of ANY completed Spaceport (not just one β see playerSpaceports). A jump
// relocates the SHIP (and the rest of the staged expedition) β NOT a deployed base. Deployed
// Command Centers are permanent: the world you leave keeps them and becomes a background
// colony. Deploy the ship at the destination to found your new base there. Null when no ship
// is on any pad.
export function jumpVessel(state) {
const spaceports = playerSpaceports(state);
if (!spaceports.length) return null;
for (const u of state.units.values())
if (u.owner === "player" && u.type === "colonyship"
&& spaceports.some(sp => Math.hypot(u.x - sp.x, u.y - sp.y) <= JUMP_LOAD_RADIUS)) return u;
return null;
}
// Can the player launch a jump from this world? β just a completed Spaceport. No colony
// ship is required: a jump can carry one (to settle a new world), or an army (to reinforce
// a colony), or nothing (to hop back and control a world you already hold). jumpVessel
// stays as an informational helper (is a ship loaded?) for the HUD, not a gate.
export function canJump(state) {
return playerSpaceports(state).length > 0;
}
// A world where the player still has a foothold β a Command Center or an undeployed colony ship
// (the same notion as the galaxy defeat/relief check). It's a world a stranded force can fall
// back to and actually operate from: build, re-arm, or fetch a colony ship.
const playerFoothold = state => !!state && ([...state.buildings.values()]
.some(b => b.owner === "player" && b.type === "command") || hasColonyShip(state, "player"));
// Can the player jump to `destId` right now? A Spaceport on the CURRENT world lets you jump
// anywhere (expand to a new world or hop to a held one). WITHOUT a Spaceport here you can still
// FALL BACK to any world where you have a foothold β so a force stranded on a portless world
// (e.g. you hopped an army over and forgot the colony ship) is never trapped: it can always
// retreat to a base it holds and bring the ship back. Only opening a NEW frontier needs a
// Spaceport here.
export function canJumpTo(galaxy, destId) {
if (destId === galaxy.activeId) return false;
return canJump(activeState(galaxy)) || playerFoothold(galaxy.planets.get(destId));
}
const planetX = id => PLANETS.find(p => p.id === id)?.x ?? 0;
// The Spaceport's tier doesn't just lift more fleet per jump (SPACEPORT_CAPACITY) β a bigger
// pad also burns new-world fuel more efficiently. Indexed by tier (1..3) like SPACEPORT_CAPACITY;
// index 0 (no completed pad at all) is the same x1.0 as a fresh Tier-1 pad, so building the FIRST
// Spaceport is a pure capability unlock and never itself a hidden fuel bump.
export const FUEL_DISCOUNT_BY_TIER = [1, 1, 0.85, 0.7];
// The fuel a jump to `destId` costs: FREE to a world you already hold (any world you've
// visited β a colony you're returning to, reinforcing, or re-settling), so bouncing between
// your own worlds to defend or ferry a colony ship stays friction-free. Reaching a NEW world
// costs fuel that SCALES WITH DISTANCE across the frontier (data.js planet x, 0..~18): a near
// hop is close to the base fee, settling a distant world is a real, growing credit sink and a
// strategic choice β so exploration spend isn't the old flat, quickly-capped ~4,000 lifetime.
// The origin's best COMPLETED Spaceport tier (playerSpaceports(from), not the jump's actual
// destination pad β this is about YOUR launch infrastructure) cuts that distance-scaled fuel by
// FUEL_DISCOUNT_BY_TIER, so upgrading the pad is a real economic choice, not just a capacity knob.
export function jumpCost(galaxy, destId) {
// Free to a world you've already REACHED (a colony you're returning to, reinforcing, or
// re-settling). Since the living galaxy instantiates every world up front, "reached" is the
// player-facing `discovered` set, NOT merely "the state object exists" β else every jump would be
// free. Old saves (no discovered set) fall back to planets.has, which for them meant the same thing.
const known = galaxy.discovered ? galaxy.discovered.has(destId) : galaxy.planets.has(destId);
if (known || playerFoothold(galaxy.planets.get(destId))) return 0; // reached before, or a base you still hold β free
const dist = Math.abs(planetX(destId) - planetX(galaxy.activeId));
const tier = playerSpaceports(activeState(galaxy)).reduce((max, sp) => Math.max(max, spaceportTier(sp)), 0);
const discount = FUEL_DISCOUNT_BY_TIER[tier] ?? 1;
return Math.round(JUMP_COST * discount * (0.8 + dist / 18)); // ~340 next-door β¦ ~720 across the map, before the pad discount
}
// The player units staged near a Spaceport β the expedition that rides along on a
// jump. One definition, so the HUD's preview count and the jump's actual move can
// never disagree about what leaves.
export function stagedRiders(state, spaceport) {
const out = [];
for (const u of state.units.values())
// A ship assigned to a Freight Lane (u.laneId, see runLanes below) is physically parked here
// as a STANDING investment, not a free ride β excluded so it never gets swept off-world on the
// next jump out from under its lane.
if (u.owner === "player" && !u.laneId && Math.hypot(u.x - spaceport.x, u.y - spaceport.y) <= JUMP_LOAD_RADIUS) out.push(u);
return out;
}
// What actually LAUNCHES on one jump, capped by the pad's tier capacity. From the units
// staged near the Spaceport, fill the hold closest-to-the-pad first by ship population
// (supply) until the next unit wouldn't fit β a skip-not-break fill, so a heavy unit that
// doesn't fit is passed over for lighter ones behind it rather than blocking them (workers
// are supply 1, so a fleet always makes progress and nothing softlocks). The overflow
// waits at the pad for the next jump. Pure + deterministic: closest-first, ties broken by
// entity id. One definition, so the HUD preview and the jump's actual move always agree.
export function jumpManifest(state, spaceport) {
const capacity = jumpCapacity(spaceport);
const staged = stagedRiders(state, spaceport)
.map(u => ({ u, d: Math.hypot(u.x - spaceport.x, u.y - spaceport.y) }))
.sort((a, b) => a.d - b.d || (a.u.id < b.u.id ? -1 : 1));
const riders = [];
let used = 0;
for (const { u } of staged) {
const s = unitSupply(u);
if (used + s <= capacity) { riders.push(u); used += s; }
}
const stagedSupply = staged.reduce((t, { u }) => t + unitSupply(u), 0);
return { riders, capacity, used, stagedSupply, staged: staged.length, leftBehind: staged.length - riders.length };
}
// EVERY player Spaceport's staged riders, combined into one launch. With a single completed
// pad this is byte-identical to jumpManifest(state, thatPad) β every candidate unit's nearest
// pad is trivially the one pad, so the same closest-first fill runs unchanged. With more than
// one pad, each staged unit boards through its NEAREST pad (ties broken by pad id, matching
// the unit tie-break below), and each pad fills its own capacity independently β a unit that
// doesn't fit at its nearest pad is left behind rather than spilling over to another pad's
// spare capacity (a simple, deterministic policy, not a globally-optimal pack across pads).
// jumpCapital is the only caller: this is what actually determines who jumps, so it has to
// count every completed pad, not just one β a second Spaceport's staged units used to be
// silently ignored on every jump, because only the first Spaceport found (Map iteration
// order) was ever consulted.
export function jumpManifestAll(state) {
const spaceports = playerSpaceports(state);
if (!spaceports.length) return { riders: [], capacity: 0, used: 0, stagedSupply: 0, staged: 0, leftBehind: 0 };
// Every player unit within range of at least one pad, tagged with its NEAREST pad so it can
// never be double-counted across two overlapping catchment radii.
const byNearestPad = new Map(spaceports.map(sp => [sp.id, []]));
for (const u of state.units.values()) {
// A Freight Lane's assigned ship is busy standing infrastructure, not a jump rider β see
// stagedRiders' identical exclusion above; jumpManifestAll can't just call stagedRiders (it
// needs the nearest-pad tagging below), so the check is repeated here.
if (u.owner !== "player" || u.laneId) continue;
let nearest = null, nearestD = Infinity;
for (const sp of spaceports) {
const d = Math.hypot(u.x - sp.x, u.y - sp.y);
if (d > JUMP_LOAD_RADIUS) continue;
if (d < nearestD || (d === nearestD && sp.id < nearest.id)) { nearest = sp; nearestD = d; }
}
if (nearest) byNearestPad.get(nearest.id).push({ u, d: nearestD });
}
const riders = [];
let capacity = 0, used = 0, stagedSupply = 0, staged = 0;
for (const sp of spaceports) {
const cap = jumpCapacity(sp);
capacity += cap;
const candidates = byNearestPad.get(sp.id).sort((a, b) => a.d - b.d || (a.u.id < b.u.id ? -1 : 1));
let padUsed = 0;
for (const { u } of candidates) {
const s = unitSupply(u);
if (padUsed + s <= cap) { riders.push(u); padUsed += s; }
}
used += padUsed;
staged += candidates.length;
stagedSupply += candidates.reduce((t, { u }) => t + unitSupply(u), 0);
}
return { riders, capacity, used, stagedSupply, staged, leftBehind: staged - riders.length };
}
// A jump carries a CARGO HOLD of manufactured goods to the destination, so a run of production on
// one world can be sold on another β the make-here/sell-there loop (produced goods price
// differently per world, engine/market.js). The hold's SIZE is the combined cargoHold of the cargo
// ships (engine/entities.js: hauler/heavyhauler/bulkfreighter) staged for the jump β no cargo ship
// means no freight. Loaded most-valuable-first (data.js COM.base: machinery 250 > electronics 95 >
// alloys 80 > spice 34 > metals 22). spice is here so Verdani's agri surplus can be exported and
// sold dear on an industrial world (it's cheap where it's mined, precious where it isn't); cheaper
// raws aren't worth a cargo slot and strategic goods stay put.
// Exported so runLanes' commodity-filter fallback (below) and any future caller can share the
// exact same default ordering cargoManifest already uses β one most-valuable-first list, not two
// that could drift.
export const CARGO_GOODS = ["machinery", "electronics", "alloys", "spice", "metals"];
// The freight capacity a set of riders provides β the summed cargoHold of the cargo ships among
// them (anything without a cargoHold carries nothing). Pure.
export function freightCapacity(riders) {
let cap = 0;
for (const u of riders) cap += UNITS[u.type]?.cargoHold || 0;
return cap;
}
// What `capacity` units of hold would haul from `from`, as { good: qty } β most-valuable-first, for
// the HUD preview and the jump itself (so the shown manifest and the moved goods can never
// disagree). capacity 0 (no cargo ship staged) β an empty hold. `goods` lets a caller restrict the
// pick to a subset (runLanes' commodity filter, below) while walking CARGO_GOODS' own
// most-valuable-first order; omitted, it's the exact same list every existing caller already got.
export function cargoManifest(from, capacity = 0, goods = CARGO_GOODS) {
let room = Math.max(0, capacity | 0);
const src = from.players.player.resources;
const manifest = {};
for (const com of goods) {
if (room <= 0) break;
const move = Math.min(Math.floor(src[com] || 0), room);
if (move > 0) { manifest[com] = move; room -= move; }
}
return manifest;
}
/* ---------- Freight Lanes: standing shipping between held worlds ---------- */
// How often (in galaxy ticks) an active lane moves cargo β an integer schedule keyed on
// galaxy.tick, exactly like BG_STEP/PROGRESS_CHECK_EVERY above, so it's deterministic and
// independent of frame dt. A lane is a slow drip (real sim minutes, at the 20Hz cadence the rest
// of this scheduler assumes), not an instant transfer β it's standing infrastructure, not a
// one-off haul.
export const LANE_PERIOD = 200; // ~10 sim-seconds at 20Hz
// Create a standing lane from `from` to `to`, filtered to `commodities` (empty/omitted = every
// CARGO_GOODS commodity, most-valuable-first β see runLanes). Refused for a same-world "lane" or
// an unrecognised world on either end. Returns the new lane (also pushed onto galaxy.lanes), or
// null on refusal.
export function createLane(galaxy, from, to, commodities = []) {
if (from === to || !galaxy.planets.has(from) || !galaxy.planets.has(to)) return null;
const lanes = galaxy.lanes || (galaxy.lanes = []);
const lane = { id: "lane" + (galaxy.laneSeq = (galaxy.laneSeq || 0) + 1), from, to, commodities: [...commodities], shipIds: [] };
lanes.push(lane);
return lane;
}
// Disband a lane and free every ship it held busy (clears their laneId flag). Returns whether a
// lane with that id existed.
export function deleteLane(galaxy, laneId) {
const lanes = galaxy.lanes;
if (!lanes) return false;
const idx = lanes.findIndex(l => l.id === laneId);
if (idx < 0) return false;
const lane = lanes[idx];
const from = galaxy.planets.get(lane.from);
if (from) for (const id of lane.shipIds) { const u = from.units.get(id); if (u) delete u.laneId; }
lanes.splice(idx, 1);
return true;
}
// Assign a player freighter parked on the lane's SOURCE world to it β it must be a real cargo
// ship (UNITS[type].cargoHold) and currently standing within JUMP_LOAD_RADIUS of one of that
// world's own completed Spaceports (reusing the exact same catchment jumpVessel/stagedRiders use
// β this is deliberately the same "at the pad" test, not a new one). A ship already on another
// lane is moved over rather than double-booked. Returns whether the assignment happened.
export function assignShipToLane(galaxy, laneId, unitId) {
const lane = (galaxy.lanes || []).find(l => l.id === laneId);
if (!lane) return false;
const from = galaxy.planets.get(lane.from);
const u = from && from.units.get(unitId);
if (!u || u.owner !== "player" || !UNITS[u.type]?.cargoHold) return false;
const pads = playerSpaceports(from);
if (!pads.length || !pads.some(p => Math.hypot(u.x - p.x, u.y - p.y) <= JUMP_LOAD_RADIUS)) return false;
if (u.laneId && u.laneId !== laneId) unassignShipFromLane(galaxy, u.laneId, unitId);
if (!lane.shipIds.includes(unitId)) lane.shipIds.push(unitId);
u.laneId = laneId;
return true;
}
// Pull a ship off a lane, freeing it for jumps/other orders again. Returns whether it was found.
export function unassignShipFromLane(galaxy, laneId, unitId) {
const lane = (galaxy.lanes || []).find(l => l.id === laneId);
if (!lane) return false;
const idx = lane.shipIds.indexOf(unitId);
if (idx < 0) return false;
lane.shipIds.splice(idx, 1);
const from = galaxy.planets.get(lane.from);
const u = from && from.units.get(unitId);
if (u && u.laneId === laneId) delete u.laneId;
return true;
}
// Run every standing lane one delivery cycle: revalidate its assigned ships (still alive, still a
// player freighter, still parked near one of the source world's own Spaceports β exactly
// assignShipToLane's own test, run again every cycle so a ship that wandered off, died, or was
// reassigned quietly drops out rather than silently keep "shipping" from wherever it ended up),
// then move min(the survivors' combined cargoHold, the filtered stock on hand) straight from the
// source world's treasury to the destination's β a lane never touches a ship's own .freight hold,
// it's a capacity NUMBER standing in for "how much this route can carry today", not a physical
// load/unload cycle. Called from stepGalaxy on the LANE_PERIOD schedule. Deterministic: galaxy.lanes
// and each lane's shipIds are plain arrays in insertion order, and cargoManifest's own commodity
// walk is a fixed list β nothing here reads a clock, RNG, or Map/Set iteration order.
export function runLanes(galaxy) {
const lanes = galaxy.lanes;
if (!lanes || !lanes.length) return;
for (const lane of lanes) {
const from = galaxy.planets.get(lane.from);
const to = galaxy.planets.get(lane.to);
if (!from || !to) continue;
const pads = playerSpaceports(from);
const kept = [];
for (const id of lane.shipIds) {
const u = from.units.get(id);
const valid = u && u.owner === "player" && UNITS[u.type]?.cargoHold
&& pads.some(p => Math.hypot(u.x - p.x, u.y - p.y) <= JUMP_LOAD_RADIUS);
if (valid) { u.laneId = lane.id; kept.push(id); }
else if (u) delete u.laneId;
}
lane.shipIds = kept;
if (!kept.length) continue;
const capacity = kept.reduce((sum, id) => sum + (UNITS[from.units.get(id).type]?.cargoHold || 0), 0);
if (capacity <= 0) continue;
const goods = lane.commodities && lane.commodities.length ? lane.commodities : CARGO_GOODS;
const manifest = cargoManifest(from, capacity, goods);
const src = from.players.player.resources, dst = to.players.player.resources;
for (const com in manifest) {
src[com] -= manifest[com];
dst[com] = (dst[com] || 0) + manifest[com];
}
}
}
// freightUsed/freightRoom (how full a freighter's hold is, and its remaining room) now live in
// entities.js β engine/haul.js needs them for the workerβfreighter physical loading path and can't
// import this module (galaxy.js -> sim.js -> haul.js would cycle back here). Re-exported (imported
// above) so every existing caller of these two from "./galaxy.js" is unaffected.
export { freightUsed, freightRoom };
// Load up to `qty` of commodity `com` from the CURRENT world's player stockpile into a freighter's
// hold, clamped to the hold's room and what's actually in stock. Returns the amount loaded (0 if
// it's not a player freighter, the commodity is unknown, or nothing could move). The goods leave
// the stockpile immediately β they're aboard the ship now, to ride the next jump.
export function loadFreighter(state, unitId, com, qty) {
const u = state.units.get(unitId);
if (!u || u.owner !== "player" || !u.freight || !COM[com] || !(UNITS[u.type]?.cargoHold)) return 0;
const res = state.players.player.resources;
const move = Math.min(Math.floor(qty) || 0, freightRoom(u), Math.floor(res[com] || 0));
if (move <= 0) return 0;
res[com] -= move;
u.freight[com] = (u.freight[com] || 0) + move;
return move;
}
// Unload up to `qty` of `com` from a freighter's hold back onto the CURRENT world's player
// stockpile. Returns the amount unloaded. Works wherever the ship is β the origin (to undo a
// load) or a world it jumped to (to bank the haul and sell it at that market).
export function unloadFreighter(state, unitId, com, qty) {
const u = state.units.get(unitId);
if (!u || u.owner !== "player" || !u.freight) return 0;
const move = Math.min(Math.floor(qty) || 0, u.freight[com] || 0);
if (move <= 0) return 0;
u.freight[com] -= move;
if (u.freight[com] <= 0) delete u.freight[com];
const res = state.players.player.resources;
res[com] = (res[com] || 0) + move;
return move;
}
// A jump delivers every staged freighter's HOLD to the destination colony's stockpile β exactly
// what the player hand-loaded (loadFreighter/unloadFreighter), nothing more. A freighter left
// empty carries nothing across; it does NOT auto-fill from the origin stockpile any more (the
// previous "empty hold β most-valuable-first" default made a round trip actively work against a
// player who wanted to haul on the way back only: land empty at a destination, jump home before
// reloading, and the empty ship would eagerly scoop up local goods and carry them right back,
// silently undoing a delivery the player had just made β see the reported case in git history).
// Manual control now means exactly that: load what you want shipped, or it stays put. Returns the
// combined delivered manifest (for the arrival toast). `riders` are the units that lifted off;
// non-freighters (no hold) are skipped.
function loadCargo(from, dest, riders) {
const dst = dest.players.player.resources;
const delivered = {};
for (const u of riders) {
if (!u.freight || !(UNITS[u.type]?.cargoHold)) continue;
for (const com in u.freight) { // deliver the hold to the destination colony
dst[com] = (dst[com] || 0) + u.freight[com];
delivered[com] = (delivered[com] || 0) + u.freight[com];
}
u.freight = {};
}
return delivered;
}
// A minimap pick is necessarily coarse β that's the whole point of it (a blind landing on a
// world with no beacon should feel like one, not a precise coordinate entry). So a pick resolves
// to the NEAREST ALLOWED LANDING SITE: a fixed grid of them across the world's middle, plus the
// margin ring itself at each edge. The margin comfortably exceeds the widest rider-spawn ring
// jumpCapital uses below (46 + 2*18 = 82), so a picked corner can never spawn a unit off the map.
//
// NEAREST-SITE, NOT ROUND-THEN-CLAMP. This used to round onto the grid and then clamp into the
// margin, which made it NOT IDEMPOTENT: the clamp bound (100) is not a multiple of the grid (160),
// so the whole edge band collapsed onto an off-lattice 100 that re-snapped to 160. Feeding the
// function its own output moved the point again β 161 of the 1601 x-values on a 1600-wide map.
// That is a live trap for callers, because the obvious way to build a picker is "snap to show the
// player where they'll land, then send THAT point": the confirmed point got snapped a second time
// and the colony touched down 60 units from the ring the player was looking at, with nothing on
// screen to say so. (landingPicker.js dodges it by forwarding the RAW point and snapping once β
// which is no longer something a caller has to know.)
//
// Choosing the nearest site instead fixes that WITHOUT changing where anyone can land: the set of
// reachable outputs is exactly what round-then-clamp already produced β {margin, ...grid...,
// span-margin} β so no landing area is lost or gained, and no balance changes. All that changes is
// which clicks map to which site, and only inside the edge bands, where a click now resolves to
// the site actually closest to it (x=90 lands at 100, where it used to skip out to 160). Every
// output is a fixed point, so snap(snap(p)) === snap(p) everywhere.
export const LANDING_PICK_GRID = 160;
const LANDING_PICK_MARGIN = 100;
// Every landing site along one axis of length `span`, ascending: the near margin, the grid points
// strictly inside it, and the far margin. Exported through landingSites below so a picker can OFFER
// these rather than guess at them β "the grid" alone is not the answer (a click at 90 belongs to
// the margin site at 100, not to the grid point at 160), which is exactly the kind of 60-unit-wrong
// lattice a caller would otherwise build for itself.
function axisLandingSites(span) {
const lo = LANDING_PICK_MARGIN, hi = span - LANDING_PICK_MARGIN;
// A world narrower than its own two margins has no valid band at all. Nothing generates one
// (engine/map.js maps are far wider), but this is reachable from any caller's map object, and a
// single fixed site keeps the "every output is a fixed point" promise true even there.
if (hi <= lo) return [Math.round(span / 2)];
const sites = [lo];
for (let v = Math.ceil(lo / LANDING_PICK_GRID) * LANDING_PICK_GRID; v < hi; v += LANDING_PICK_GRID)
if (v > lo) sites.push(v);
sites.push(hi);
return sites;
}
// The allowed landing sites for `map`, as the two axes' independent site lists (any xs Γ ys pair is
// a valid landing point). Purely derived β no clock, no RNG, no state.
export function landingSites(map) {
return { xs: axisLandingSites(num(map.width)), ys: axisLandingSites(num(map.height)) };
}
export function snapLandingPoint(map, x, y) {
// Nearest site wins; a tie goes to the FARTHER-OUT one, matching the way the old Math.round
// broke its own half-way ties upward (a click exactly between two sites is arbitrary either way).
const snap = (v, sites) => {
let best = sites[0];
for (const s of sites) if (Math.abs(v - s) <= Math.abs(v - best)) best = s;
return best;
};
const { xs, ys } = landingSites(map);
return { x: snap(num(x), xs), y: snap(num(y), ys) };
}
function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
// Where a jump's riders actually touch down on `dest`. In priority order:
// 1. The player's own Spaceport most recently used to LAND here (`lastLanding`, a galaxy.time
// stamp β see below) β a return trip comes in at your own infrastructure, not the world's
// fixed generation-time anchor, which could by now be nowhere near where you actually
// built. Ties (never-landed-at pads) fall to the lowest id, same as every other tie-break
// in this file.
// 2. No Spaceport stands on `dest` at all (never settled, or one stood here and was since
// razed): there's no beacon to home in on, so the player chose an approximate spot
// themselves (the landingPicker.js "blind minimap" screen) β `landingPoint`, already
// fuzzed by snapLandingPoint above before it ever reaches here.
// 3. Neither β an old save, or a caller that skipped the picker: the planet's fixed
// generation-time anchor, exactly today's (pre-this-feature) behaviour.
function landingZone(dest, landingPoint) {
const pads = playerSpaceports(dest);
if (pads.length) {
let best = pads[0];
for (const p of pads) if ((p.lastLanding || 0) > (best.lastLanding || 0)) best = p;
return { x: best.x, y: best.y, pad: best };
}
if (landingPoint && Number.isFinite(landingPoint.x) && Number.isFinite(landingPoint.y)) {
const p = snapLandingPoint(dest.map, landingPoint.x, landingPoint.y);
return { x: p.x, y: p.y, pad: null };
}
return { x: dest.map.bases.player.x, y: dest.map.bases.player.y, pad: null };
}
// Launch an interplanetary jump to `destId`: player units move to the destination's landing
// zone along with the cargo hold. NO deployed base moves β the origin keeps ALL its buildings
// and becomes a background colony. Costs fuel only for a NEW world (jumpCost); returning to a
// held world is free.
//
// Two modes:
// β’ With a Spaceport HERE β the normal launch: the capacity-capped expedition staged near the
// pad (a colony ship to settle, an army to reinforce, or nothing) rides along.
// β’ WITHOUT a Spaceport, falling back to a world you ALREADY HOLD β a pure CONTROL SWITCH that
// moves NO units: with no launch pad there's no way to load a fleet, so the force you left on
// this world STAYS PUT. This is the catch-22 escape (hop back to a base to fetch a colony
// ship) WITHOUT dragging a garrison home β you bring the ship to the stranded force, not the
// force back. To actually ferry units off a world, build a Spaceport there. See canJumpTo.
//
// `opts.landingPoint` (world coords) is consulted ONLY when `dest` has no player Spaceport of
// its own β see landingZone above. Ignored (harmlessly) whenever a pad is standing, and never
// required: every other case falls back cleanly on its own.
//
// Returns a summary, or null if the jump can't run (no way to reach the destination, same world,
// or too poor to fuel a new-world jump).
export function jumpCapital(galaxy, destId, opts = {}) {
const from = activeState(galaxy);
if (destId === galaxy.activeId) return null;
const hasSpaceport = playerSpaceports(from).length > 0;
const canFallBack = playerFoothold(galaxy.planets.get(destId)); // a world you hold a base on
if (!hasSpaceport && !canFallBack) return null; // no port here and nowhere to fall back β can't jump
const cost = jumpCost(galaxy, destId);
if (galaxy.credits < cost) return null;
// A Spaceport (or several β every completed pad's staged units ride, not just one) β the
// combined capacity-capped expedition. No Spaceport (a fallback) β nothing rides: a control
// switch that leaves every unit on this world where it is.
let riders = [], leftBehind = 0;
if (hasSpaceport) ({ riders, leftBehind } = jumpManifestAll(from));
galaxy.credits -= cost; // fuel β free to a world you already hold
const dest = galaxy.planets.get(destId) || addPlanet(galaxy, destId, { unsettled: true });
const { x: lzx, y: lzy, pad } = landingZone(dest, opts.landingPoint);
const nextId = () => "g" + (galaxy.entitySeq = (galaxy.entitySeq || 0) + 1); // fresh ids: no cross-state collision
riders.forEach((u, i) => {
from.units.delete(u.id);
const a = (i / Math.max(1, riders.length)) * Math.PI * 2, ring = 46 + (i % 3) * 18;
u.id = nextId(); u.x = lzx + Math.cos(a) * ring; u.y = lzy + Math.sin(a) * ring;
u.order = null; u.orderQueue = [];
dest.units.set(u.id, u);
});
// Remember this pad for the NEXT return trip (landingZone above prefers whichever pad has
// the highest `lastLanding`). Only worth stamping when riders actually landed β an empty
// control-switch hop (see the file header comment) doesn't touch anything here.
if (pad && riders.length) pad.lastLanding = galaxy.time || 0;
const cargo = loadCargo(from, dest, riders); // deliver each staged freighter's hold β hand-loaded only
from.selection = []; dest.selection = [];
from.background = true; // the world you left keeps evolving on its own
dest.background = false; // the destination is now your active seat
galaxy.activeId = destId;
(galaxy.discovered || (galaxy.discovered = new Set())).add(destId); // now REACHED β a free return-jump, and it shows on the starmap
updateFog(dest, dest.fog, "player");
updateFog(dest, dest.fogAI, "ai");
updateFog(from, from.fog, "player");
return { destId, riders: riders.length, leftBehind, cargo }; // leftBehind: staged units the pad couldn't fit this trip
}
|