From 67a7ac857e93c2fd6fdd18731ea74ae679182106 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 15 Jul 2026 18:56:02 -0600 Subject: [PATCH] feat: add Civilization II-lite game engine and UI Implement a complete turn-based strategy game with: - Deterministic headless engine: cities, units, combat, research, governments - Seeded world generation with noise-based terrain, continents, quality scoring - Isometric map renderer with procedural fallbacks and optional sprite sheets - AI rivals with strategy phases, diplomacy, trade routes, and unit control - Full UI: setup, city screen, tech tree, diplomacy, spaceship tracker, victory - 80 techs, 51 units, 26 buildings, 11 terrains, 6 governments, 5 difficulties - Comprehensive headless verification suite (rules integrity, worldgen, combat, AI self-play) --- assets/images/game-icons.png | Bin 317106 -> 322497 bytes assets/images/game-icons.psd | Bin 819094 -> 831466 bytes data/civilization-artwork.json | 19 + data/civilization-rules.json | 288 +++ src/data/assetManifest.js | 9 + src/data/gamesRegistry.js | 1 + src/games/civilization/CivilizationAI.js | 643 +++++++ .../civilization/CivilizationCityScreen.js | 162 ++ src/games/civilization/CivilizationGame.js | 825 +++++++++ src/games/civilization/CivilizationLogic.js | 1580 +++++++++++++++++ src/games/civilization/CivilizationMapView.js | 667 +++++++ src/games/civilization/CivilizationRules.js | 153 ++ src/games/civilization/CivilizationScreens.js | 412 +++++ .../civilization/CivilizationWorldGen.js | 273 +++ src/games/civilization/sprites.md | 280 +++ src/main.js | 2 + src/scenes/GameRoomScene.js | 2 +- src/scenes/PreloadScene.js | 1 + tools/verifyCivilization.js | 1149 ++++++++++++ 19 files changed, 6465 insertions(+), 1 deletion(-) create mode 100644 data/civilization-artwork.json create mode 100644 data/civilization-rules.json create mode 100644 src/games/civilization/CivilizationAI.js create mode 100644 src/games/civilization/CivilizationCityScreen.js create mode 100644 src/games/civilization/CivilizationGame.js create mode 100644 src/games/civilization/CivilizationLogic.js create mode 100644 src/games/civilization/CivilizationMapView.js create mode 100644 src/games/civilization/CivilizationRules.js create mode 100644 src/games/civilization/CivilizationScreens.js create mode 100644 src/games/civilization/CivilizationWorldGen.js create mode 100644 src/games/civilization/sprites.md create mode 100644 tools/verifyCivilization.js diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 262d8bccd8e904fce4ece372325bdd9a2f4abeab..6bda30e377c69a9295ac41e4b54be8f6169f18db 100644 GIT binary patch delta 52142 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 YN(&)f^VyGdLP3G$>FVdQ&MBb@0Js|CcmMzZ delta 46708 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 LtI1Wpd>i&3>H}NU diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index a60e230643340d32af96ade5ab2d3fe6cc4299fa..30c662e78cbc360573f6ee581f673fc10efb4cb2 100644 GIT binary patch delta 24916 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 a=@a??6Icd+@;&ypIkm!JNYb48=Klkx;^-v+ delta 10734 zcmeHLcTiK`mrp1mBoU;ebOC7=1PdipmEL<3MWl#;6zNK+(jnAG7o>|IAR%-?ItnOV znlwc~Iu<}-AEA7Ac6N4WXLe@ykKeqx;q%VtoOACz=W}nEEPNJ9*t>*fCt{9^J|lbj z^cDmHfr2CFU_e(OP$?t?a(cYfn+GaP3We=8Lj=L%DP7h8T>&7*CarEzoRdj3IRd<5o| z$>XYp_Vf!H$yfV9tW{bt-E5Imc%wrgQaM;@R94G@nUu$xtiM>!f$Y?D`6%sq7JKXN z($_X-gb6b@1bM*f|F^d87il4Lgp~1WXohk zMw~=M0wNqciGku6Vb0*w)OBJ-#XR%D4#s%yDreet>K*I*HXMp?gT9i z)ID{Y)GY-aP_&6|x@apyN~*?jfr~ixWIS6MM$F%_7u%_uKe?>#&cV-TdTZp9!|E{i zeI?nD_J$GXTW&w@2W=)*E0|GWjEnLw3I*_~;?q(gRE_N##Qt-kH6mA`{k)8+gFy+& z5}tH(SuDu|jVj$$qP(>qwX|@te(+f$HFR=2?$*e3nvBPiiM?u3N|LO}HM+W7EUkHmab%D;_E66m zc&zc{>0k~ky>P><*9@Cd?lp2wN{T+0I}wdv1xL}8fd-y+-UIadSThBelc^TmEG0zv zB{a=!T7y^AfGNwxf#kAB$i)>o&uMm{K%u5ZB|TicfK(#+jCJ>=)LBZK4=#_8H)=%V z9sOA!IAZmBO(fIhS9);z7#ETqz0i+-EBE@A^2cvkyk@yzhtLk0%#V%A!^vuKdPH|; zkUmq@QF(ulM{p}|K+n^zUsc&-knO3|iK;FY?m6ms*Y{i}s#@jQxfN%|{yHZiyDR!X zUP<}cJ*tw4Z&G+L)!Cb&(8%A8&h~HI(LFmg$X8))U3n>!qm9Zut^`x(%S4?MM}iU% z5nv@-iSq2noz2I|_J1E?!JRSLQk~`Fm7DyaH{Kjiilt2RHJ0gU_?qv}Mp7CxG)(5W zTzJn<-`&_WBWm*vi(Zv!=6#&n>uwc;Z{Q|btd15FYuXWvU!8|lwm|Ya_*Sp|c zR9b}Ovq4fk7WEL9p`csMp|fJY$3xbZJZ{>5fdkMRRnU~?AtfP>Dz;(LPm^$ZR`xfq zNN7huTl#%4mxvZ6JCOwYEE|s>1^?NJ&l{4Rzos)Tu#7=#esFEhMUA}NG|mUaTVlR{ z`6Oq2DIM<1-2xql7Gb(Ml!?jASmo4cg;OnyMuhd1szpbtZm3ux{1AvbYeO!(2Ik2? z=X99FMi>_(eXNxwtpOF2Ph8~aiSoD#A5lgHMn)X&Fh6 zRgBEqv5q;0Pew0Is@Gse>m?+O=GrHJF~EAWZN@E%zW>#X%E7RIq7xdGnA|>Fsh=>s z2QdgFqP|Mw1K;u(<8OKO<4@grNwlWbMZp;ko5Yz!Y5g4sGA;+q!&7Me2d^i`bAt65 z`Ka!B=MHfz>pc$7_S6#gpWXUdT$uj)V>IrOD&2)8a<}>O36m@s?Jjhf^gag1+$B9Vx~Wcwc6*0aL-V4vcw1E7!Tk+$Rkdj zY9rf&P_J*G~=P+dyu4_iI;UoeW#yk&tJKLHzB^r-L-sOwm~I; z2(DMfKpV;(%)7hiXey-aQ$=`~{czvXUU(KyF32q1|NG;3Zw{T->F>_f27vUT!j5k&+8hTORC1$|Q6oJ9j2$c_9Ab>+Qp1A5OdI z-=xCQBwD{3m-WrPqQzQ1_V8qqiHqyF-P^b#{?XX-LQEsC=`}7Zgb4f4lHTuMyUFiM zjVv2n`m^PYQVh=p(Uf{!GjC8-Xz!LBNWI14JyZ1Xdy~;ucnlp&2SVF=Y}G8cTZWU& ziPlME%lvZ^uuQcS8jyIgIr{Y1RZUl-G$m}g;5D9MKdpKhsq8-+hUP6a;!o9I3FwqY z6U#k;;aV`gb(>i-W447=Ro=z|Q9}H8&oDZ>E8j6Q{CR4N=)vUy3BGS%a(5#n%m)lk7)62B)In~svauHm?OBY($gbPp0K zWL%!faD7qPqSJi+wo296>-kS8xngQ7ioUfoPEd?DU}lLU9qhe=?^s*-xqSY*V1SzT z{_(2wYZ%GnsV9$GO5e$0L+*tJ6_ZEMQTZCGeaYb+jji3kDsArG4{}I$Ej>gvyU|-)Q2l!PcBIJ(DxE54%p=j_ z#T`TMta&zV7f#C)t={RDG_R?*a% zI>%sn7QglNm8-Hvw`6>1(6C)qK<&m-5R_ayRI9Y6u~oXbkhqtr&&)=f4bBOecP`s2 z#bm}|QYq&fH=fZEI}NzGpEmt4t1L8kesWYO&EpO?Nwy!wSX%SUFva6Pm7b2?H71`h zO5^345mw<1QLNfFN?Cl^w9K3w1FlK)UicgqogU8NS2oe;xnVip8RyN?qB*WmfgVX! zgcLPTv1tdUOSZo8{IR3sRS2C|IVL$5#fr@Q+W5?h8Gor zIe$Je)LH-~ia#~i{~Qsc*wDtT6B+WbW}>Ps;`I4y73*)2eU#k&`4zNCpS*C|I4-ZL z(p2}Col_aF$g=fP_%qt?Z_1ok{yx=q$_N_}L-SXfHWj)~*VXsL;&ES;;iiz*_>_8P z^Nk5??+9GYH9yKhU6k3mhweIefl#IXX$NRl$3#XOTkf*lp9j=e{@!HJb>UZy(rvSQ6=v+9%OMXZML?NcSppeFMW8(0 zilJ}DtDjM({4g7V z9OZoZ?Y343GA^z!%v+L<-L*7l;=4EJp2(%?DW)30Ox^4JqAkeHpp25qe!akx6>xy6FH(K*cTp04-g?X3FD~DCgoy;lAj*DaIt8_z!s(i#|Um z*x#pq9XM|vPw~NV@RJ;w1J|~!BD=+Hd$pVXray!g4RMIewZn_m%IAM^UG63Jjq{_R z73Es$EP*x6yq}8Vx5TF_ycrJ&+;y(PLXM6`5i;*E? zemrM2RT|kWnrpvG;hVuGUW~39c$kR8h9I3HU0%76e^4v3IibpTe%yc>b21WNv|PP| zbav8F8r6TmQo^L5H-Jh<6A9-^55{owqY|>J^EcQ`G(OpJ*ok3vlo~bO%4(@I)kdn< zQmTpF&Mx1BXkHjecwfQvyQ1Hy0`piiZ4)(K_RJ7sF|zYT#k$`>~t;L`i<43=zu z*;3RA3v-N#S27}k$m%%>M%U(Z%kvOJ!UyXtYVQBl%}uPno@7@qW62AY zqIU(TGmY)3O)G!uXuloT!t*fuU=RK9pq6H*R9JBfUS1722@oE{Ncb)4D3A14`DD z%}!!RBHej7tUD7V*4-Pod^JZN+^Q6ra49ZeS@zEVbpZXK$e2XvoNo2Q)oJsG7h-gMhc zsX*9Jgi9#MvM5`UDCJ?bn=LQ0<8O)l-#;%JU;Uu|o+kaeG7KX?eKHS`qpE#=#Vq?f znN6HLmjIij2wnKM6P#v4+x4dD_(kJRuBNE9Fd2&rg4lR&&TSv$pB@MsPwG(blbf&S z3o34Q^iw3-;r!R^(q72HIfrL0IQ=L?3zi;tJs9~@L_+P-|6u;hqEf-?#+2!|XKYa! zV!O2d+sXas3ajN}Ur`5OvYreYj3>h?@!Er%#pr$-qx$u*CZX#pJ+C{-Dk|q2>_of2 z6v>lwHj+gOQN{&R6#P)@qjvW6NPMY4+59_`<0p2;9FXSV|0}~?rr5}c)>$s?-L~9v zR>nkiR&WnB#S8~%QE#u!IVujTdGIw#UGMT-v=hjP&gC#JPWC>B`N=37OMXGj%U7!4 z23l zD~CNc)pyc|jObPOrXNa`!_r($-Dc`)YhX!`e)+Fn6XQglexhDHZRS!pd@Z=pdAven zl-O^0f~K3U)HKy@=I55P-dP?KbHf_fA=xQKquw|lwIAGkV`-S-&a!yNwx>PIe2wN( zPj`QE-0=Q|(_>c*ZbF@lTs;v))*(0ih(VLF?oGb8B$ueFjANy($ntca4pzcTMm>su zj+;+p(mY1uO~Hz~XP2kzxJMA*j~g-lg;^A3rFd^++X{MGtlOQ9Z{3%)3cRo6$(h(u zzoKULyHmNIk!$!{J=afeDc^xw%ikx4LJQ^~JzY{KPNBMOt9VXDd0BGZT$on5IVeRz z!7f8at*?}wNVY^uUzEr6BCh*vj{E7~)|6%M0vp0bI#`C+y!76z#d^~Ln4+k+eN0^y z)pXo_5mY&wJ0Tub(HDNDWxr6Hjrm-_Mn6s>(&7^QICe)yePV^YHXFQt;-*`lsqwL@)G> z^yzk&-_EK|3C31r$|xAg%3_>kDy;Kfaaye#f<@*)yt*@yBJ@u zkiw2W6&bsL1_}n4)R1g1PE1W}a&_91KObooRC1Xj-<~pY3sbT~cvVt-&{B%|m2*8VjzWQ7G0a@crfU3# zuK>=;a|~dnkTr)+Q%%`eV>Lc;p$geHui0NoS7FBzCq``-tUOL|u?=&USH}@$k62dB z{9;Itd}}neK01+B2;WF2Z%dc5nN7=CF$&9KP`xP98#mCcoK-P9S@Fxx5lL#Own9Ea zArXYLp)E`bdSJf1t;^RG@>saS)xH=00~Tw}D36(Llv>OqRimY!!GS|roy0XY{UdPkNFgZ6%;#`K zZ-L$~)fYMdO|{<#z58Mq<>qn)EZc7ftxv;HC1)&!2v3jHZg1XwZK6Ql7k#H0lm$QA zr8q}qF}}5^71{EBgLBDKnqPZxwZ)jp=@h1(&d5c{;B&ZDzdD|AEi^gDo$=#GFN@gv zQ&%w4;YoSlh3#*LsJ=Fy?HUOnn+OklNy1wIp%xN+uCP6HvwIXaM>;H=#rW!8t)l(&u_fN z@O?lz(bjf<7ysF-EDhS!Xj`2J6-?sQzG>2!rMkdzM(eWv=MvU3-O{^rJN?m+$oopk zezSOEyPy389-oF8hbBaUuALbinVh(8C$o~{QAkFA5pCD z>hsvN`KSH`Ye5BTP;d}|gBTnn_<}Xk_FlZw7%YhvKfMa8f$G~sydY7KY{+X!BV-UV z0r?L310{o^pp;NXC8jN z{xW0)f`_C)f*_{A+B0H?gbZT%zsCP*4M^~mKVcR7ZGQ3}+WhxFZL<*AP&A>{5@4Ir zV3W~c)2~6Vg3rI&{D0cY|LQ1_0AQQpc&Qy&@qgcI&ch=u1RiEFu)|_tr}uj;4n9{7 zyZx_T|NreZ?lu{CqHQ4Z5M_ud#0+8waff(99z#MP!4Ox78;AoS(U2I3KWGVn_&~xS zu^@^C*)T{bcnd^T4G?7&_yoa1m68X@o#`zF)SW7DfpI4krN_4Y_K#>3^2v-_)= zmF4eq(+kUoSx$z-Pwt0sL>z}WM!+u~l&1^d9SLVYz%6)YES&KGH{k!xgUoc1j3z=BXdt6AGj?I;|DfR zAZ`)Z48V~B@r=M`>`3nt6cN zJ}r0}v2vJ6lL>LG-CSnGv39pu{;{jEA&#|Mz>YZ9F8LY6`GZxo;%zt)7eGlJX&2wc zg%Ad{e5Bbu{Q}_R4Y11QXA#4o($GAJBT?$S1SIyERvyGC$UNso9Ep0D7tDbm_V9Q- zcpy0|egtY?r;h}VeLe^v2r3^tcbrh*HL$jy(DFQD_XNzx?cqb$Bh98=K+x^0?e#?j z2cgj5naBZ@Z-U-Jf{00wp}F)AQv|s0GclJCtNRMZdb_!KxO$cxD0u!z!7V}v%Ke-J z!UzsRQM>Tmml2l^Y7XWv1orl`lwCor6Q*q06DR`C$-x8?t|1l(0nK=N5pZJ!+ytnK zB6bMy4d5h(m?y;4?yGbXi~&l;5qSi*3h+iF$_OleUtwbMAnPlEuqLqO09+E`M}Pr9 zjwIqgk#o5w0$;9)j5w0BZaLDO5ReJXl_QU?+-Mx~SjG`Rj{>M3Oa;=HFfXX=B;bz% zIIk8S8A*t30>~mwmI|(rzc!^r%v7GJdkYop? zXvb&QBabiNi8si9bQ}vrD}gJ~Xha_A*zqlrtI8ZOC&6VQqW*slYR37=NB_2lBwR6(7=xy!h``z4Q)w34C9uKfH;xyO8jGL7H?U zk3?J9jU?YUz3n+Rv3@`jWGnCyc_ey)7@$Z6ECTK$asqUY>pgZ}?L`s<{ipBPWY7;T znD)ewk&&;jkDG_RjguSx@cMi+fTZ0QU;iMIgHT^kaB+Z{DwtY(2)Qrl%ftULX+Y#Y z^LiM$%uKyKniTBo;biOla$nG2r;h}^|1D9o??yF(oCXtYj{K7#8~6q?c&AY$@j;2Y z@L6NX;~RQy9Lac4&E`FUbl{;Hn8o}va^g@Lwv)&sWk*jTkChz{{M>i^FpWG?cG?W` zSlQu#hC0Y*%p$D`8w4+G0C=)*(*1%wy0DwSApf1hW2o`1zmX?FMS}nKWdaLNQL=|n zCI}@*fEhq)5DE+d9Wnq|Fv{c*ItHV_0|Z)|fcapQGXb^%wjn4N0&EA?LQrmp&@B`d zK!6<0klsRDc5C0WF1s+l!iHFr~0rnCPtI!OD zC!u@^4ov`a@?oLh042$X1*rw3Q&4V*{S8(@AVq__0EZTg2AE7aY&#yfoQhH;Bq#?W iQ&F0*eb)d8@bDUp3t&h)EQLSbF%9KG4S1)a82=4UCAs_n diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json new file mode 100644 index 0000000..d9936a5 --- /dev/null +++ b/data/civilization-artwork.json @@ -0,0 +1,19 @@ +{ + "_readme": [ + "Drop-in art for Civilization. Full spec with frame maps: src/games/civilization/sprites.md.", + "Every sheet is optional: path null = not painted yet, the game renders that layer procedurally.", + "To ship a sheet: put the PNG at the documented path under public/assets/images/civilization/", + "and set `path` here (paths are relative to the web root, no leading slash).", + "Frame order is row-major, 0-based. Frame indexes are pinned in data/civilization-rules.json", + "(units[].frame, terrains[].frame, specials[].frame) — append-only, never renumber.", + "citySheets is keyed by theme. Adding e.g. an `asian` entry makes it selectable with no code change." + ], + "terrainSheet": { "key": "civilization-terrain", "path": null, "frameWidth": 128, "frameHeight": 96 }, + "resourceSheet": { "key": "civilization-resources", "path": null, "frameWidth": 64, "frameHeight": 64 }, + "improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 }, + "unitSheet": { "key": "civilization-units", "path": null, "frameWidth": 64, "frameHeight": 64 }, + "iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 }, + "citySheets": { + "classic": { "key": "civilization-cities-classic", "path": null, "frameWidth": 128, "frameHeight": 96 } + } +} diff --git a/data/civilization-rules.json b/data/civilization-rules.json new file mode 100644 index 0000000..f87f03d --- /dev/null +++ b/data/civilization-rules.json @@ -0,0 +1,288 @@ +{ + "_readme": [ + "Civilization (Civ II-lite) rules data. All techs/units/buildings/terrain are data-driven;", + "src/games/civilization/CivilizationRules.js compiles and validates this file, and", + "tools/verifyCivilization.js enforces integrity (acyclic tech DAG, all prereqs resolve, etc.).", + "Simplifications vs Civ II (by design): no happiness/tax sliders, no senate, no wonders,", + "no pollution, no espionage units, no rivers. Cut techs: Theology, Espionage, Fundamentalism,", + "Environmentalism, Genetic Engineering, Recycling. Cut buildings: Temple, Colosseum, Cathedral,", + "Police Station, Mass Transit, Recycling Center, Solar Plant (happiness/pollution systems removed).", + "Frame indexes reference the sheets documented in src/games/civilization/sprites.md;", + "all sheets are optional (procedural fallback), see data/civilization-artwork.json." + ], + "version": 1, + + "techs": [ + { "id": "alphabet", "name": "Alphabet", "era": "ancient", "prereqs": [] }, + { "id": "bronzeworking", "name": "Bronze Working", "era": "ancient", "prereqs": [] }, + { "id": "ceremonialburial", "name": "Ceremonial Burial", "era": "ancient", "prereqs": [] }, + { "id": "horsebackriding", "name": "Horseback Riding", "era": "ancient", "prereqs": [] }, + { "id": "masonry", "name": "Masonry", "era": "ancient", "prereqs": [] }, + { "id": "pottery", "name": "Pottery", "era": "ancient", "prereqs": [] }, + { "id": "thewheel", "name": "The Wheel", "era": "ancient", "prereqs": [] }, + { "id": "warriorcode", "name": "Warrior Code", "era": "ancient", "prereqs": [] }, + { "id": "codeoflaws", "name": "Code of Laws", "era": "ancient", "prereqs": ["alphabet"] }, + { "id": "writing", "name": "Writing", "era": "ancient", "prereqs": ["alphabet"] }, + { "id": "mapmaking", "name": "Map Making", "era": "ancient", "prereqs": ["alphabet"] }, + { "id": "mathematics", "name": "Mathematics", "era": "ancient", "prereqs": ["alphabet", "masonry"] }, + { "id": "currency", "name": "Currency", "era": "ancient", "prereqs": ["bronzeworking"] }, + { "id": "ironworking", "name": "Iron Working", "era": "ancient", "prereqs": ["bronzeworking", "warriorcode"] }, + { "id": "monarchy", "name": "Monarchy", "era": "ancient", "prereqs": ["ceremonialburial", "codeoflaws"] }, + { "id": "polytheism", "name": "Polytheism", "era": "ancient", "prereqs": ["horsebackriding", "ceremonialburial"] }, + { "id": "literacy", "name": "Literacy", "era": "ancient", "prereqs": ["writing", "codeoflaws"] }, + { "id": "therepublic", "name": "The Republic", "era": "ancient", "prereqs": ["codeoflaws", "literacy"] }, + { "id": "philosophy", "name": "Philosophy", "era": "ancient", "prereqs": ["codeoflaws", "literacy"] }, + { "id": "mysticism", "name": "Mysticism", "era": "ancient", "prereqs": ["ceremonialburial", "philosophy"] }, + { "id": "astronomy", "name": "Astronomy", "era": "ancient", "prereqs": ["mysticism", "mathematics"] }, + { "id": "construction", "name": "Construction", "era": "ancient", "prereqs": ["masonry", "currency"] }, + { "id": "trade", "name": "Trade", "era": "ancient", "prereqs": ["currency", "codeoflaws"] }, + { "id": "seafaring", "name": "Seafaring", "era": "ancient", "prereqs": ["pottery", "mapmaking"] }, + + { "id": "feudalism", "name": "Feudalism", "era": "medieval", "prereqs": ["warriorcode", "monarchy"] }, + { "id": "chivalry", "name": "Chivalry", "era": "medieval", "prereqs": ["feudalism", "horsebackriding"] }, + { "id": "monotheism", "name": "Monotheism", "era": "medieval", "prereqs": ["philosophy", "polytheism"] }, + { "id": "banking", "name": "Banking", "era": "medieval", "prereqs": ["trade", "therepublic"] }, + { "id": "university", "name": "University", "era": "medieval", "prereqs": ["astronomy", "philosophy"] }, + { "id": "navigation", "name": "Navigation", "era": "medieval", "prereqs": ["seafaring", "astronomy"] }, + { "id": "physics", "name": "Physics", "era": "medieval", "prereqs": ["literacy", "navigation"] }, + { "id": "engineering", "name": "Engineering", "era": "medieval", "prereqs": ["thewheel", "construction"] }, + { "id": "invention", "name": "Invention", "era": "medieval", "prereqs": ["writing", "engineering"] }, + { "id": "gunpowder", "name": "Gunpowder", "era": "medieval", "prereqs": ["invention", "ironworking"] }, + { "id": "medicine", "name": "Medicine", "era": "medieval", "prereqs": ["philosophy", "trade"] }, + { "id": "bridgebuilding", "name": "Bridge Building", "era": "medieval", "prereqs": ["ironworking", "construction"] }, + { "id": "sanitation", "name": "Sanitation", "era": "medieval", "prereqs": ["engineering", "medicine"] }, + { "id": "economics", "name": "Economics", "era": "medieval", "prereqs": ["banking", "university"] }, + { "id": "chemistry", "name": "Chemistry", "era": "medieval", "prereqs": ["university", "medicine"] }, + { "id": "theoryofgravity", "name": "Theory of Gravity", "era": "medieval", "prereqs": ["astronomy", "physics"] }, + { "id": "magnetism", "name": "Magnetism", "era": "medieval", "prereqs": ["physics", "ironworking"] }, + { "id": "metallurgy", "name": "Metallurgy", "era": "medieval", "prereqs": ["gunpowder", "university"] }, + { "id": "leadership", "name": "Leadership", "era": "medieval", "prereqs": ["chivalry", "gunpowder"] }, + + { "id": "democracy", "name": "Democracy", "era": "industrial", "prereqs": ["banking", "invention"] }, + { "id": "conscription", "name": "Conscription", "era": "industrial", "prereqs": ["democracy", "metallurgy"] }, + { "id": "explosives", "name": "Explosives", "era": "industrial", "prereqs": ["gunpowder", "chemistry"] }, + { "id": "steamengine", "name": "Steam Engine", "era": "industrial", "prereqs": ["physics", "invention"] }, + { "id": "railroad", "name": "Railroad", "era": "industrial", "prereqs": ["steamengine", "bridgebuilding"] }, + { "id": "industrialization", "name": "Industrialization", "era": "industrial", "prereqs": ["railroad", "banking"] }, + { "id": "thecorporation", "name": "The Corporation", "era": "industrial", "prereqs": ["economics", "industrialization"] }, + { "id": "refining", "name": "Refining", "era": "industrial", "prereqs": ["chemistry", "thecorporation"] }, + { "id": "electricity", "name": "Electricity", "era": "industrial", "prereqs": ["metallurgy", "magnetism"] }, + { "id": "steel", "name": "Steel", "era": "industrial", "prereqs": ["electricity", "industrialization"] }, + { "id": "combustion", "name": "Combustion", "era": "industrial", "prereqs": ["refining", "explosives"] }, + { "id": "automobile", "name": "Automobile", "era": "industrial", "prereqs": ["combustion", "steel"] }, + { "id": "tactics", "name": "Tactics", "era": "industrial", "prereqs": ["conscription", "leadership"] }, + { "id": "communism", "name": "Communism", "era": "industrial", "prereqs": ["philosophy", "industrialization"] }, + { "id": "guerrillawarfare", "name": "Guerrilla Warfare", "era": "industrial", "prereqs": ["communism", "tactics"] }, + { "id": "atomictheory", "name": "Atomic Theory", "era": "industrial", "prereqs": ["theoryofgravity", "physics"] }, + { "id": "electronics", "name": "Electronics", "era": "industrial", "prereqs": ["electricity", "engineering"] }, + { "id": "refrigeration", "name": "Refrigeration", "era": "industrial", "prereqs": ["electricity", "sanitation"] }, + { "id": "machinetools", "name": "Machine Tools", "era": "industrial", "prereqs": ["steel", "tactics"] }, + { "id": "flight", "name": "Flight", "era": "industrial", "prereqs": ["combustion", "theoryofgravity"] }, + { "id": "radio", "name": "Radio", "era": "industrial", "prereqs": ["flight", "electricity"] }, + { "id": "amphibiouswarfare", "name": "Amphibious Warfare", "era": "industrial", "prereqs": ["navigation", "tactics"] }, + + { "id": "advancedflight", "name": "Advanced Flight", "era": "modern", "prereqs": ["radio", "machinetools"] }, + { "id": "rocketry", "name": "Rocketry", "era": "modern", "prereqs": ["advancedflight", "electronics"] }, + { "id": "mobilewarfare", "name": "Mobile Warfare", "era": "modern", "prereqs": ["automobile", "tactics"] }, + { "id": "combinedarms", "name": "Combined Arms", "era": "modern", "prereqs": ["mobilewarfare", "advancedflight"] }, + { "id": "massproduction", "name": "Mass Production", "era": "modern", "prereqs": ["automobile", "thecorporation"] }, + { "id": "laborunion", "name": "Labor Union", "era": "modern", "prereqs": ["massproduction", "guerrillawarfare"] }, + { "id": "nuclearfission", "name": "Nuclear Fission", "era": "modern", "prereqs": ["massproduction", "atomictheory"] }, + { "id": "nuclearpower", "name": "Nuclear Power", "era": "modern", "prereqs": ["nuclearfission", "electronics"] }, + { "id": "laser", "name": "Laser", "era": "modern", "prereqs": ["massproduction", "nuclearpower"] }, + { "id": "miniaturization", "name": "Miniaturization", "era": "modern", "prereqs": ["machinetools", "electronics"] }, + { "id": "computers", "name": "Computers", "era": "modern", "prereqs": ["massproduction", "miniaturization"] }, + { "id": "plastics", "name": "Plastics", "era": "modern", "prereqs": ["refining", "spaceflight"] }, + { "id": "spaceflight", "name": "Space Flight", "era": "modern", "prereqs": ["computers", "rocketry"] }, + { "id": "superconductor", "name": "Superconductor", "era": "modern", "prereqs": ["nuclearpower", "laser"] }, + { "id": "robotics", "name": "Robotics", "era": "modern", "prereqs": ["plastics", "mobilewarfare"] }, + { "id": "stealth", "name": "Stealth", "era": "modern", "prereqs": ["superconductor", "advancedflight"] }, + { "id": "fusionpower", "name": "Fusion Power", "era": "modern", "prereqs": ["nuclearpower", "superconductor"] }, + { "id": "futuretech", "name": "Future Tech", "era": "modern", "prereqs": ["fusionpower"], "repeatable": true } + ], + + "units": [ + { "id": "settlers", "name": "Settlers", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": null, "obsoletedBy": "engineers", "flags": ["settler", "noncombat"], "abbr": "ST", "frame": 0 }, + { "id": "engineers", "name": "Engineers", "domain": "land", "attack": 0, "defense": 2, "move": 2, "hp": 20, "fp": 1, "cost": 40, "prereq": "explosives", "obsoletedBy": null, "flags": ["settler", "engineer", "noncombat"], "abbr": "EN", "frame": 1 }, + { "id": "explorer", "name": "Explorer", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 30, "prereq": "seafaring", "obsoletedBy": null, "flags": ["ignoreterrain", "noncombat"], "abbr": "EX", "frame": 2 }, + { "id": "caravan", "name": "Caravan", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 50, "prereq": "trade", "obsoletedBy": "freight", "flags": ["caravan", "noncombat"], "abbr": "CV", "frame": 3 }, + { "id": "freight", "name": "Freight", "domain": "land", "attack": 0, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 50, "prereq": "thecorporation", "obsoletedBy": null, "flags": ["caravan", "noncombat"], "abbr": "FR", "frame": 4 }, + + { "id": "warriors", "name": "Warriors", "domain": "land", "attack": 1, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 10, "prereq": null, "obsoletedBy": "pikemen", "flags": [], "abbr": "WA", "frame": 5 }, + { "id": "phalanx", "name": "Phalanx", "domain": "land", "attack": 1, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 20, "prereq": "bronzeworking", "obsoletedBy": "pikemen", "flags": [], "abbr": "PH", "frame": 6 }, + { "id": "archers", "name": "Archers", "domain": "land", "attack": 3, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 30, "prereq": "warriorcode", "obsoletedBy": "musketeers", "flags": [], "abbr": "AR", "frame": 7 }, + { "id": "legion", "name": "Legion", "domain": "land", "attack": 4, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": "ironworking", "obsoletedBy": "musketeers", "flags": [], "abbr": "LG", "frame": 8 }, + { "id": "pikemen", "name": "Pikemen", "domain": "land", "attack": 1, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 20, "prereq": "feudalism", "obsoletedBy": "musketeers", "flags": ["antimounted"], "abbr": "PK", "frame": 9 }, + { "id": "musketeers", "name": "Musketeers", "domain": "land", "attack": 3, "defense": 3, "move": 1, "hp": 20, "fp": 1, "cost": 30, "prereq": "gunpowder", "obsoletedBy": "riflemen", "flags": [], "abbr": "MK", "frame": 10 }, + { "id": "riflemen", "name": "Riflemen", "domain": "land", "attack": 5, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 40, "prereq": "conscription", "obsoletedBy": null, "flags": [], "abbr": "RF", "frame": 11 }, + { "id": "alpinetroops", "name": "Alpine Troops", "domain": "land", "attack": 5, "defense": 5, "move": 1, "hp": 20, "fp": 1, "cost": 50, "prereq": "tactics", "obsoletedBy": null, "flags": ["ignoreterrain"], "abbr": "AL", "frame": 12 }, + { "id": "partisans", "name": "Partisans", "domain": "land", "attack": 4, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 50, "prereq": "guerrillawarfare", "obsoletedBy": null, "flags": ["ignoreterrain"], "abbr": "PS", "frame": 13 }, + { "id": "marines", "name": "Marines", "domain": "land", "attack": 8, "defense": 5, "move": 1, "hp": 20, "fp": 1, "cost": 60, "prereq": "amphibiouswarfare", "obsoletedBy": null, "flags": ["amphibious"], "abbr": "MN", "frame": 14 }, + { "id": "paratroopers", "name": "Paratroopers", "domain": "land", "attack": 6, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 60, "prereq": "combinedarms", "obsoletedBy": null, "flags": ["paradrop"], "abbr": "PT", "frame": 15 }, + { "id": "mechinf", "name": "Mech. Inf.", "domain": "land", "attack": 6, "defense": 6, "move": 3, "hp": 30, "fp": 1, "cost": 50, "prereq": "laborunion", "obsoletedBy": null, "flags": [], "abbr": "MI", "frame": 16 }, + + { "id": "horsemen", "name": "Horsemen", "domain": "land", "attack": 2, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 20, "prereq": "horsebackriding", "obsoletedBy": "knights", "flags": ["mounted"], "abbr": "HS", "frame": 17 }, + { "id": "chariot", "name": "Chariot", "domain": "land", "attack": 3, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 30, "prereq": "thewheel", "obsoletedBy": "knights", "flags": ["mounted"], "abbr": "CH", "frame": 18 }, + { "id": "elephant", "name": "Elephant", "domain": "land", "attack": 4, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "polytheism", "obsoletedBy": "crusaders", "flags": ["mounted"], "abbr": "EL", "frame": 19 }, + { "id": "knights", "name": "Knights", "domain": "land", "attack": 4, "defense": 2, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "chivalry", "obsoletedBy": "dragoons", "flags": ["mounted"], "abbr": "KN", "frame": 20 }, + { "id": "crusaders", "name": "Crusaders", "domain": "land", "attack": 5, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "monotheism", "obsoletedBy": "dragoons", "flags": ["mounted"], "abbr": "CS", "frame": 21 }, + { "id": "dragoons", "name": "Dragoons", "domain": "land", "attack": 5, "defense": 2, "move": 2, "hp": 20, "fp": 1, "cost": 50, "prereq": "leadership", "obsoletedBy": "cavalry", "flags": ["mounted"], "abbr": "DG", "frame": 22 }, + { "id": "cavalry", "name": "Cavalry", "domain": "land", "attack": 8, "defense": 3, "move": 2, "hp": 20, "fp": 1, "cost": 60, "prereq": "tactics", "obsoletedBy": "armor", "flags": ["mounted"], "abbr": "CY", "frame": 23 }, + { "id": "armor", "name": "Armor", "domain": "land", "attack": 10, "defense": 5, "move": 3, "hp": 30, "fp": 1, "cost": 80, "prereq": "mobilewarfare", "obsoletedBy": null, "flags": ["mounted"], "abbr": "AM", "frame": 24 }, + + { "id": "catapult", "name": "Catapult", "domain": "land", "attack": 6, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": "mathematics", "obsoletedBy": "cannon", "flags": [], "abbr": "CP", "frame": 25 }, + { "id": "cannon", "name": "Cannon", "domain": "land", "attack": 8, "defense": 1, "move": 1, "hp": 20, "fp": 1, "cost": 40, "prereq": "metallurgy", "obsoletedBy": "artillery", "flags": [], "abbr": "CN", "frame": 26 }, + { "id": "artillery", "name": "Artillery", "domain": "land", "attack": 10, "defense": 1, "move": 1, "hp": 20, "fp": 2, "cost": 50, "prereq": "machinetools", "obsoletedBy": "howitzer", "flags": [], "abbr": "AT", "frame": 27 }, + { "id": "howitzer", "name": "Howitzer", "domain": "land", "attack": 12, "defense": 2, "move": 2, "hp": 30, "fp": 2, "cost": 70, "prereq": "robotics", "obsoletedBy": null, "flags": ["ignorewalls"], "abbr": "HW", "frame": 28 }, + + { "id": "fighter", "name": "Fighter", "domain": "air", "attack": 4, "defense": 3, "move": 10, "hp": 20, "fp": 2, "cost": 60, "prereq": "flight", "obsoletedBy": "stealthfighter", "flags": ["fighter"], "abbr": "FT", "frame": 29 }, + { "id": "bomber", "name": "Bomber", "domain": "air", "attack": 12, "defense": 1, "move": 8, "hp": 20, "fp": 2, "cost": 120, "prereq": "advancedflight", "obsoletedBy": "stealthbomber", "flags": [], "abbr": "BM", "frame": 30 }, + { "id": "helicopter", "name": "Helicopter", "domain": "air", "attack": 10, "defense": 3, "move": 6, "hp": 20, "fp": 2, "cost": 100, "prereq": "combinedarms", "obsoletedBy": null, "flags": [], "abbr": "HC", "frame": 31 }, + { "id": "stealthfighter", "name": "Stealth Ftr.", "domain": "air", "attack": 8, "defense": 4, "move": 14, "hp": 20, "fp": 2, "cost": 80, "prereq": "stealth", "obsoletedBy": null, "flags": ["fighter"], "abbr": "SF", "frame": 32 }, + { "id": "stealthbomber", "name": "Stealth Bmb.", "domain": "air", "attack": 14, "defense": 5, "move": 12, "hp": 20, "fp": 2, "cost": 160, "prereq": "stealth", "obsoletedBy": null, "flags": [], "abbr": "SB", "frame": 33 }, + { "id": "cruisemsl", "name": "Cruise Msl.", "domain": "air", "attack": 18, "defense": 0, "move": 12, "hp": 10, "fp": 3, "cost": 60, "prereq": "rocketry", "obsoletedBy": null, "flags": ["missile"], "abbr": "CM", "frame": 34 }, + { "id": "nuclearmsl", "name": "Nuclear Msl.", "domain": "air", "attack": 99, "defense": 0, "move": 16, "hp": 10, "fp": 1, "cost": 160, "prereq": "nuclearfission", "obsoletedBy": null, "flags": ["missile", "nuke"], "abbr": "NM", "frame": 35 }, + + { "id": "trireme", "name": "Trireme", "domain": "sea", "attack": 1, "defense": 1, "move": 3, "hp": 10, "fp": 1, "cost": 40, "prereq": "mapmaking", "obsoletedBy": "caravel", "flags": ["coastal"], "cargo": 2, "abbr": "TR", "frame": 36 }, + { "id": "caravel", "name": "Caravel", "domain": "sea", "attack": 2, "defense": 1, "move": 3, "hp": 10, "fp": 1, "cost": 40, "prereq": "navigation", "obsoletedBy": "galleon", "flags": [], "cargo": 3, "abbr": "CL", "frame": 37 }, + { "id": "galleon", "name": "Galleon", "domain": "sea", "attack": 0, "defense": 2, "move": 4, "hp": 20, "fp": 1, "cost": 40, "prereq": "magnetism", "obsoletedBy": "transport", "flags": [], "cargo": 4, "abbr": "GL", "frame": 38 }, + { "id": "frigate", "name": "Frigate", "domain": "sea", "attack": 4, "defense": 2, "move": 4, "hp": 20, "fp": 1, "cost": 50, "prereq": "magnetism", "obsoletedBy": "destroyer", "flags": [], "cargo": 2, "abbr": "FG", "frame": 39 }, + { "id": "ironclad", "name": "Ironclad", "domain": "sea", "attack": 4, "defense": 4, "move": 4, "hp": 30, "fp": 1, "cost": 60, "prereq": "steamengine", "obsoletedBy": "destroyer", "flags": [], "abbr": "IC", "frame": 40 }, + { "id": "destroyer", "name": "Destroyer", "domain": "sea", "attack": 4, "defense": 4, "move": 6, "hp": 30, "fp": 1, "cost": 60, "prereq": "combustion", "obsoletedBy": null, "flags": [], "abbr": "DS", "frame": 41 }, + { "id": "cruiser", "name": "Cruiser", "domain": "sea", "attack": 6, "defense": 6, "move": 5, "hp": 30, "fp": 2, "cost": 80, "prereq": "steel", "obsoletedBy": "aegiscruiser", "flags": [], "abbr": "CU", "frame": 42 }, + { "id": "aegiscruiser", "name": "AEGIS Cruiser", "domain": "sea", "attack": 8, "defense": 8, "move": 5, "hp": 30, "fp": 2, "cost": 100, "prereq": "rocketry", "obsoletedBy": null, "flags": ["aegis"], "abbr": "AE", "frame": 43 }, + { "id": "battleship", "name": "Battleship", "domain": "sea", "attack": 12, "defense": 12, "move": 4, "hp": 40, "fp": 2, "cost": 160, "prereq": "automobile", "obsoletedBy": null, "flags": [], "abbr": "BS", "frame": 44 }, + { "id": "submarine", "name": "Submarine", "domain": "sea", "attack": 10, "defense": 2, "move": 3, "hp": 30, "fp": 2, "cost": 60, "prereq": "combustion", "obsoletedBy": null, "flags": ["submarine"], "abbr": "SM", "frame": 45 }, + { "id": "carrier", "name": "Carrier", "domain": "sea", "attack": 1, "defense": 9, "move": 5, "hp": 40, "fp": 2, "cost": 160, "prereq": "advancedflight", "obsoletedBy": null, "flags": [], "cargoAir": 8, "abbr": "CR", "frame": 46 }, + { "id": "transport", "name": "Transport", "domain": "sea", "attack": 0, "defense": 3, "move": 5, "hp": 30, "fp": 1, "cost": 50, "prereq": "industrialization", "obsoletedBy": null, "flags": [], "cargo": 8, "abbr": "TP", "frame": 47 }, + + { "id": "ssstructural", "name": "SS Structural", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 80, "prereq": "spaceflight", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S1", "frame": 48 }, + { "id": "sscomponent", "name": "SS Component", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 160, "prereq": "plastics", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S2", "frame": 49 }, + { "id": "ssmodule", "name": "SS Module", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 320, "prereq": "superconductor", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S3", "frame": 50 } + ], + + "spaceship": { "structuralNeeded": 8, "componentsNeeded": 4, "modulesNeeded": 3, "travelTurns": 15 }, + + "terrains": [ + { "id": "grassland", "name": "Grassland", "food": 2, "shield": 0, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": null, "transform": null, "water": false, "frame": 0, "color": "#5a9e44" }, + { "id": "plains", "name": "Plains", "food": 1, "shield": 1, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": null, "transform": "grassland", "water": false, "frame": 2, "color": "#b8a04e" }, + { "id": "forest", "name": "Forest", "food": 1, "shield": 2, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "plains", "water": false, "frame": 3, "color": "#2f6b2f" }, + { "id": "hills", "name": "Hills", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 2.0, "irrigate": 1, "mine": 3, "transform": null, "water": false, "frame": 4, "color": "#8a7a4a" }, + { "id": "mountains", "name": "Mountains", "food": 0, "shield": 1, "trade": 0, "move": 3, "defense": 3.0, "irrigate": null, "mine": 2, "transform": "hills", "water": false, "frame": 5, "color": "#7d7d85" }, + { "id": "desert", "name": "Desert", "food": 0, "shield": 1, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": 1, "transform": "plains", "water": false, "frame": 6, "color": "#d8c072" }, + { "id": "tundra", "name": "Tundra", "food": 1, "shield": 0, "trade": 0, "move": 1, "defense": 1.0, "irrigate": null, "mine": null, "transform": "plains", "water": false, "frame": 7, "color": "#a8b0a0" }, + { "id": "glacier", "name": "Glacier", "food": 0, "shield": 0, "trade": 0, "move": 2, "defense": 1.0, "irrigate": null, "mine": 1, "transform": "tundra", "water": false, "frame": 8, "color": "#e8f0f4" }, + { "id": "swamp", "name": "Swamp", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "grassland", "water": false, "frame": 9, "color": "#4a6b52" }, + { "id": "jungle", "name": "Jungle", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "grassland", "water": false, "frame": 10, "color": "#1f7a3d" }, + { "id": "ocean", "name": "Ocean", "food": 1, "shield": 0, "trade": 2, "move": 1, "defense": 1.0, "irrigate": null, "mine": null, "transform": null, "water": true, "frame": 11, "color": "#2b5f9e" } + ], + "grasslandShieldFrame": 1, + + "specials": [ + { "id": "buffalo", "name": "Buffalo", "terrain": "plains", "food": 1, "shield": 3, "trade": 0, "frame": 0 }, + { "id": "wheat", "name": "Wheat", "terrain": "plains", "food": 3, "shield": 1, "trade": 0, "frame": 1 }, + { "id": "pheasant", "name": "Pheasant", "terrain": "forest", "food": 3, "shield": 2, "trade": 0, "frame": 2 }, + { "id": "silk", "name": "Silk", "terrain": "forest", "food": 1, "shield": 2, "trade": 3, "frame": 3 }, + { "id": "coal", "name": "Coal", "terrain": "hills", "food": 1, "shield": 2, "trade": 0, "frame": 4 }, + { "id": "wine", "name": "Wine", "terrain": "hills", "food": 1, "shield": 0, "trade": 4, "frame": 5 }, + { "id": "gold", "name": "Gold", "terrain": "mountains", "food": 0, "shield": 1, "trade": 6, "frame": 6 }, + { "id": "iron", "name": "Iron", "terrain": "mountains", "food": 0, "shield": 4, "trade": 0, "frame": 7 }, + { "id": "oasis", "name": "Oasis", "terrain": "desert", "food": 3, "shield": 1, "trade": 0, "frame": 8 }, + { "id": "oil", "name": "Oil", "terrain": "desert", "food": 0, "shield": 4, "trade": 0, "frame": 9 }, + { "id": "game", "name": "Game", "terrain": "tundra", "food": 3, "shield": 0, "trade": 0, "frame": 10 }, + { "id": "furs", "name": "Furs", "terrain": "tundra", "food": 2, "shield": 0, "trade": 3, "frame": 11 }, + { "id": "ivory", "name": "Ivory", "terrain": "glacier", "food": 1, "shield": 1, "trade": 4, "frame": 12 }, + { "id": "glacieroil", "name": "Oil", "terrain": "glacier", "food": 0, "shield": 4, "trade": 0, "frame": 13 }, + { "id": "peat", "name": "Peat", "terrain": "swamp", "food": 1, "shield": 4, "trade": 0, "frame": 14 }, + { "id": "spice", "name": "Spice", "terrain": "swamp", "food": 3, "shield": 0, "trade": 4, "frame": 15 }, + { "id": "gems", "name": "Gems", "terrain": "jungle", "food": 1, "shield": 0, "trade": 4, "frame": 16 }, + { "id": "fruit", "name": "Fruit", "terrain": "jungle", "food": 4, "shield": 0, "trade": 1, "frame": 17 }, + { "id": "fish", "name": "Fish", "terrain": "ocean", "food": 3, "shield": 0, "trade": 2, "frame": 18 }, + { "id": "whales", "name": "Whales", "terrain": "ocean", "food": 2, "shield": 2, "trade": 3, "frame": 19 } + ], + + "improvements": [ + { "id": "road", "name": "Road", "work": 2, "prereq": null, "engineerOnly": false }, + { "id": "railroad", "name": "Railroad", "work": 4, "prereq": "railroad", "engineerOnly": false, "requires": "road" }, + { "id": "irrigation", "name": "Irrigation", "work": 3, "prereq": null, "engineerOnly": false }, + { "id": "farmland", "name": "Farmland", "work": 4, "prereq": "refrigeration", "engineerOnly": false, "requires": "irrigation" }, + { "id": "mine", "name": "Mine", "work": 4, "prereq": null, "engineerOnly": false }, + { "id": "fortress", "name": "Fortress", "work": 5, "prereq": "construction", "engineerOnly": false }, + { "id": "transform", "name": "Transform", "work": 8, "prereq": "explosives", "engineerOnly": true } + ], + + "buildings": [ + { "id": "palace", "name": "Palace", "cost": 100, "upkeep": 0, "prereq": "masonry", "requires": null, "effect": "palace" }, + { "id": "barracks", "name": "Barracks", "cost": 40, "upkeep": 1, "prereq": null, "requires": null, "effect": "veterans" }, + { "id": "granary", "name": "Granary", "cost": 60, "upkeep": 1, "prereq": "pottery", "requires": null, "effect": "granary" }, + { "id": "citywalls", "name": "City Walls", "cost": 80, "upkeep": 0, "prereq": "masonry", "requires": null, "effect": "walls", "value": 3 }, + { "id": "courthouse", "name": "Courthouse", "cost": 80, "upkeep": 1, "prereq": "codeoflaws", "requires": null, "effect": "corruption", "value": 0.5 }, + { "id": "library", "name": "Library", "cost": 80, "upkeep": 1, "prereq": "writing", "requires": null, "effect": "science", "value": 0.5 }, + { "id": "marketplace", "name": "Marketplace", "cost": 80, "upkeep": 1, "prereq": "currency", "requires": null, "effect": "gold", "value": 0.5 }, + { "id": "aqueduct", "name": "Aqueduct", "cost": 80, "upkeep": 2, "prereq": "construction", "requires": null, "effect": "sizecap", "value": 8 }, + { "id": "harbor", "name": "Harbor", "cost": 60, "upkeep": 1, "prereq": "seafaring", "requires": null, "effect": "oceanfood", "value": 1 }, + { "id": "university", "name": "University", "cost": 160, "upkeep": 3, "prereq": "university", "requires": "library", "effect": "science", "value": 0.5 }, + { "id": "bank", "name": "Bank", "cost": 120, "upkeep": 2, "prereq": "banking", "requires": "marketplace", "effect": "gold", "value": 0.5 }, + { "id": "sewersystem", "name": "Sewer System", "cost": 120, "upkeep": 2, "prereq": "sanitation", "requires": "aqueduct", "effect": "sizecap", "value": 12 }, + { "id": "stockexchange", "name": "Stock Exchange", "cost": 160, "upkeep": 3, "prereq": "thecorporation", "requires": "bank", "effect": "gold", "value": 0.5 }, + { "id": "coastalfortress", "name": "Coastal Fortress", "cost": 80, "upkeep": 1, "prereq": "metallurgy", "requires": null, "effect": "defensesea", "value": 2 }, + { "id": "factory", "name": "Factory", "cost": 200, "upkeep": 4, "prereq": "industrialization", "requires": null, "effect": "shields", "value": 0.5 }, + { "id": "powerplant", "name": "Power Plant", "cost": 160, "upkeep": 4, "prereq": "refining", "requires": "factory", "effect": "power", "value": 0.25 }, + { "id": "hydroplant", "name": "Hydro Plant", "cost": 240, "upkeep": 4, "prereq": "electronics", "requires": "factory", "effect": "power", "value": 0.25 }, + { "id": "nuclearplant", "name": "Nuclear Plant", "cost": 160, "upkeep": 2, "prereq": "nuclearpower", "requires": "factory", "effect": "power", "value": 0.25 }, + { "id": "mfgplant", "name": "Mfg. Plant", "cost": 320, "upkeep": 6, "prereq": "robotics", "requires": "factory", "effect": "shields", "value": 0.25 }, + { "id": "offshoreplatform", "name": "Offshore Platform", "cost": 160, "upkeep": 3, "prereq": "miniaturization", "requires": null, "effect": "oceanshield", "value": 1 }, + { "id": "airport", "name": "Airport", "cost": 160, "upkeep": 3, "prereq": "radio", "requires": null, "effect": "airport" }, + { "id": "superhighways", "name": "Superhighways", "cost": 120, "upkeep": 5, "prereq": "automobile", "requires": null, "effect": "roadtrade", "value": 0.5 }, + { "id": "researchlab", "name": "Research Lab", "cost": 160, "upkeep": 3, "prereq": "computers", "requires": "university", "effect": "science", "value": 0.5 }, + { "id": "sambattery", "name": "SAM Battery", "cost": 100, "upkeep": 2, "prereq": "rocketry", "requires": null, "effect": "defenseair", "value": 2 }, + { "id": "sdidefense", "name": "SDI Defense", "cost": 200, "upkeep": 4, "prereq": "superconductor", "requires": null, "effect": "sdi" }, + { "id": "supermarket", "name": "Supermarket", "cost": 80, "upkeep": 3, "prereq": "refrigeration", "requires": null, "effect": "farmfood", "value": 0.5 } + ], + + "governments": [ + { "id": "despotism", "name": "Despotism", "prereq": null, "corruptionFactor": 1.0, "despotPenalty": true, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 10 }, + { "id": "anarchy", "name": "Anarchy", "prereq": null, "corruptionFactor": 1.5, "despotPenalty": true, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "noScience": true, "frame": 11 }, + { "id": "monarchy", "name": "Monarchy", "prereq": "monarchy", "corruptionFactor": 0.7, "despotPenalty": false, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 12 }, + { "id": "communism", "name": "Communism", "prereq": "communism", "corruptionFactor": 0.4, "despotPenalty": false, "tradeBonus": 0, "flatCorruption": true, "freeUnits": 6, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 13 }, + { "id": "republic", "name": "The Republic", "prereq": "therepublic", "corruptionFactor": 0.3, "despotPenalty": false, "tradeBonus": 1, "flatCorruption": false, "freeUnits": 0, "unitUpkeep": "shield", "settlerFood": 2, "warPenalty": 1, "frame": 14 }, + { "id": "democracy", "name": "Democracy", "prereq": "democracy", "corruptionFactor": 0.0, "despotPenalty": false, "tradeBonus": 1, "flatCorruption": false, "freeUnits": 0, "unitUpkeep": "gold", "settlerFood": 2, "warPenalty": 2, "frame": 15 } + ], + + "difficulties": [ + { "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25 }, + { "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4 }, + { "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5 }, + { "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65 }, + { "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8 } + ], + + "worldSizes": [ + { "id": "small", "name": "Small", "cols": 40, "rows": 32, "startSettlers": 1 }, + { "id": "medium", "name": "Medium", "cols": 56, "rows": 44, "startSettlers": 2 }, + { "id": "large", "name": "Large", "cols": 72, "rows": 56, "startSettlers": 2 } + ], + + "playerColors": ["#3b82f6", "#ef4444", "#22c55e", "#eab308", "#a855f7", "#06b6d4", "#f97316", "#ec4899"], + + "cityNames": [ + "Rivermouth", "Stonehaven", "Fairview", "Oakdale", "Silverport", "Highgate", "Westmarch", "Eastwick", + "Northolt", "Southmere", "Goldcrest", "Ironforge", "Millbrook", "Ashford", "Briarwood", "Cedar Falls", + "Duskvale", "Elmshade", "Foxglove", "Greenfield", "Harborview", "Ivorydale", "Junction", "Kingsbridge", + "Lakeshore", "Maplewood", "Newhaven", "Oxbow", "Pinecrest", "Quarrytown", "Redcliff", "Sandpoint", + "Thornbury", "Umberfell", "Vineyard", "Willowdale", "Yellowbrook", "Zephyr Bay", "Amberton", "Blackwater", + "Coppermine", "Driftwood", "Emberly", "Frostholm", "Glasswater", "Hearthstone", "Islegard", "Jadeport", + "Kestrel Point", "Larkspur", "Moonwell", "Nightfall", "Opal Coast", "Palisade", "Quietwater", "Ravenrock", + "Starfall", "Tidewater", "Underhill", "Violetgate", "Wheatfield", "Wolfden", "Yarrow", "Zenith" + ], + + "yearCurve": [ + { "until": -1000, "step": 50 }, + { "until": 1, "step": 25 }, + { "until": 1500, "step": 20 }, + { "until": 1750, "step": 10 }, + { "until": 1850, "step": 5 }, + { "until": 1900, "step": 2 }, + { "until": 99999, "step": 1 } + ] +} diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index c7ee57e..74e4daf 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -151,6 +151,15 @@ export const MANIFEST = { return ids.map((id) => image(`peggle-bg-${id}`, `assets/images/peggle/${id}.png`)); }, ], + // Rules data always loads; sprite sheets are optional drop-ins declared in + // data/civilization-artwork.json (spec: src/games/civilization/sprites.md). + // Sheets with path:null stay procedural. citySheets is a map keyed by theme + // (classic today; asian etc. drop in later with no code change). + civilization: [ + { type: 'json', key: 'civilization-rules', path: 'data/civilization-rules.json' }, + (scene) => sheetsFrom(scene, 'civilization-artwork', + ['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet', 'citySheets']), + ], }; // Returns the full (normalized) descriptor list for a slug — including assets diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 599e25b..315fb12 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -110,3 +110,4 @@ registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: tr registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 }); registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 }); registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, iconFrame: 82 }); +registerGame({ slug: 'civilization', name: 'Civilization', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 83 }); diff --git a/src/games/civilization/CivilizationAI.js b/src/games/civilization/CivilizationAI.js new file mode 100644 index 0000000..5d838b7 --- /dev/null +++ b/src/games/civilization/CivilizationAI.js @@ -0,0 +1,643 @@ +// Civilization — AI civ controller. Headless (no Phaser). +// +// runAITurn(rules, state, civIdx) plays one civ's whole turn: strategy pick, +// diplomacy, research, city builds, then unit orders. respondToProposal +// answers human (or other-AI) diplomacy. AI-initiated proposals toward the +// human are queued on state.events as { type: 'aiProposal', keep: true } for +// the scene to present at the start of the human turn. + +import { + rand, randInt, cheb, tileIndex, inBounds, terrainAt, cityAt, unitsAt, + civUnits, civCities, cityById, knownCount, availableTechs, availableUnits, + availableBuildings, canFoundCity, foundCity, setBuild, buyCost, buyBuild, + tryMove, disembark, findPath, startWork, canWork, canEstablishRoute, + establishTradeRoute, resolveAttack, attackerStrength, defenderStrength, + pickDefender, launchSpaceship, canPropose, applyTreaty, declareWar, + exchangeTechs, civPower, cityYields, buildCost, + setResearch, startRevolution, +} from './CivilizationLogic.js'; +import { siteQuality } from './CivilizationWorldGen.js'; + +const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops + +export function runAITurn(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (!civ.alive || state.over) return; + + const strategy = computeStrategy(rules, state, civIdx); + doDiplomacy(rules, state, civIdx, strategy); + doResearch(rules, state, civIdx, strategy); + doGovernment(rules, state, civIdx); + for (const city of civCities(state, civIdx)) { + manageCityBuild(rules, state, civIdx, city, strategy); + } + doUnits(rules, state, civIdx, strategy); + + // Launch when ready unless conquest is already in hand. + const ship = civ.spaceship; + if (!ship.launched + && ship.structural >= rules.spaceship.structuralNeeded + && ship.component >= rules.spaceship.componentsNeeded + && ship.module >= rules.spaceship.modulesNeeded) { + launchSpaceship(rules, state, civ); + } +} + +// --------------------------------------------------------------------------- +// Strategy + +export function computeStrategy(rules, state, civIdx) { + const civ = state.civs[civIdx]; + const diff = rules.difficulties[state.difficultyId]; + const myCities = civCities(state, civIdx); + const myPower = civPower(rules, state, civIdx); + + const atWarWith = state.civs.filter((c) => c.alive && c.id !== civIdx + && civ.relations[c.id] === 'war').map((c) => c.id); + + const techTotal = rules.techList.filter((t) => !t.repeatable).length; + const techFrac = knownCount(civ) / techTotal; + const wantsSpace = civ.known.spaceflight || techFrac >= 0.8; + + const settlerCount = civUnits(state, civIdx) + .filter((u) => rules.units[u.type].flags.includes('settler')).length; + const landTiles = state.world.cols * state.world.rows * (state.world.landFraction ?? 0.3); + const targetCities = Math.max(5, Math.round(landTiles / 40)); + const canExpand = myCities.length < targetCities && settlerCount < 3; + + let phase = 'develop'; + if (atWarWith.length) phase = 'war'; + else if (wantsSpace) phase = 'space'; + else if (canExpand || myCities.length === 0) phase = 'expand'; + + return { phase, atWarWith, myPower, aggression: diff.aiAggression, techFrac }; +} + +// --------------------------------------------------------------------------- +// Diplomacy + +function doDiplomacy(rules, state, civIdx, strategy) { + const civ = state.civs[civIdx]; + for (const other of state.civs) { + if (other.id === civIdx || !other.alive) continue; + const rel = civ.relations[other.id]; + if (rel === 'nocontact') continue; + const attitude = civ.attitude[other.id] ?? 0; + const powerRatio = strategy.myPower / Math.max(1, civPower(rules, state, other.id)); + + if (rel === 'war') { + // Sue for peace when clearly losing. + if (powerRatio < 0.6 && canPropose(state, civIdx, other.id, 'ceasefire')) { + proposeOrQueue(rules, state, civIdx, other.id, 'ceasefire'); + } + continue; + } + // Escalate to war: hostile attitude + power advantage + aggression roll, + // opportunism against a much weaker neighbour, or (rarely) a true sneak + // attack on a treaty partner who has fallen far behind. + const sneakBar = rel === 'peace' || rel === 'alliance' ? -70 : -10; + const hostile = attitude <= sneakBar && powerRatio > 1.15; + const opportunist = rel === 'contact' && powerRatio > 1.6 && attitude < 40; + const sneak = (rel === 'peace') && powerRatio > 2.5 && attitude < 0; + if ((hostile || opportunist) && rand(state) < strategy.aggression * 0.3) { + declareWar(rules, state, civIdx, other.id); + continue; + } + if (sneak && rand(state) < strategy.aggression * 0.1) { + declareWar(rules, state, civIdx, other.id); + continue; + } + // Peace-seeking & alliances with actual friends only — a world of + // reflexive peace treaties stalls the game forever. + if (rel === 'ceasefire' && attitude > 0) proposeOrQueue(rules, state, civIdx, other.id, 'peace'); + else if (rel === 'contact' && attitude > 30) proposeOrQueue(rules, state, civIdx, other.id, 'peace'); + else if (rel === 'peace' && attitude > 50 && strategy.atWarWith.length === 0 + && sharedEnemy(state, civIdx, other.id)) { + proposeOrQueue(rules, state, civIdx, other.id, 'alliance'); + } + // Fair tech trades with non-hostile AIs (human trades happen in their UI). + if (!other.human && attitude > 0 && rand(state) < 0.25) { + tryFairExchange(rules, state, civIdx, other.id); + } + } +} + +function sharedEnemy(state, a, b) { + return state.civs.some((c) => c.alive && c.id !== a && c.id !== b + && state.civs[a].relations[c.id] === 'war' && state.civs[b].relations[c.id] === 'war'); +} + +function proposeOrQueue(rules, state, fromIdx, toIdx, kind) { + const target = state.civs[toIdx]; + if (target.human) { + if (!state.events.some((e) => e.type === 'aiProposal' && e.from === fromIdx && e.kind === kind)) { + state.events.push({ type: 'aiProposal', from: fromIdx, to: toIdx, kind, keep: true }); + } + return; + } + if (respondToProposal(rules, state, toIdx, fromIdx, kind)) { + applyTreaty(state, fromIdx, toIdx, kind); + } +} + +export function respondToProposal(rules, state, aiIdx, fromIdx, kind, payload = {}) { + const civ = state.civs[aiIdx]; + const attitude = civ.attitude[fromIdx] ?? 0; + const powerRatio = civPower(rules, state, aiIdx) + / Math.max(1, civPower(rules, state, fromIdx)); + switch (kind) { + case 'ceasefire': + // Winners press on; only accept when not dominant or genuinely friendly. + return powerRatio < 1.1 || attitude > 0; + case 'peace': + return powerRatio < 0.8 || attitude > 20; + case 'alliance': + return attitude > 40 && (sharedEnemy(state, aiIdx, fromIdx) || attitude > 65); + case 'exchange': { + const { giveId, getId } = payload; // from `fromIdx`'s perspective + if (!giveId || !getId) return false; + const rankGive = rules.techRank[giveId] ?? 0; + const rankGet = rules.techRank[getId] ?? 0; + return attitude > -10 && rankGive >= rankGet - 1; + } + case 'tribute': + return powerRatio < 0.6 && (payload.amount ?? 0) <= civ.gold * 0.25; + default: + return false; + } +} + +function tryFairExchange(rules, state, a, b) { + const civA = state.civs[a]; + const civB = state.civs[b]; + const aOffers = Object.keys(civA.known).filter((t) => !civB.known[t]); + const bOffers = Object.keys(civB.known).filter((t) => !civA.known[t]); + if (!aOffers.length || !bOffers.length) return; + aOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0)); + bOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0)); + const give = aOffers[0]; + const get = bOffers[0]; + if (Math.abs((rules.techRank[give] ?? 0) - (rules.techRank[get] ?? 0)) <= 1) { + exchangeTechs(rules, state, a, b, give, get); + } +} + +// --------------------------------------------------------------------------- +// Research & government + +const SPACE_CHAIN = ['spaceflight', 'plastics', 'superconductor', 'fusionpower']; + +function doResearch(rules, state, civIdx, strategy) { + const civ = state.civs[civIdx]; + if (civ.researching) return; + const options = availableTechs(rules, civ); + if (!options.length) return; + let best = null; + let bestScore = -Infinity; + for (const t of options) { + const g = rules.techGates[t.id]; + let score = rand(state) * 4; // jitter + score += g.prereqOf.length * 2; + const gatesUnits = g.units.length > 0; + const gatesEcon = g.buildings.some((b) => ['science', 'gold', 'shields', 'granary', 'sizecap'] + .includes(rules.buildings[b].effect)); + if (strategy.phase === 'war' && gatesUnits) score += 8; + if (strategy.phase !== 'war' && gatesEcon) score += 6; + if (g.governments.length && strategy.phase !== 'war') score += 5; + if (strategy.phase === 'space' && (SPACE_CHAIN.includes(t.id) + || g.prereqOf.some((p) => SPACE_CHAIN.includes(p)))) score += 12; + if (t.repeatable) score -= 20; + if (score > bestScore) { bestScore = score; best = t; } + } + if (best) setResearch(rules, state, civ, best.id); +} + +function doGovernment(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (civ.government === 'anarchy') return; + // Simple ladder: despotism -> monarchy -> republic (peace) / communism (war-heavy). + const wants = civ.known.democracy && atPeace(state, civIdx) ? 'democracy' + : civ.known.therepublic && atPeace(state, civIdx) ? 'republic' + : civ.known.communism && !atPeace(state, civIdx) ? 'communism' + : civ.known.monarchy ? 'monarchy' : null; + if (wants && wants !== civ.government + && govRank(wants) > govRank(civ.government)) { + startRevolution(rules, state, civ, wants); + } +} +function atPeace(state, civIdx) { + return !Object.values(state.civs[civIdx].relations).includes('war'); +} +function govRank(id) { + return { despotism: 0, anarchy: -1, monarchy: 1, communism: 2, republic: 2, democracy: 3 }[id] ?? 0; +} + +// --------------------------------------------------------------------------- +// City builds + +const DEVELOP_CHAIN = ['granary', 'library', 'marketplace', 'barracks', 'aqueduct', 'harbor', + 'university', 'bank', 'courthouse', 'sewersystem', 'factory', 'powerplant', 'stockexchange', + 'superhighways', 'researchlab', 'supermarket', 'mfgplant']; + +function manageCityBuild(rules, state, civIdx, city, strategy) { + const civ = state.civs[civIdx]; + const def = rules.units[city.build?.id]; + const midBuild = city.shieldBox > 0 && city.shieldBox < buildCost(rules, city) * 0.9; + const defenders = unitsAt(state, city.x, city.y) + .filter((u) => u.civ === civIdx && rules.units[u.type].domain === 'land' + && !rules.units[u.type].flags.includes('noncombat')); + + // Emergency: garrison first, buy it when the enemy is at the gate. + const wantGarrison = strategy.phase === 'war' ? 2 : 1; + if (defenders.length < wantGarrison) { + const best = bestDefender(rules, state, civ, city); + if (best && city.build?.id !== best.id) setBuild(rules, state, city, 'unit', best.id); + const enemyNear = state.units.some((u) => u.civ !== civIdx + && state.civs[civIdx].relations[u.civ] === 'war' + && !rules.units[u.type].flags.includes('noncombat') + && cheb(u.x, u.y, city.x, city.y) <= 3); + if (defenders.length === 0 && enemyNear && civ.gold > buyCost(rules, city) + 50) { + buyBuild(rules, state, city); + } + return; + } + if (midBuild && city.build.type === 'unit' && def?.flags.includes('spaceship')) return; + if (midBuild && rand(state) < 0.7) return; // usually let builds finish + + // Spaceship race: pour high-shield cities into parts. + if (strategy.phase === 'space') { + const parts = availableUnits(rules, state, civ, city) + .filter((u) => u.flags.includes('spaceship')) + .sort((a, b) => a.cost - b.cost); + if (parts.length) { setBuild(rules, state, city, 'unit', parts[0].id); return; } + } + + // Expansion: settlers from cities that can spare the head. + if (strategy.phase === 'expand' && city.size >= 2) { + const settlerType = civ.known.explosives ? 'engineers' : 'settlers'; + const settlersOut = civUnits(state, civIdx) + .filter((u) => rules.units[u.type].flags.includes('settler')).length; + if (settlersOut < 3 && rand(state) < 0.7) { + setBuild(rules, state, city, 'unit', 'settlers'); + return; + } + if (settlerType === 'engineers' && !civUnits(state, civIdx).some((u) => u.type === 'engineers')) { + setBuild(rules, state, city, 'unit', 'engineers'); + return; + } + } + + // War: attackers with a side of siege. + if (strategy.phase === 'war') { + const units = availableUnits(rules, state, civ, city) + .filter((u) => u.domain === 'land' && !u.flags.includes('noncombat') && !u.flags.includes('spaceship')); + if (units.length) { + const attackers = units.sort((a, b) => b.attack - a.attack); + const pick = rand(state) < 0.3 + ? attackers.find((u) => u.attack >= 6 && u.defense <= 2) ?? attackers[0] + : attackers[0]; + setBuild(rules, state, city, 'unit', pick.id); + return; + } + } + + // Keep a worker corps alive after expansion: roads/irrigation are the whole + // economy, and settlers all get consumed founding cities. + const workers = civUnits(state, civIdx) + .filter((u) => rules.units[u.type].flags.includes('settler')).length; + const wantWorkers = Math.min(3, Math.max(1, Math.ceil(civCities(state, civIdx).length / 2))); + if (workers < wantWorkers && city.size >= 2 && rand(state) < 0.5) { + setBuild(rules, state, city, 'unit', civ.known.explosives ? 'engineers' : 'settlers'); + return; + } + + // Develop: walk the improvement chain; caravans for big trade cities. + const avail = availableBuildings(rules, state, civ, city); + const yields = cityYields(rules, state, city); + if (yields.netTrade >= 8 && city.routes.length < 2 && civ.known.trade && rand(state) < 0.3) { + const cvType = civ.known.thecorporation ? 'freight' : 'caravan'; + setBuild(rules, state, city, 'unit', cvType); + return; + } + for (const id of DEVELOP_CHAIN) { + if (avail.some((b) => b.id === id)) { + // Skip aqueduct/sewer until the city is close to its cap. + if (id === 'aqueduct' && city.size < 6) continue; + if (id === 'sewersystem' && city.size < 10) continue; + setBuild(rules, state, city, 'building', id); + return; + } + } + const fallback = bestDefender(rules, state, civ, city); + if (fallback) setBuild(rules, state, city, 'unit', fallback.id); +} + +function bestDefender(rules, state, civ, city) { + return availableUnits(rules, state, civ, city) + .filter((u) => u.domain === 'land' && !u.flags.includes('noncombat') && !u.flags.includes('spaceship')) + .sort((a, b) => b.defense - a.defense || a.cost - b.cost)[0] ?? null; +} + +// --------------------------------------------------------------------------- +// Units + +function doUnits(rules, state, civIdx, strategy) { + const civ = state.civs[civIdx]; + for (const unit of [...civUnits(state, civIdx)]) { + if (!state.units.includes(unit) || state.over) continue; + if (unit.carriedBy) { handleCargo(rules, state, unit, strategy); continue; } + if (unit.order?.kind === 'work') continue; + const def = rules.units[unit.type]; + if (def.domain === 'project') continue; + let steps = 0; + while (unit.mp > 0 && steps < MAX_UNIT_STEPS && state.units.includes(unit) && !state.over) { + const acted = stepUnit(rules, state, civIdx, unit, strategy); + steps += 1; + if (!acted) break; + } + if (state.units.includes(unit) && !unit.moved && !unit.fortified && def.defense > 0 + && cityAt(state, unit.x, unit.y)) { + unit.fortified = true; + } + } +} + +function stepUnit(rules, state, civIdx, unit, strategy) { + const def = rules.units[unit.type]; + if (def.flags.includes('settler')) return stepSettler(rules, state, civIdx, unit, strategy); + if (def.flags.includes('caravan')) return stepCaravan(rules, state, civIdx, unit); + if (def.flags.includes('ignoreterrain') && def.attack === 0) return stepExplorer(rules, state, civIdx, unit); + if (def.domain === 'sea') return stepShip(rules, state, civIdx, unit, strategy); + if (def.domain === 'air') { unit.mp = 0; return false; } // AI keeps air home (v1) + return stepMilitary(rules, state, civIdx, unit, strategy); +} + +function moveToward(rules, state, unit, tx, ty) { + // Pathfind once per decision, then walk the whole path while movement + // lasts — re-planning every tile step dominates AI turn time otherwise. + const path = findPath(rules, state, unit, tx, ty); + if (!path || !path.length) return false; + let progressed = false; + for (const [nx, ny] of path) { + if (unit.mp <= 0 || !state.units.includes(unit) || state.over) break; + const out = tryMove(rules, state, unit, Math.sign(nx - unit.x), Math.sign(ny - unit.y)); + if (out.result !== 'moved' && out.result !== 'boarded' && out.result !== 'captured') break; + progressed = true; + if (out.result !== 'moved') break; // boarded/captured ends the walk + if (out.hut) break; // hut outcomes can change the situation + } + return progressed; +} + +function stepSettler(rules, state, civIdx, unit, strategy) { + const civ = state.civs[civIdx]; + // Improve home turf when expansion is done (or this is an engineer). + const wantsFound = strategy.phase === 'expand' || civCities(state, civIdx).length === 0; + if (!wantsFound || rules.units[unit.type].flags.includes('engineer')) { + return stepWorker(rules, state, civIdx, unit); + } + const best = bestCitySite(rules, state, civIdx, unit); + if (!best) return stepWorker(rules, state, civIdx, unit); + if (best.x === unit.x && best.y === unit.y && canFoundCity(rules, state, unit.x, unit.y)) { + foundCity(rules, state, unit); + return false; + } + return moveToward(rules, state, unit, best.x, best.y); +} + +export function bestCitySite(rules, state, civIdx, unit) { + const { world } = state; + let best = null; + let bestScore = -Infinity; + const R = 7; + for (let dy = -R; dy <= R; dy += 1) { + for (let dx = -R; dx <= R; dx += 1) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(world, x, y)) continue; + if (!canFoundCity(rules, state, x, y)) continue; + const terr = terrainAt(rules, world, x, y); + if (terr.water || terr.id === 'mountains') continue; + const enemyClose = state.civs.some((c) => c.alive && c.id !== civIdx + && civCities(state, c.id).some((ct) => cheb(ct.x, ct.y, x, y) <= 3)); + if (enemyClose) continue; + const score = siteQuality(rules, world, x, y) - cheb(unit.x, unit.y, x, y) * 2; + if (score > bestScore) { bestScore = score; best = { x, y }; } + } + } + return best; +} + +function stepWorker(rules, state, civIdx, unit) { + // Improve tiles inside own city radii: irrigation > mine > road. + if (!cityNeedsMe(rules, state, civIdx, unit.x, unit.y)) { + const target = nearestWorkTile(rules, state, civIdx, unit); + if (target) return moveToward(rules, state, unit, target[0], target[1]); + unit.mp = 0; + return false; + } + for (const imp of ['irrigation', 'mine', 'road', 'railroad', 'farmland']) { + if (canWork(rules, state, unit, imp)) { + // Don't irrigate what a mine serves better and vice versa: terrain fields + // already gate this; simple priority order is enough for the AI. + startWork(rules, state, unit, imp); + return false; + } + } + const target = nearestWorkTile(rules, state, civIdx, unit); + if (target) return moveToward(rules, state, unit, target[0], target[1]); + unit.mp = 0; + return false; +} + +function cityNeedsMe(rules, state, civIdx, x, y) { + return civCities(state, civIdx).some((c) => cheb(c.x, c.y, x, y) <= 2); +} + +function nearestWorkTile(rules, state, civIdx, unit) { + const { world } = state; + let best = null; + let bestDist = Infinity; + for (const city of civCities(state, civIdx)) { + for (let dy = -2; dy <= 2; dy += 1) { + for (let dx = -2; dx <= 2; dx += 1) { + const x = city.x + dx; + const y = city.y + dy; + if (!inBounds(world, x, y)) continue; + const terr = terrainAt(rules, world, x, y); + if (terr.water) continue; + const bits = world.improvements[tileIndex(world, x, y)]; + const needsSomething = (!(bits & 4) && terr.irrigate !== null) + || (!(bits & 16) && terr.mine !== null) || !(bits & 1); + if (!needsSomething) continue; + const d = cheb(unit.x, unit.y, x, y); + if (d < bestDist) { bestDist = d; best = [x, y]; } + } + } + } + return best; +} + +function stepExplorer(rules, state, civIdx, unit) { + const target = nearestFrontier(state, civIdx, unit); + if (!target) { unit.mp = 0; return false; } + return moveToward(rules, state, unit, target[0], target[1]); +} + +function nearestFrontier(state, civIdx, unit) { + const { world } = state; + const grid = state.explored[civIdx]; + let best = null; + let bestDist = Infinity; + // Sample the map (stride 2) for unexplored tiles adjacent to explored ones. + for (let y = 0; y < world.rows; y += 2) { + for (let x = 0; x < world.cols; x += 2) { + if (grid[tileIndex(world, x, y)]) continue; + const d = cheb(unit.x, unit.y, x, y); + if (d < bestDist) { bestDist = d; best = [x, y]; } + } + } + return best; +} + +function stepCaravan(rules, state, civIdx, unit) { + if (canEstablishRoute(rules, state, unit)) { + establishTradeRoute(rules, state, unit); + return false; + } + const home = cityById(state, unit.homeCity) ?? civCities(state, civIdx)[0]; + if (!home) { unit.mp = 0; return false; } + if (!unit.homeCity) unit.homeCity = home.id; + let best = null; + let bestScore = -Infinity; + for (const city of state.cities) { + if (city.id === home.id) continue; + if (city.civ !== civIdx && state.civs[civIdx].relations[city.civ] !== 'peace' + && state.civs[civIdx].relations[city.civ] !== 'alliance') continue; + const d = cheb(home.x, home.y, city.x, city.y); + if (d < 8) continue; + const score = (city.civ === civIdx ? 0 : 20) + d - cheb(unit.x, unit.y, city.x, city.y) * 0.5; + if (score > bestScore) { bestScore = score; best = city; } + } + if (!best) { unit.mp = 0; return false; } + return moveToward(rules, state, unit, best.x, best.y); +} + +function stepShip(rules, state, civIdx, unit, strategy) { + const def = rules.units[unit.type]; + // Warships hunt enemy ships/coastal targets during war; else patrol home. + if (def.attack > 0 && strategy.atWarWith.length) { + const target = state.units.find((u) => strategy.atWarWith.includes(u.civ) + && rules.units[u.type].domain === 'sea' && cheb(u.x, u.y, unit.x, unit.y) <= 6); + if (target) { + if (cheb(target.x, target.y, unit.x, unit.y) <= 1) { + resolveAttack(rules, state, unit, target.x, target.y); + return false; + } + return moveToward(rules, state, unit, target.x, target.y); + } + } + unit.mp = 0; + return false; +} + +function handleCargo(rules, state, unit, strategy) { + // Disembark next to a hostile city or onto open land when the boat parks. + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + for (const [dx, dy] of dirs) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(state.world, x, y)) continue; + if (terrainAt(rules, state.world, x, y).water) continue; + const city = cityAt(state, x, y); + if (city && strategy.atWarWith.includes(city.civ)) { + disembark(rules, state, unit, dx, dy); + return; + } + } +} + +function stepMilitary(rules, state, civIdx, unit, strategy) { + const civ = state.civs[civIdx]; + const def = rules.units[unit.type]; + const homeCity = cityAt(state, unit.x, unit.y); + + // Keep the garrison staffed before adventuring. + if (homeCity && homeCity.civ === civIdx) { + const garrison = unitsAt(state, unit.x, unit.y).filter((u) => u.civ === civIdx + && rules.units[u.type].domain === 'land' && !rules.units[u.type].flags.includes('noncombat')); + const wanted = strategy.phase === 'war' ? 2 : 1; + const myRank = garrison.sort((a, b) => rules.units[b.type].defense - rules.units[a.type].defense) + .indexOf(unit); + if (myRank >= 0 && myRank < wanted) { + unit.fortified = true; + unit.mp = 0; + return false; + } + } + + // Attack adjacent enemies when odds look good. + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + for (const [dx, dy] of dirs) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(state.world, x, y)) continue; + const enemies = unitsAt(state, x, y).filter((u) => civ.relations[u.civ] === 'war'); + const enemyCity = cityAt(state, x, y); + const cityHostile = enemyCity && civ.relations[enemyCity.civ] === 'war'; + if (!enemies.length && !cityHostile) continue; + if (def.domain === 'land' && terrainAt(rules, state.world, x, y).water) continue; + if (def.attack <= 0) continue; + if (enemies.length) { + const defender = pickDefender(rules, state, x, y, unit); + const A = attackerStrength(rules, state, unit); + const D = defenderStrength(rules, state, defender, unit); + // Massed assault: with friends beside us, grind the walls down even at + // poor odds — defenders keep their damage between waves. + const allies = unitsAt(state, unit.x, unit.y).filter((u) => u.civ === civIdx + && rules.units[u.type].attack >= 2).length; + const gate = cityHostile ? (allies >= 2 ? 0.35 : 0.6) : 0.8; + if (A >= D * gate) { + const out = tryMove(rules, state, unit, dx, dy); + return out.result === 'combat'; + } + } else if (cityHostile) { + const out = tryMove(rules, state, unit, dx, dy); + return out.result === 'captured'; + } + } + + // March on the weakest reachable enemy city. + if (strategy.atWarWith.length && def.attack > 0) { + let target = null; + let bestScore = -Infinity; + for (const city of state.cities) { + if (!strategy.atWarWith.includes(city.civ)) continue; + const defenders = unitsAt(state, city.x, city.y).length; + const d = cheb(unit.x, unit.y, city.x, city.y); + const score = -defenders * 4 - d; + if (score > bestScore) { bestScore = score; target = city; } + } + if (target) return moveToward(rules, state, unit, target.x, target.y); + } + + // Peacetime: a slice of the army scouts outward (finds neighbours, pops + // huts); the rest drifts home and fortifies. + if (unit.id % 3 === 0 && strategy.atWarWith.length === 0) { + const frontier = nearestFrontier(state, civIdx, unit); + if (frontier && moveToward(rules, state, unit, frontier[0], frontier[1])) return true; + } + const own = civCities(state, civIdx); + if (own.length) { + let nearest = own[0]; + for (const c of own) { + if (cheb(unit.x, unit.y, c.x, c.y) < cheb(unit.x, unit.y, nearest.x, nearest.y)) nearest = c; + } + if (cheb(unit.x, unit.y, nearest.x, nearest.y) > 1) { + return moveToward(rules, state, unit, nearest.x, nearest.y); + } + } + unit.fortified = true; + unit.mp = 0; + return false; +} diff --git a/src/games/civilization/CivilizationCityScreen.js b/src/games/civilization/CivilizationCityScreen.js new file mode 100644 index 0000000..437362a --- /dev/null +++ b/src/games/civilization/CivilizationCityScreen.js @@ -0,0 +1,162 @@ +// Civilization — city detail modal: growth, yields, worked-tile emphasis, +// build queue with buy, buildings, supported units and trade routes. + +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import * as Logic from './CivilizationLogic.js'; + +const FONT = '"Julius Sans One"'; + +export function openCityScreen(scene, rules, state, city, onClose) { + const root = scene.add.container(0, 0).setDepth(65); + const dim = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6) + .setInteractive(); + const W = 1500; + const H = 860; + const px = GAME_WIDTH / 2; + const py = GAME_HEIGHT / 2; + const panel = scene.add.rectangle(px, py, W, H, COLORS.panel) + .setStrokeStyle(3, COLORS.accent); + root.add([dim, panel]); + + let dynamic = scene.add.container(0, 0); + root.add(dynamic); + + const close = () => { root.destroy(true); onClose(); }; + const closeBtn = new Button(scene, px + W / 2 - 80, py - H / 2 + 40, '✕', close, + { width: 60, height: 44, fontSize: 22, variant: 'ghost' }); + root.add(closeBtn); + + function redraw() { + dynamic.destroy(true); + dynamic = scene.add.container(0, 0); + root.add(dynamic); + const civ = state.civs[city.civ]; + Logic.autoAssignTiles(rules, state, city); + const y = Logic.cityYields(rules, state, city); + const left = px - W / 2 + 40; + const top = py - H / 2 + 30; + + dynamic.add(scene.add.text(px, top + 10, `${city.name} — Population ${city.size}`, { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.accentHex, + }).setOrigin(0.5, 0)); + + // Growth bar. + const boxSize = (city.size + 1) * Logic.FOODBOX_PER_SIZE; + const gw = 400; + dynamic.add(scene.add.text(left, top + 70, 'Growth', { fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex })); + dynamic.add(scene.add.rectangle(left + 90, top + 80, gw, 20, 0x0a0d12).setOrigin(0, 0.5) + .setStrokeStyle(1, COLORS.muted)); + dynamic.add(scene.add.rectangle(left + 90, top + 80, + gw * Math.max(0, Math.min(1, city.foodBox / boxSize)), 16, 0x4a9e44).setOrigin(0, 0.5)); + dynamic.add(scene.add.text(left + 100 + gw, top + 70, `${city.foodBox}/${boxSize}`, { + fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, + })); + + // Yields breakdown. + const lines = [ + `Food ${y.food} (need ${y.foodNeed}) surplus ${y.foodSurplus >= 0 ? '+' : ''}${y.foodSurplus}`, + `Shields ${y.shield}${y.supportShields ? ` (support −${y.supportShields})` : ''}`, + `Trade ${y.trade} corruption −${y.corruption}${y.routeTrade ? ` routes +${y.routeTrade}` : ''}`, + `Gold +${y.gold} · Science +${y.science} · Upkeep −${y.upkeep}`, + ]; + dynamic.add(scene.add.text(left, top + 112, lines.join('\n'), { + fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, lineSpacing: 8, + })); + + // Emphasis toggle. + dynamic.add(scene.add.text(left, top + 260, 'Worker emphasis:', { + fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex, + })); + ['balanced', 'food', 'production', 'trade'].forEach((emp, i) => { + const bx = left + 200 + i * 150; + const rect = scene.add.rectangle(bx, top + 270, 138, 40, COLORS.panel) + .setStrokeStyle(2, city.emphasis === emp ? COLORS.gold : COLORS.muted); + const txt = scene.add.text(bx, top + 270, emp.toUpperCase(), { + fontFamily: FONT, fontSize: '16px', + color: city.emphasis === emp ? COLORS.goldHex : COLORS.textHex, + }).setOrigin(0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { city.emphasis = emp; redraw(); }); + dynamic.add(rect); + dynamic.add(txt); + }); + + // Buildings list. + dynamic.add(scene.add.text(left, top + 320, 'City Improvements', { + fontFamily: FONT, fontSize: '21px', color: COLORS.accentHex, + })); + const built = Object.keys(city.buildings).map((id) => rules.buildings[id]?.name ?? id); + dynamic.add(scene.add.text(left, top + 355, built.length ? built.join(', ') : '(none yet)', { + fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, + wordWrap: { width: 640 }, lineSpacing: 6, + })); + + // Supported units + routes. + const supported = state.units.filter((u) => u.homeCity === city.id) + .map((u) => rules.units[u.type].name); + dynamic.add(scene.add.text(left, top + 500, `Supported units (${supported.length})`, { + fontFamily: FONT, fontSize: '21px', color: COLORS.accentHex, + })); + dynamic.add(scene.add.text(left, top + 535, supported.length ? supported.join(', ') : '(none)', { + fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, + wordWrap: { width: 640 }, lineSpacing: 5, + })); + const routes = city.routes.map((r) => { + const other = Logic.cityById(state, r.cityId); + return other ? `${other.name} (+${r.amount})` : `(lost city +${r.amount})`; + }); + dynamic.add(scene.add.text(left, top + 660, `Trade routes: ${routes.length ? routes.join(', ') : '(none)'}`, { + fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, wordWrap: { width: 640 }, + })); + + // --- Build picker (right column) + const rx = px + 60; + const cost = Logic.buildCost(rules, city); + const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id]; + const turns = y.shield > 0 ? Math.ceil((cost - city.shieldBox) / y.shield) : '∞'; + dynamic.add(scene.add.text(rx, top + 64, + `Building: ${cur.name} ${city.shieldBox}/${cost} shields (${turns} turns)`, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, + })); + const buyPrice = Logic.buyCost(rules, city); + const buyBtn = new Button(scene, rx + 560, top + 120, `BUY (${buyPrice}g)`, () => { + if (Logic.buyBuild(rules, state, city)) redraw(); + }, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' }); + dynamic.add(buyBtn); + dynamic.add(scene.add.text(rx, top + 108, `Treasury: ${civ.gold} gold`, { + fontFamily: FONT, fontSize: '19px', color: COLORS.goldHex, + })); + + // Choices: units then buildings, two columns of rows. + const unitChoices = Logic.availableUnits(rules, state, civ, city) + .map((u) => ({ type: 'unit', id: u.id, label: `${u.name} (${u.cost})`, tip: `A${u.attack} D${u.defense} M${u.move}` })); + const bldChoices = Logic.availableBuildings(rules, state, civ, city) + .map((b) => ({ type: 'building', id: b.id, label: `${b.name} (${b.cost})`, tip: `upkeep ${b.upkeep}` })); + const choices = [...unitChoices, ...bldChoices]; + const colW = 330; + const rowH = 36; + const perCol = 17; + choices.slice(0, perCol * 2).forEach((ch, i) => { + const colX = rx + Math.floor(i / perCol) * (colW + 20); + const rowY = top + 170 + (i % perCol) * rowH; + const active = city.build.type === ch.type && city.build.id === ch.id; + const rect = scene.add.rectangle(colX + colW / 2, rowY, colW, rowH - 4, + active ? 0x3a3222 : 0x181510) + .setStrokeStyle(1, active ? COLORS.gold : COLORS.muted, active ? 1 : 0.5); + const txt = scene.add.text(colX + 10, rowY, ch.label, { + fontFamily: FONT, fontSize: '16px', + color: ch.type === 'unit' ? '#cfe3ff' : '#ffe9bd', + }).setOrigin(0, 0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { + Logic.setBuild(rules, state, city, ch.type, ch.id); + redraw(); + }); + dynamic.add(rect); + dynamic.add(txt); + }); + } + + redraw(); +} diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js new file mode 100644 index 0000000..4e08897 --- /dev/null +++ b/src/games/civilization/CivilizationGame.js @@ -0,0 +1,825 @@ +// Civilization (Civ II-lite) — main Phaser scene. +// +// Phases: setup -> playing -> over. Setup picks a leader character (the +// standard opponents double as civilizations), world size, rival count and +// difficulty. The engine (CivilizationLogic) is headless; this scene drives it +// and renders through CivilizationMapView. AI rivals play via CivilizationAI. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { compileRules, turnToYear, formatYear } from './CivilizationRules.js'; +import * as Logic from './CivilizationLogic.js'; +import { runAITurn, respondToProposal } from './CivilizationAI.js'; +import { CivilizationMapView } from './CivilizationMapView.js'; +import { openCityScreen } from './CivilizationCityScreen.js'; +import { + openTechScreen, openDiplomacyScreen, openSpaceshipScreen, showVictoryOverlay, +} from './CivilizationScreens.js'; + +const FONT = '"Julius Sans One"'; +const SAVE_KEY = 'civilization-save'; +const D = { hud: 30, modal: 60, toast: 80 }; + +export default class CivilizationGame extends Phaser.Scene { + constructor() { super('CivilizationGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'civilization', name: 'Civilization' }; + this.state = null; + this.view = null; + this.phase = 'setup'; + this.modalOpen = false; + this.busy = false; + } + + create() { + this.rules = compileRules(this.cache.json.get('civilization-rules')); + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0a0d12) + .setDepth(-10); + + this.hudRoot = this.add.container(0, 0).setDepth(D.hud); + this.modalRoot = this.add.container(0, 0).setDepth(D.modal); + this.toastRoot = this.add.container(0, 0).setDepth(D.toast); + this.setupRoot = null; + this.toasts = []; + + this.keys = this.input.keyboard.addKeys('UP,DOWN,LEFT,RIGHT,Q,E,Z,C,W,A,S,D,B,F,G,I,M,N,O,R,T,SPACE,ENTER'); + this.escHandler = () => this.onEscape(); + this.input.keyboard.on('keydown-ESC', this.escHandler); + this.events.once('shutdown', () => { + this.input.keyboard.off('keydown-ESC', this.escHandler); + this.view?.destroy(); + }); + + try { + const music = this.cache.json.get('music'); + if (music?.tracks) this.music = new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.opponentsData = []; + fetch('data/opponents.json') + .then((r) => r.json()) + .then((json) => { this.opponentsData = json.opponents ?? []; this.showSetup(); }) + .catch(() => { this.opponentsData = []; this.showSetup(); }); + } + + onEscape() { + if (this.modalOpen) return; // modals close themselves + if (this.phase === 'playing') this.openMenu(); + else this.scene.start('GameMenu'); + } + + // =========================================================================== + // Setup phase + + showSetup() { + this.phase = 'setup'; + this.setupRoot?.destroy(true); + this.setupRoot = this.add.container(0, 0).setDepth(10); + const root = this.setupRoot; + const cx = GAME_WIDTH / 2; + + root.add(this.add.text(cx, 52, 'CIVILIZATION', { + fontFamily: 'Righteous', fontSize: '54px', color: COLORS.accentHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, 100, 'Build an empire to stand the test of time', { + fontFamily: FONT, fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + + // --- leader pick grid + root.add(this.add.text(cx, 150, 'CHOOSE YOUR LEADER', { + fontFamily: FONT, fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5)); + + const leaders = this.opponentsData.length ? this.opponentsData + : Array.from({ length: 8 }, (_, i) => ({ id: `leader${i}`, name: `Leader ${i + 1}`, spriteIndex: 0 })); + this.setupLeaders = leaders; + this.pickedLeader = this.pickedLeader ?? 0; + + const perRow = 15; + const cell = 96; + const gridW = Math.min(leaders.length, perRow) * cell; + const gx = cx - gridW / 2 + cell / 2; + const gy = 214; + this.leaderMarks = []; + leaders.forEach((op, i) => { + const x = gx + (i % perRow) * cell; + const y = gy + Math.floor(i / perRow) * (cell + 18); + const ring = this.add.circle(x, y, 40, COLORS.panel) + .setStrokeStyle(3, i === this.pickedLeader ? COLORS.gold : COLORS.muted); + let face; + if (this.textures.exists('opponents')) { + face = this.add.image(x, y, 'opponents', op.spriteIndex ?? 0).setDisplaySize(72, 72); + const maskShape = this.make.graphics({ add: false }); + maskShape.fillStyle(0xffffff); + maskShape.fillCircle(x, y, 36); + face.setMask(maskShape.createGeometryMask()); + } else { + face = this.add.text(x, y, op.name[0], { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex, + }).setOrigin(0.5); + } + const label = this.add.text(x, y + 52, op.name, { + fontFamily: FONT, fontSize: '14px', + color: i === this.pickedLeader ? COLORS.goldHex : COLORS.mutedHex, + }).setOrigin(0.5); + ring.setInteractive({ useHandCursor: true }); + ring.on('pointerdown', () => { + this.pickedLeader = i; + this.leaderMarks.forEach((m, j) => { + m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted); + m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex); + }); + }); + this.leaderMarks.push({ ring, label }); + root.add([ring, face, label]); + }); + + const rowsUsed = Math.ceil(leaders.length / perRow); + let oy = gy + rowsUsed * (cell + 18) + 40; + + // --- option rows + this.setupOpts = this.setupOpts ?? { sizeId: 'medium', opponents: 3, difficultyId: 'prince' }; + const mkRow = (label, options, current, onPick) => { + root.add(this.add.text(cx - 560, oy, label, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, + }).setOrigin(0, 0.5)); + const marks = []; + const startX = cx - 260; + options.forEach((opt, i) => { + const w = Math.max(90, opt.label.length * 13 + 30); + const x = startX + options.slice(0, i).reduce((a, o) => a + Math.max(90, o.label.length * 13 + 30) + 14, 0); + const rect = this.add.rectangle(x + w / 2, oy, w, 44, COLORS.panel) + .setStrokeStyle(2, opt.value === current ? COLORS.gold : COLORS.muted); + const txt = this.add.text(x + w / 2, oy, opt.label, { + fontFamily: FONT, fontSize: '19px', + color: opt.value === current ? COLORS.goldHex : COLORS.textHex, + }).setOrigin(0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { + onPick(opt.value); + marks.forEach((m, j) => { + m.rect.setStrokeStyle(2, options[j].value === opt.value ? COLORS.gold : COLORS.muted); + m.txt.setColor(options[j].value === opt.value ? COLORS.goldHex : COLORS.textHex); + }); + }); + marks.push({ rect, txt }); + root.add([rect, txt]); + }); + oy += 62; + }; + + mkRow('World Size', + this.rules.worldSizeList.map((w) => ({ label: `${w.name} (${w.cols}×${w.rows})`, value: w.id })), + this.setupOpts.sizeId, (v) => { this.setupOpts.sizeId = v; }); + mkRow('Rival Civilizations', + [2, 3, 4, 5, 6, 7].map((n) => ({ label: `${n}`, value: n })), + this.setupOpts.opponents, (v) => { this.setupOpts.opponents = v; }); + mkRow('Difficulty', + this.rules.difficultyList.map((d) => ({ label: d.name, value: d.id })), + this.setupOpts.difficultyId, (v) => { this.setupOpts.difficultyId = v; }); + + oy += 8; + const startBtn = new Button(this, cx - (this.hasSave() ? 160 : 0), oy + 20, 'BEGIN', () => { + this.startNewGame(); + }, { width: 280, height: 64, fontSize: 30 }); + root.add(startBtn); + if (this.hasSave()) { + const resumeBtn = new Button(this, cx + 160, oy + 20, 'RESUME GAME', () => { + this.resumeGame(); + }, { width: 280, height: 64, fontSize: 26, variant: 'ghost' }); + root.add(resumeBtn); + } + } + + hasSave() { + try { return !!localStorage.getItem(SAVE_KEY); } catch (_) { return false; } + } + + startNewGame() { + const { sizeId, opponents, difficultyId } = this.setupOpts; + const pool = this.setupLeaders; + const me = pool[this.pickedLeader]; + const rivals = pool.filter((_, i) => i !== this.pickedLeader); + // Shuffle rivals with Math.random (game determinism starts at engine seed). + for (let i = rivals.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [rivals[i], rivals[j]] = [rivals[j], rivals[i]]; + } + const leaders = [me, ...rivals.slice(0, opponents)] + .map((op) => ({ id: op.id, name: op.name })); + const seed = (Date.now() % 1000000) + 1; + try { + this.state = Logic.createGame(this.rules, { + sizeId, seed, difficultyId, leaders, humanIndex: 0, + }); + } catch (err) { + this.toast(`World generation failed — try again (${err.message})`); + return; + } + this.leaderPool = leaders; + this.beginPlaying(); + } + + resumeGame() { + try { + const state = Logic.deserialize(localStorage.getItem(SAVE_KEY)); + if (!state) { this.toast('Saved game is from an old version'); localStorage.removeItem(SAVE_KEY); return; } + this.state = state; + this.beginPlaying(true); + } catch (_) { + this.toast('Could not load the save'); + } + } + + saveGame() { + try { localStorage.setItem(SAVE_KEY, Logic.serialize(this.state)); } catch (_) { /* full */ } + } + clearSave() { + try { localStorage.removeItem(SAVE_KEY); } catch (_) { /* noop */ } + } + + // =========================================================================== + // Playing phase + + beginPlaying(resumed = false) { + this.phase = 'playing'; + this.setupRoot?.destroy(true); + this.setupRoot = null; + this.view = new CivilizationMapView(this, this.rules, this.state, { + onCityClick: (city) => this.onCityClick(city), + }); + this.view.buildMinimap(16, GAME_HEIGHT - 260, 300); + this.buildHud(); + this.bindPointer(); + + const human = this.state.civs[this.state.humanIndex]; + const capital = Logic.civCities(this.state, human.id)[0]; + const firstUnit = Logic.civUnits(this.state, human.id)[0]; + const focus = capital ?? firstUnit; + if (focus) this.view.centerOn(focus.x, focus.y); + + if (!resumed || this.state.current !== this.state.humanIndex) { + // Fresh games (and saves mid-AI-round) run up to the human turn. + this.runToHumanTurn(); + } else { + this.startHumanTurn(false); + } + } + + buildHud() { + this.hudRoot.removeAll(true); + const bar = this.add.rectangle(GAME_WIDTH / 2, 28, GAME_WIDTH, 56, COLORS.panel, 0.95) + .setStrokeStyle(1, COLORS.accent, 0.6); + this.hudRoot.add(bar); + this.hudText = this.add.text(20, 28, '', { + fontFamily: FONT, fontSize: '21px', color: COLORS.textHex, + }).setOrigin(0, 0.5); + this.hudRoot.add(this.hudText); + + const mkBtn = (x, label, fn, w = 150) => { + const b = new Button(this, x, 28, label, fn, { width: w, height: 42, fontSize: 18 }); + this.hudRoot.add(b); + return b; + }; + mkBtn(GAME_WIDTH - 760, 'TECH', () => this.openTech()); + mkBtn(GAME_WIDTH - 600, 'DIPLOMACY', () => this.openDiplomacy(), 170); + mkBtn(GAME_WIDTH - 430, 'SPACESHIP', () => this.openSpaceship(), 170); + mkBtn(GAME_WIDTH - 270, 'MENU', () => this.openMenu(), 120); + this.endTurnBtn = new Button(this, GAME_WIDTH - 110, 28, 'END TURN', () => this.onEndTurn(), + { width: 180, height: 42, fontSize: 18, bg: COLORS.gold, textColor: COLORS.textDarkHex }); + this.hudRoot.add(this.endTurnBtn); + + // Right-side unit/terrain panel. + this.unitPanel = this.add.container(GAME_WIDTH - 300, 80); + const panelBg = this.add.rectangle(0, 0, 284, 240, COLORS.panel, 0.92) + .setOrigin(0, 0).setStrokeStyle(2, COLORS.accent, 0.7); + this.unitPanelText = this.add.text(14, 14, '', { + fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, + wordWrap: { width: 256 }, lineSpacing: 5, + }); + this.unitPanel.add([panelBg, this.unitPanelText]); + this.hudRoot.add(this.unitPanel); + + this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 24, + 'Arrows/QEZC move · B found city · R road · I irrigate · M mine · O fortress · F fortify · Space skip · N next · Enter end turn', + { fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5); + this.hudRoot.add(this.hintText); + this.refreshHud(); + } + + refreshHud() { + if (!this.state || this.phase !== 'playing') return; + const civ = this.state.civs[this.state.humanIndex]; + const year = formatYear(turnToYear(this.state.turn, this.rules.yearCurve)); + const gov = this.rules.governments[civ.government].name; + const research = civ.researching + ? `${this.rules.techs[civ.researching].name} ${civ.beakers}/${Logic.currentResearchCost(this.rules, this.state, civ)}` + : '— pick research —'; + this.hudText.setText(`${year} Gold: ${civ.gold} Science: ${research} ${gov}`); + this.refreshUnitPanel(); + } + + refreshUnitPanel() { + const unit = this.selectedUnit(); + if (!unit) { + this.unitPanelText.setText(this.busy ? 'Rivals are moving…' : 'No unit selected.\nClick a unit or press N.'); + return; + } + const def = this.rules.units[unit.type]; + const terr = Logic.terrainAt(this.rules, this.state.world, unit.x, unit.y); + const idx = Logic.tileIndex(this.state.world, unit.x, unit.y); + const spec = this.state.world.special[idx] >= 0 + ? this.rules.specialList[this.state.world.special[idx]].name : null; + const lines = [ + `${def.name}${unit.vet ? ' (V)' : ''}`, + `A${def.attack} D${def.defense} HP ${unit.hp}/${def.hp}`, + `Moves: ${(unit.mp / 3).toFixed(unit.mp % 3 ? 1 : 0)}`, + `Terrain: ${terr.name}${spec ? ` (${spec})` : ''}`, + ]; + if (unit.order?.kind === 'work') { + lines.push(`Working: ${this.rules.improvements[unit.order.imp].name}`); + } + if (unit.fortified) lines.push('Fortified'); + this.unitPanelText.setText(lines.join('\n')); + } + + // --------------------------------------------------------------------------- + // Pointer + keyboard input + + bindPointer() { + let dragStart = null; + let dragged = false; + this.input.on('pointerdown', (pointer) => { + if (this.modalOpen || this.phase !== 'playing') return; + dragStart = { x: pointer.x, y: pointer.y, rx: this.view.root.x, ry: this.view.root.y }; + dragged = false; + }); + this.input.on('pointermove', (pointer) => { + if (!pointer.isDown || !dragStart || this.modalOpen) return; + const dx = pointer.x - dragStart.x; + const dy = pointer.y - dragStart.y; + if (Math.abs(dx) + Math.abs(dy) > 8) dragged = true; + if (dragged) { + this.view.root.x = dragStart.rx + dx; + this.view.root.y = dragStart.ry + dy; + this.view.clampPan(); + } + }); + this.input.on('pointerup', (pointer) => { + const wasDrag = dragged; + dragStart = null; + dragged = false; + if (wasDrag || this.modalOpen || this.phase !== 'playing' || this.busy) return; + if (pointer.y < 56 || pointer.y > GAME_HEIGHT - 44) return; // HUD bands + // Clicks that landed on any interactive object (buttons, minimap, city + // banners) are theirs, not the map's. + if (this.input.hitTestPointer(pointer).length > 0) return; + const tile = this.view.screenToTile(pointer.x, pointer.y); + if (tile) this.onTileClick(tile[0], tile[1]); + }); + this.input.on('wheel', (pointer, objs, dx, dy) => { + if (this.modalOpen || this.phase !== 'playing') return; + this.view.zoomBy(dy > 0 ? -1 : 1); + }); + } + + onTileClick(c, r) { + const state = this.state; + const human = state.humanIndex; + const myUnits = Logic.unitsAt(state, c, r).filter((u) => u.civ === human); + const sel = this.selectedUnit(); + + if (myUnits.length && (!sel || sel.x !== c || sel.y !== r)) { + this.selectUnit(myUnits[0]); + return; + } + if (myUnits.length && sel && sel.x === c && sel.y === r) { + // Cycle the stack. + const i = myUnits.indexOf(sel); + this.selectUnit(myUnits[(i + 1) % myUnits.length]); + return; + } + const city = Logic.cityAt(state, c, r); + if (city && city.civ === human && !myUnits.length) { + this.onCityClick(city); + return; + } + // Move the selected unit toward the clicked tile. + if (sel && state.current === human) { + if (Logic.cheb(sel.x, sel.y, c, r) === 1) { + this.tryStep(sel, c - sel.x, r - sel.y); + return; + } + const path = Logic.findPath(this.rules, state, sel, c, r); + if (!path) { this.toast('No route there'); return; } + this.view.showPath(path); + this.walkPath(sel, path); + } + } + + onCityClick(city) { + if (city.civ !== this.state.humanIndex || this.modalOpen) return; + this.modalOpen = true; + openCityScreen(this, this.rules, this.state, city, () => { + this.modalOpen = false; + this.view.refresh(); + this.refreshHud(); + }); + } + + update() { + if (this.phase !== 'playing' || this.modalOpen || this.busy) return; + // Edge-of-keyboard panning. + const k = this.keys; + const pan = 14; + if (k.W.isDown) this.view.panBy(0, pan); + if (k.S.isDown && !k.S.shiftKey) this.view.panBy(0, -pan); + if (k.A.isDown) this.view.panBy(pan, 0); + if (k.D.isDown) this.view.panBy(-pan, 0); + + if (this.state?.current !== this.state?.humanIndex) return; + const sel = this.selectedUnit(); + const just = (key) => Phaser.Input.Keyboard.JustDown(key); + + if (just(k.N)) this.selectNextUnit(); + if (just(k.ENTER)) this.onEndTurn(); + if (!sel) return; + + if (just(k.UP)) this.tryStep(sel, 0, -1); + else if (just(k.DOWN)) this.tryStep(sel, 0, 1); + else if (just(k.LEFT)) this.tryStep(sel, -1, 0); + else if (just(k.RIGHT)) this.tryStep(sel, 1, 0); + else if (just(k.Q)) this.tryStep(sel, -1, -1); + else if (just(k.E)) this.tryStep(sel, 1, -1); + else if (just(k.Z)) this.tryStep(sel, -1, 1); + else if (just(k.C)) this.tryStep(sel, 1, 1); + else if (just(k.SPACE)) { sel.mp = 0; this.selectNextUnit(); } + else if (just(k.F)) { sel.fortified = true; sel.mp = 0; this.afterAction(); this.selectNextUnit(); } + else if (just(k.B)) this.tryFound(sel); + else if (just(k.R)) this.tryWork(sel, this.canRail(sel) ? 'railroad' : 'road'); + else if (just(k.I)) this.tryWork(sel, 'irrigation'); + else if (just(k.M)) this.tryWork(sel, 'mine'); + else if (just(k.O)) this.tryWork(sel, 'fortress'); + else if (just(k.T)) this.tryWork(sel, 'transform'); + else if (just(k.G)) this.tryCaravan(sel); + } + + canRail(unit) { + const bits = this.state.world.improvements[Logic.tileIndex(this.state.world, unit.x, unit.y)]; + return (bits & Logic.IMP.ROAD) && this.state.civs[unit.civ].known.railroad; + } + + selectedUnit() { + if (!this.state || this.selectedUnitId == null) return null; + const u = Logic.unitById(this.state, this.selectedUnitId); + if (!u || u.civ !== this.state.humanIndex) return null; + return u; + } + + selectUnit(unit) { + this.selectedUnitId = unit?.id ?? null; + this.view.selectedUnitId = this.selectedUnitId; + this.view.showPath(null); + if (unit) this.view.centerOnIfOffscreen?.(unit.x, unit.y); + this.view.refresh(); + this.refreshUnitPanel(); + } + + selectNextUnit() { + const units = Logic.civUnits(this.state, this.state.humanIndex) + .filter((u) => u.mp > 0 && !u.fortified && !u.sentry && !u.order && !u.carriedBy + && this.rules.units[u.type].domain !== 'project'); + if (!units.length) { this.selectUnit(null); return; } + const curIdx = units.findIndex((u) => u.id === this.selectedUnitId); + const next = units[(curIdx + 1) % units.length]; + this.selectUnit(next); + this.view.centerOn(next.x, next.y); + } + + tryStep(unit, dx, dy) { + if (this.state.current !== this.state.humanIndex) return; + const out = unit.carriedBy + ? Logic.disembark(this.rules, this.state, unit, dx, dy) + : Logic.tryMove(this.rules, this.state, unit, dx, dy); + if (out.result === 'blocked' && out.needsWar !== undefined) { + this.confirmWar(out.needsWar, () => { + Logic.declareWar(this.rules, this.state, this.state.humanIndex, out.needsWar); + this.tryStep(unit, dx, dy); + }); + return; + } + if (out.result === 'invalid') return; + if (out.hut) this.toastHut(out.hut); + this.afterAction(); + if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit(); + if (!this.state.units.includes(unit)) this.selectNextUnit(); + } + + walkPath(unit, path) { + // Step along the found path until movement runs out or something happens. + let i = 0; + const step = () => { + if (i >= path.length || unit.mp <= 0 || !this.state.units.includes(unit) || this.state.over) { + this.view.showPath(null); + this.afterAction(); + if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit(); + return; + } + const [nx, ny] = path[i]; + i += 1; + const out = Logic.tryMove(this.rules, this.state, unit, + Math.sign(nx - unit.x), Math.sign(ny - unit.y)); + if (out.result === 'invalid' || out.result === 'blocked') { + this.view.showPath(null); + this.afterAction(); + return; + } + if (out.hut) this.toastHut(out.hut); + this.view.refresh(); + this.time.delayedCall(60, step); + }; + step(); + } + + tryFound(unit) { + const def = this.rules.units[unit.type]; + if (!def.flags.includes('settler')) return; + if (!Logic.canFoundCity(this.rules, this.state, unit.x, unit.y)) { + this.toast('Cannot found a city here'); + return; + } + const city = Logic.foundCity(this.rules, this.state, unit); + if (city) { + this.toast(`${city.name} founded!`); + this.view.repaintTileAndNeighbors(city.x, city.y); + this.afterAction(); + this.selectNextUnit(); + } + } + + tryWork(unit, impId) { + if (Logic.startWork(this.rules, this.state, unit, impId)) { + this.toast(`${this.rules.units[unit.type].name}: building ${this.rules.improvements[impId].name}`); + this.afterAction(); + this.selectNextUnit(); + } else { + this.toast(`Cannot build ${this.rules.improvements[impId]?.name ?? impId} here`); + } + } + + tryCaravan(unit) { + const pair = Logic.canEstablishRoute(this.rules, this.state, unit); + if (!pair) { this.toast('Caravans need a city 8+ tiles from home'); return; } + const out = Logic.establishTradeRoute(this.rules, this.state, unit); + if (out) { + this.toast(`Trade route: ${out.from.name} ↔ ${out.to.name} (+${out.bonus} gold & beakers)`); + this.afterAction(); + this.selectNextUnit(); + } + } + + afterAction() { + // Repaint tiles that may have changed (work orders complete on turn start, + // but roads from engineers etc. show up next refresh — cheap full check + // is unnecessary; unit/city layer + HUD is enough here). + this.view.refresh(); + this.refreshHud(); + if (this.state.over) this.onGameOver(); + } + + // --------------------------------------------------------------------------- + // Turn cycle + + startHumanTurn(process = true) { + const human = this.state.humanIndex; + if (process) Logic.beginCivTurn(this.rules, this.state, human); + this.repaintFromEvents(); + this.busy = false; + this.endTurnBtn?.setAlpha(1); + + const civ = this.state.civs[human]; + if (!civ.alive) { this.onGameOver(); return; } + + // Research prompt. + if (!civ.researching && Logic.availableTechs(this.rules, civ).length) { + this.openTech(); + } + this.presentAIProposals(); + this.announceEvents(); + this.view.refresh(); + this.refreshHud(); + this.selectNextUnit(); + } + + onEndTurn() { + if (this.busy || this.modalOpen || this.phase !== 'playing') return; + if (this.state.current !== this.state.humanIndex) return; + Logic.endCivTurn(this.rules, this.state, this.state.humanIndex); + this.saveGame(); + this.runToHumanTurn(); + } + + runToHumanTurn() { + this.busy = true; + this.endTurnBtn?.setAlpha(0.4); + this.refreshUnitPanel(); + const stepCiv = () => { + if (this.phase !== 'playing') return; + if (this.state.over) { this.onGameOver(); return; } + if (!this.state.civs[this.state.humanIndex].alive) { this.onGameOver(); return; } + const cur = this.state.current; + if (cur === this.state.humanIndex) { + this.startHumanTurn(true); + return; + } + Logic.beginCivTurn(this.rules, this.state, cur); + runAITurn(this.rules, this.state, cur); + Logic.endCivTurn(this.rules, this.state, cur); + this.view.refresh(); + this.refreshHud(); + this.time.delayedCall(90, stepCiv); + }; + stepCiv(); + } + + // --------------------------------------------------------------------------- + // Events, proposals, toasts + + // Terrain changes (work orders, founded cities' roads) arrive as engine + // events; repaint just those tiles instead of rebaking the world. + repaintFromEvents() { + for (const e of this.state.events) { + if (e.painted) continue; + e.painted = true; + if (e.type === 'workDone') { + this.view.repaintTileAndNeighbors(e.x, e.y); + } else if (e.type === 'cityFounded' || e.type === 'cityDestroyed') { + const city = Logic.cityById(this.state, e.cityId); + if (city) this.view.repaintTileAndNeighbors(city.x, city.y); + } + } + } + + announceEvents() { + const human = this.state.humanIndex; + for (const e of this.state.events) { + if (e.announced) continue; + e.announced = true; + if (e.type === 'war' && e.b === human) { + this.toast(`${this.state.civs[e.a].name} declares WAR on you!`); + } else if (e.type === 'techDone' && e.civ === human) { + this.toast(`Research complete: ${this.rules.techs[e.tech].name}`); + } else if (e.type === 'cityCaptured' && (e.from === human || e.to === human)) { + this.toast(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`); + } else if (e.type === 'civEliminated') { + this.toast(`${this.state.civs[e.civ].name} has been destroyed`); + } else if (e.type === 'spaceshipLaunched') { + this.toast(`${this.state.civs[e.civ].name} launched a spaceship!`); + } else if (e.type === 'contact' && (e.a === human || e.b === human)) { + const other = e.a === human ? e.b : e.a; + this.toast(`You have made contact with ${this.state.civs[other].name}`); + } + } + } + + presentAIProposals() { + const human = this.state.humanIndex; + const proposals = this.state.events.filter((e) => e.type === 'aiProposal' && e.to === human); + this.state.events = this.state.events.filter((e) => !(e.type === 'aiProposal' && e.to === human)); + const next = () => { + const p = proposals.shift(); + if (!p) return; + if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; } + const from = this.state.civs[p.from]; + this.confirmDialog( + `${from.name} proposes a ${p.kind === 'ceasefire' ? 'cease-fire' : p.kind}. Accept?`, + () => { Logic.applyTreaty(this.state, p.from, human, p.kind); this.refreshHud(); next(); }, + () => next(), + ); + }; + next(); + } + + toastHut(hut) { + const msgs = { + gold: `You found ${hut.gold} gold in the hut!`, + tech: hut.tech ? `The tribe teaches you ${this.rules.techs[hut.tech].name}!` : 'A gift!', + unit: `A band of ${hut.unit} joins you!`, + ambushWon: 'Hostile tribe! Your unit fought them off.', + ambushLost: 'Hostile tribe! Your unit was lost!', + }; + this.toast(msgs[hut.outcome] ?? 'An empty hut.'); + } + + toast(msg) { + const y = 90 + this.toasts.length * 40; + const t = this.add.text(GAME_WIDTH / 2, y, msg, { + fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, + backgroundColor: '#1e1a12ee', padding: { x: 16, y: 7 }, + }).setOrigin(0.5).setDepth(D.toast); + this.toastRoot.add(t); + this.toasts.push(t); + this.tweens.add({ + targets: t, alpha: 0, delay: 2600, duration: 500, + onComplete: () => { + this.toasts = this.toasts.filter((x) => x !== t); + t.destroy(); + }, + }); + } + + confirmWar(civIdx, onYes) { + const name = this.state.civs[civIdx].name; + this.confirmDialog(`Attack ${name}? This means WAR!`, onYes, () => {}); + } + + confirmDialog(message, onYes, onNo) { + this.modalOpen = true; + const root = this.add.container(0, 0).setDepth(D.modal); + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) + .setInteractive(); + const panel = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 640, 220, COLORS.panel) + .setStrokeStyle(2, COLORS.accent); + const txt = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40, message, { + fontFamily: FONT, fontSize: '24px', color: COLORS.textHex, + wordWrap: { width: 580 }, align: 'center', + }).setOrigin(0.5); + const close = (fn) => () => { root.destroy(true); this.modalOpen = false; fn?.(); }; + const yes = new Button(this, GAME_WIDTH / 2 - 110, GAME_HEIGHT / 2 + 50, 'YES', close(onYes), + { width: 180, height: 52 }); + const no = new Button(this, GAME_WIDTH / 2 + 110, GAME_HEIGHT / 2 + 50, 'NO', close(onNo), + { width: 180, height: 52, variant: 'ghost' }); + root.add([dim, panel, txt, yes, no]); + } + + // --------------------------------------------------------------------------- + // Screens + + openTech() { + if (this.modalOpen) return; + this.modalOpen = true; + openTechScreen(this, this.rules, this.state, () => { + this.modalOpen = false; + this.refreshHud(); + }); + } + + openDiplomacy() { + if (this.modalOpen) return; + this.modalOpen = true; + openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => { + this.modalOpen = false; + this.view.refresh(); + this.refreshHud(); + }); + } + + openSpaceship() { + if (this.modalOpen) return; + this.modalOpen = true; + openSpaceshipScreen(this, this.rules, this.state, () => { + this.modalOpen = false; + this.refreshHud(); + if (this.state.over) this.onGameOver(); + }); + } + + openMenu() { + if (this.modalOpen) return; + this.modalOpen = true; + const root = this.add.container(0, 0).setDepth(D.modal); + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) + .setInteractive(); + const panel = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 460, 340, COLORS.panel) + .setStrokeStyle(2, COLORS.accent); + const title = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 120, 'MENU', { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex, + }).setOrigin(0.5); + const close = () => { root.destroy(true); this.modalOpen = false; }; + const resume = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 - 50, 'RESUME', close, + { width: 320, height: 56 }); + const save = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20, 'SAVE GAME', () => { + this.saveGame(); + this.toast('Game saved'); + close(); + }, { width: 320, height: 56, variant: 'ghost' }); + const quit = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 90, 'SAVE & QUIT', () => { + this.saveGame(); + this.scene.start('GameMenu'); + }, { width: 320, height: 56, variant: 'ghost' }); + root.add([dim, panel, title, resume, save, quit]); + } + + onGameOver() { + if (this.phase === 'over') return; + this.phase = 'over'; + this.busy = false; + this.clearSave(); + showVictoryOverlay(this, this.rules, this.state, this.opponentsData, () => { + this.scene.restart({ game: this.gameDef }); + }); + } +} diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js new file mode 100644 index 0000000..b6593f6 --- /dev/null +++ b/src/games/civilization/CivilizationLogic.js @@ -0,0 +1,1580 @@ +// Civilization — headless game engine. No Phaser imports; runs in Node for +// tools/verifyCivilization.js and in the browser scene. +// +// Simplifications vs Civ II (by design, see plan/sprites.md): no happiness or +// tax sliders (fixed 50/50 gold/science trade split), no senate, no wonders, +// no pollution, no zones of control, no rivers. Movement points are stored in +// thirds (road = 1/3, railroad = free). Combat is the Civ II round model: +// p(hit) = A/(A+D), loser of a round loses the winner's firepower in hp. + +import { generateWorld, mulberry32, shieldGrassAt, IMP } from './CivilizationWorldGen.js'; +import { techCost } from './CivilizationRules.js'; + +export { mulberry32, shieldGrassAt, IMP }; + +export const FOOD_PER_CITIZEN = 2; +export const FOODBOX_PER_SIZE = 10; +export const VET_BONUS = 1.5; +export const FORTIFY_BONUS = 1.5; +export const CITY_BASE_DEF = 1.5; // unwalled city acts like fortified ground +export const FORTRESS_BONUS = 2; +export const HEAL_FIELD = 0.1; +export const HEAL_CITY = 1 / 3; +export const PATH_EXPANSION_CAP = 400; +export const HUT_RESULTS = ['gold', 'tech', 'unit', 'ambush']; + +// Fat cross: 5x5 Chebyshev block minus the four corners = 21 tiles. +export const CITY_RADIUS = []; +for (let dy = -2; dy <= 2; dy += 1) { + for (let dx = -2; dx <= 2; dx += 1) { + if (Math.abs(dx) === 2 && Math.abs(dy) === 2) continue; + CITY_RADIUS.push([dx, dy]); + } +} + +export function rand(state) { + // Explicit-state mulberry32 step so the RNG serializes with the game. + let a = state.rngState | 0; + a = (a + 0x6d2b79f5) | 0; + state.rngState = a; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} +export function randInt(state, n) { return Math.floor(rand(state) * n); } + +export function cheb(x1, y1, x2, y2) { + return Math.max(Math.abs(x1 - x2), Math.abs(y1 - y2)); +} + +// --------------------------------------------------------------------------- +// Game creation + +export function createGame(rules, opts) { + const { + sizeId = 'medium', seed = 1, difficultyId = 'prince', + leaders, humanIndex = 0, + } = opts; + const numCivs = leaders.length; + const world = generateWorld(rules, { sizeId, seed, numCivs }); + const difficulty = rules.difficulties[difficultyId]; + const n = world.cols * world.rows; + + const state = { + version: 1, + rules: null, // attached transiently via attachRules; never serialized + seed, + rngState: (seed * 2654435761) | 0, + sizeId, + difficultyId, + turn: 0, + current: 0, + humanIndex, + world, + civs: [], + cities: [], + units: [], + nextUnitId: 1, + nextCityId: 1, + explored: [], + over: null, + events: [], + }; + + const nameOrder = shuffledIndexes(state, rules.cityNames.length); + for (let i = 0; i < numCivs; i += 1) { + const relations = {}; + const attitude = {}; + for (let j = 0; j < numCivs; j += 1) { + if (j !== i) { relations[j] = 'nocontact'; attitude[j] = 0; } + } + state.civs.push({ + id: i, + leaderId: leaders[i].id, + name: leaders[i].name, + color: rules.playerColors[i % rules.playerColors.length], + human: i === humanIndex, + alive: true, + government: 'despotism', + revolutionTurns: 0, + pendingGovernment: null, + gold: 50, + beakers: 0, + researching: null, + known: {}, + futureCount: 0, + relations, + attitude, + reputation: 0, + spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }, + nameCursor: i, // stride through the shared shuffled name pool + nameOrder, + score: 0, + }); + state.explored.push(new Array(n).fill(0)); + } + + // Starting settlers (+1 for AI at higher difficulties). + const startSettlers = rules.worldSizes[sizeId].startSettlers; + for (let i = 0; i < numCivs; i += 1) { + const [sx, sy] = world.starts[i]; + const count = startSettlers + (state.civs[i].human ? 0 : difficulty.aiStartUnits); + for (let k = 0; k < count; k += 1) { + const spot = k === 0 ? [sx, sy] : nearbyLandSpot(rules, state, sx, sy); + spawnUnit(rules, state, i, 'settlers', spot[0], spot[1], null); + } + exploreAround(state, i, sx, sy, 2); + } + return state; +} + +function shuffledIndexes(state, len) { + const arr = Array.from({ length: len }, (_, i) => i); + for (let i = arr.length - 1; i > 0; i -= 1) { + const j = randInt(state, i + 1); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +function nearbyLandSpot(rules, state, x, y) { + const { world } = state; + for (let r = 1; r <= 3; r += 1) { + for (let dy = -r; dy <= r; dy += 1) { + for (let dx = -r; dx <= r; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= world.cols || ny >= world.rows) continue; + const terr = rules.terrainList[world.terrain[ny * world.cols + nx]]; + if (!terr.water && terr.id !== 'glacier' && terr.id !== 'mountains') return [nx, ny]; + } + } + } + return [x, y]; +} + +// --------------------------------------------------------------------------- +// Lookups + +export function tileIndex(world, x, y) { return y * world.cols + x; } +export function inBounds(world, x, y) { + return x >= 0 && y >= 0 && x < world.cols && y < world.rows; +} +export function terrainAt(rules, world, x, y) { + return rules.terrainList[world.terrain[tileIndex(world, x, y)]]; +} +export function cityAt(state, x, y) { + return state.cities.find((c) => c.x === x && c.y === y) ?? null; +} +export function unitsAt(state, x, y) { + return state.units.filter((u) => u.x === x && u.y === y && !u.carriedBy); +} +export function unitById(state, id) { return state.units.find((u) => u.id === id) ?? null; } +export function cityById(state, id) { return state.cities.find((c) => c.id === id) ?? null; } +export function civUnits(state, civ) { return state.units.filter((u) => u.civ === civ); } +export function civCities(state, civ) { return state.cities.filter((c) => c.civ === civ); } + +export function knowsTech(civ, techId) { return !!civ.known[techId]; } +export function knownCount(civ) { return Object.keys(civ.known).length + civ.futureCount; } + +export function availableTechs(rules, civ) { + return rules.techList.filter((t) => (t.repeatable || !civ.known[t.id]) + && t.prereqs.every((p) => civ.known[p])); +} + +export function availableUnits(rules, state, civ, city) { + return rules.unitList.filter((u) => { + if (u.prereq && !knowsTech(civ, u.prereq)) return false; + if (u.obsoletedBy && knowsTech(civ, rules.units[u.obsoletedBy].prereq)) return false; + if (u.domain === 'sea' && !isCoastal(rules, state, city)) return false; + if (u.flags.includes('spaceship')) { + const ship = state.civs[civ.id].spaceship; + const cap = { ssstructural: rules.spaceship.structuralNeeded, + sscomponent: rules.spaceship.componentsNeeded, + ssmodule: rules.spaceship.modulesNeeded }[u.id]; + const have = { ssstructural: ship.structural, sscomponent: ship.component, + ssmodule: ship.module }[u.id]; + if (ship.launched || have >= cap) return false; + } + return true; + }); +} + +export function availableBuildings(rules, state, civ, city) { + return rules.buildingList.filter((b) => { + if (city.buildings[b.id]) return false; + if (b.prereq && !knowsTech(civ, b.prereq)) return false; + if (b.requires && !city.buildings[b.requires]) return false; + if (b.effect === 'power' && hasPowerPlant(city)) return false; + if ((b.effect === 'oceanfood' || b.effect === 'oceanshield' || b.effect === 'defensesea') + && !isCoastal(rules, state, city)) return false; + return true; + }); +} + +function hasPowerPlant(city) { + return !!(city.buildings.powerplant || city.buildings.hydroplant || city.buildings.nuclearplant); +} + +export function isCoastal(rules, state, city) { + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]) { + const x = city.x + dx; + const y = city.y + dy; + if (inBounds(state.world, x, y) && terrainAt(rules, state.world, x, y).water) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Tile yields + +export function tileYield(rules, state, civIdx, city, x, y) { + const { world } = state; + const i = tileIndex(world, x, y); + const terr = rules.terrainList[world.terrain[i]]; + const special = world.special[i] >= 0 ? rules.specialList[world.special[i]] : null; + let food = special ? special.food : terr.food; + let shield = special ? special.shield : terr.shield; + let trade = special ? special.trade : terr.trade; + if (!special && terr.id === 'grassland' && shieldGrassAt(x, y)) shield += 1; + + const imp = world.improvements[i]; + if ((imp & IMP.IRRIGATION) && terr.irrigate) food += terr.irrigate; + if ((imp & IMP.MINE) && terr.mine) shield += terr.mine; + if ((imp & IMP.ROAD) && !terr.water && terr.move === 1) trade += 1; + if ((imp & IMP.RAILROAD) && shield >= 1) shield += 1; + if ((imp & IMP.FARMLAND) && city && city.buildings.supermarket) { + food = Math.floor(food * 1.5); + } + if (city) { + if (terr.water && city.buildings.harbor) food += 1; + if (terr.water && city.buildings.offshoreplatform) shield += 1; + } + + const civ = state.civs[civIdx]; + const gov = rules.governments[civ.government]; + if (gov.tradeBonus && trade >= 1) trade += gov.tradeBonus; + if (gov.despotPenalty) { + if (food >= 3) food -= 1; + if (shield >= 3) shield -= 1; + if (trade >= 3) trade -= 1; + } + return { food, shield, trade }; +} + +const EMPHASIS_WEIGHTS = { + balanced: { food: 3, shield: 2, trade: 1 }, + food: { food: 6, shield: 1, trade: 1 }, + production: { food: 1, shield: 5, trade: 1 }, + trade: { food: 1, shield: 1, trade: 5 }, +}; + +export function autoAssignTiles(rules, state, city) { + const { world } = state; + const weights = EMPHASIS_WEIGHTS[city.emphasis] ?? EMPHASIS_WEIGHTS.balanced; + const takenElsewhere = new Set(); + for (const other of state.cities) { + if (other.id === city.id) continue; + for (const t of other.worked) takenElsewhere.add(t); + } + const options = []; + for (const [dx, dy] of CITY_RADIUS) { + if (dx === 0 && dy === 0) continue; + const x = city.x + dx; + const y = city.y + dy; + if (!inBounds(world, x, y)) continue; + const idx = tileIndex(world, x, y); + if (takenElsewhere.has(idx)) continue; + const other = cityAt(state, x, y); + if (other) continue; + const yld = tileYield(rules, state, city.civ, city, x, y); + options.push({ idx, w: yld.food * weights.food + yld.shield * weights.shield + yld.trade * weights.trade }); + } + options.sort((a, b) => b.w - a.w || a.idx - b.idx); + city.worked = options.slice(0, city.size).map((o) => o.idx); +} + +export function cityYields(rules, state, city) { + const civ = state.civs[city.civ]; + const gov = rules.governments[civ.government]; + const centre = tileYield(rules, state, city.civ, city, city.x, city.y); + let food = centre.food; + let shield = Math.max(1, centre.shield); // city tile always makes 1 shield... + let trade = Math.max(1, centre.trade); // ...and 1 trade (the market economy floor) + for (const idx of city.worked) { + const x = idx % state.world.cols; + const y = (idx / state.world.cols) | 0; + const yld = tileYield(rules, state, city.civ, city, x, y); + food += yld.food; + shield += yld.shield; + trade += yld.trade; + } + + // Trade routes. + let routeTrade = 0; + for (const r of city.routes) routeTrade += r.amount; + trade += routeTrade; + + // Corruption. + const capital = civCities(state, city.civ).find((c) => c.buildings.palace); + const dist = gov.flatCorruption ? 10 + : (capital ? cheb(city.x, city.y, capital.x, capital.y) : 16); + let corruption = Math.floor(trade * gov.corruptionFactor * Math.min(1, dist / 20)); + if (city.buildings.courthouse) corruption = Math.floor(corruption * 0.5); + corruption = Math.min(corruption, trade); + const netTrade = trade - corruption; + + // Fixed 50/50 tax split (no sliders in this build), then building multipliers. + // Science gets the odd arrow: with rivers cut, early cities often make just + // 1 trade, and research must never round down to a permanent zero. + const anarchy = gov.noScience === true; + const baseGold = Math.floor(netTrade / 2); + const baseScience = anarchy ? 0 : Math.ceil(netTrade / 2); + let goldMult = 1; + let sciMult = 1; + let shieldMult = 1; + for (const bId of Object.keys(city.buildings)) { + const b = rules.buildings[bId]; + if (!b) continue; + if (b.effect === 'gold') goldMult += b.value; + if (b.effect === 'science') sciMult += b.value; + if (b.effect === 'shields') shieldMult += b.value; + if (b.effect === 'power' && city.buildings.factory) shieldMult += b.value; + } + + // Unit support: shields per supported unit beyond the free allowance + // (Democracy pays gold instead), settlers also eat food. + const supported = state.units.filter((u) => u.homeCity === city.id); + let supportShields = 0; + let supportGold = 0; + let settlerFood = 0; + let combatants = 0; + for (const u of supported) { + const def = rules.units[u.type]; + if (def.flags.includes('settler')) settlerFood += gov.settlerFood; + if (def.domain === 'project' || def.flags.includes('noncombat')) continue; + combatants += 1; + if (combatants > gov.freeUnits) { + if (gov.unitUpkeep === 'gold') supportGold += 1; + else supportShields += 1; + } + } + + const grossShield = Math.floor(shield * shieldMult); + const netShield = Math.max(0, grossShield - supportShields); + const foodNeed = city.size * FOOD_PER_CITIZEN + settlerFood; + const upkeep = Object.keys(city.buildings) + .reduce((sum, id) => sum + (rules.buildings[id]?.upkeep ?? 0), 0); + + return { + food, foodNeed, foodSurplus: food - foodNeed, + shield: netShield, grossShield, supportShields, + trade, corruption, netTrade, routeTrade, + gold: Math.floor(baseGold * goldMult), science: Math.floor(baseScience * sciMult), + upkeep, supportGold, + }; +} + +// --------------------------------------------------------------------------- +// Cities + +export function nextCityName(rules, state, civ) { + const c = state.civs[civ]; + if (civCities(state, civ).length === 0) return `${c.name} City`; + const pool = rules.cityNames; + const idx = c.nameOrder[c.nameCursor % pool.length]; + const round = Math.floor(c.nameCursor / pool.length); + c.nameCursor += state.civs.length; + return round > 0 ? `${pool[idx]} ${'I'.repeat(round + 1)}` : pool[idx]; +} + +export function canFoundCity(rules, state, x, y) { + const terr = terrainAt(rules, state.world, x, y); + if (terr.water || terr.id === 'glacier') return false; + for (const c of state.cities) { + if (cheb(c.x, c.y, x, y) < 2) return false; + } + return true; +} + +export function foundCity(rules, state, unit) { + if (!canFoundCity(rules, state, unit.x, unit.y)) return null; + const civ = state.civs[unit.civ]; + const city = { + id: state.nextCityId, + civ: unit.civ, + x: unit.x, + y: unit.y, + name: nextCityName(rules, state, unit.civ), + size: 1, + foodBox: 0, + shieldBox: 0, + build: { type: 'unit', id: 'warriors' }, + buildings: {}, + worked: [], + emphasis: 'balanced', + routes: [], + boughtThisTurn: false, + }; + state.nextCityId += 1; + if (civCities(state, unit.civ).length === 0) city.buildings.palace = true; + // Civ II treats the city square as having a road (its main early trade). + state.world.improvements[tileIndex(state.world, city.x, city.y)] |= IMP.ROAD; + state.cities.push(city); + removeUnit(state, unit); + autoAssignTiles(rules, state, city); + exploreAround(state, unit.civ, city.x, city.y, 2); + state.events.push({ type: 'cityFounded', civ: unit.civ, cityId: city.id, name: city.name }); + return city; +} + +export function setBuild(rules, state, city, type, id) { + if (city.build && city.build.type !== type && city.shieldBox > 0) { + city.shieldBox = Math.floor(city.shieldBox / 2); // Civ II class-switch penalty + } + city.build = { type, id }; +} + +export function buildCost(rules, city) { + return city.build.type === 'unit' + ? rules.units[city.build.id].cost + : rules.buildings[city.build.id].cost; +} + +export function buyCost(rules, city) { + const remaining = Math.max(0, buildCost(rules, city) - city.shieldBox); + return Math.ceil(remaining * (city.build.type === 'unit' ? 2.5 : 2)); +} + +export function buyBuild(rules, state, city) { + const civ = state.civs[city.civ]; + const cost = buyCost(rules, city); + if (city.boughtThisTurn || civ.gold < cost) return false; + civ.gold -= cost; + city.shieldBox = buildCost(rules, city); + city.boughtThisTurn = true; + return true; +} + +export function sellBuilding(rules, state, city, buildingId) { + if (!city.buildings[buildingId] || buildingId === 'palace') return false; + delete city.buildings[buildingId]; + state.civs[city.civ].gold += rules.buildings[buildingId].cost; + return true; +} + +function completeBuild(rules, state, city) { + const civ = state.civs[city.civ]; + const { type, id } = city.build; + if (type === 'building') { + city.buildings[id] = true; + city.shieldBox = 0; + state.events.push({ type: 'buildingDone', civ: city.civ, cityId: city.id, building: id }); + pickNextBuild(rules, state, city); + return; + } + const def = rules.units[id]; + if (def.flags.includes('spaceship')) { + const ship = civ.spaceship; + if (id === 'ssstructural') ship.structural += 1; + if (id === 'sscomponent') ship.component += 1; + if (id === 'ssmodule') ship.module += 1; + city.shieldBox = 0; + state.events.push({ type: 'spaceshipPart', civ: city.civ, part: id }); + pickNextBuild(rules, state, city); + return; + } + if (def.flags.includes('settler')) { + if (city.size < 2) return; // hold until the city can spare the population + city.size -= 1; + autoAssignTiles(rules, state, city); + } + const unit = spawnUnit(rules, state, city.civ, id, city.x, city.y, city.id); + if (city.buildings.barracks && def.domain === 'land') unit.vet = true; + city.shieldBox = 0; + state.events.push({ type: 'unitDone', civ: city.civ, cityId: city.id, unit: id }); +} + +function pickNextBuild(rules, state, city) { + // Fall back to something always buildable after finishing a build. + const civ = state.civs[city.civ]; + const units = availableUnits(rules, state, civ, city).filter((u) => !u.flags.includes('spaceship')); + const best = units.filter((u) => u.domain === 'land' && !u.flags.includes('noncombat')) + .sort((a, b) => b.defense - a.defense)[0] ?? units[0]; + if (best) city.build = { type: 'unit', id: best.id }; +} + +function processCity(rules, state, city) { + const civ = state.civs[city.civ]; + autoAssignTiles(rules, state, city); + const y = cityYields(rules, state, city); + + // Food. + city.foodBox += y.foodSurplus; + const boxSize = (city.size + 1) * FOODBOX_PER_SIZE; + if (city.foodBox >= boxSize) { + const cap = sizeCap(rules, city); + if (city.size < cap) { + city.size += 1; + city.foodBox = city.buildings.granary ? Math.floor(boxSize / 2) : 0; + state.events.push({ type: 'cityGrew', civ: city.civ, cityId: city.id, size: city.size }); + } else { + city.foodBox = boxSize; // capped until an aqueduct/sewer arrives + } + } else if (city.foodBox < 0) { + // Famine: starve a citizen, or a supported settler first. + const settler = state.units.find((u) => u.homeCity === city.id + && rules.units[u.type].flags.includes('settler')); + if (settler) removeUnit(state, settler); + else { + city.size -= 1; + state.events.push({ type: 'cityShrank', civ: city.civ, cityId: city.id, size: city.size }); + } + city.foodBox = 0; + if (city.size <= 0) { destroyCity(rules, state, city); return; } + } + + // Shields. + city.shieldBox += y.shield; + if (city.shieldBox >= buildCost(rules, city)) completeBuild(rules, state, city); + city.boughtThisTurn = false; + + // Economy. + civ.gold += y.gold - y.upkeep - y.supportGold; + if (!rules.governments[civ.government].noScience) civ.beakers += y.science; + + // Bankruptcy: auto-sell the cheapest sellable building. + if (civ.gold < 0) { + const sellable = Object.keys(city.buildings).filter((b) => b !== 'palace'); + if (sellable.length) { + sellable.sort((a, b) => rules.buildings[a].cost - rules.buildings[b].cost); + sellBuilding(rules, state, city, sellable[0]); + state.events.push({ type: 'buildingSold', civ: city.civ, cityId: city.id, building: sellable[0] }); + } + if (civ.gold < 0) civ.gold = 0; + } +} + +export function sizeCap(rules, city) { + if (city.buildings.sewersystem) return 99; + if (city.buildings.aqueduct) return 12; + return 8; +} + +function destroyCity(rules, state, city) { + state.cities = state.cities.filter((c) => c.id !== city.id); + for (const u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null; + for (const other of state.cities) { + other.routes = other.routes.filter((r) => r.cityId !== city.id); + } + state.events.push({ type: 'cityDestroyed', cityId: city.id, name: city.name }); +} + +// --------------------------------------------------------------------------- +// Research & government + +export function currentResearchCost(rules, state, civ) { + const diff = rules.difficulties[state.difficultyId]; + const factor = civ.human ? diff.humanResearchFactor : 1 / diff.aiScienceBonus; + return techCost(knownCount(civ), factor); +} + +export function setResearch(rules, state, civ, techId) { + const tech = rules.techs[techId]; + if (!tech) return false; + if (!tech.repeatable && civ.known[techId]) return false; + if (!tech.prereqs.every((p) => civ.known[p])) return false; + civ.researching = techId; + return true; +} + +function progressResearch(rules, state, civ) { + if (!civ.researching) return; + const cost = currentResearchCost(rules, state, civ); + if (civ.beakers < cost) return; + civ.beakers -= cost; + const techId = civ.researching; + if (rules.techs[techId].repeatable) civ.futureCount += 1; + else civ.known[techId] = true; + civ.researching = null; + state.events.push({ type: 'techDone', civ: civ.id, tech: techId }); +} + +export function grantTech(rules, state, civ, techId) { + if (rules.techs[techId].repeatable) civ.futureCount += 1; + else civ.known[techId] = true; + if (civ.researching === techId) civ.researching = null; +} + +export function startRevolution(rules, state, civ, targetGovId) { + const gov = rules.governments[targetGovId]; + if (!gov || (gov.prereq && !civ.known[gov.prereq])) return false; + if (targetGovId === civ.government) return false; + civ.government = 'anarchy'; + civ.pendingGovernment = targetGovId; + civ.revolutionTurns = 2 + randInt(state, 3); + state.events.push({ type: 'revolution', civ: civ.id, target: targetGovId }); + return true; +} + +function progressRevolution(rules, state, civ) { + if (civ.government !== 'anarchy' || !civ.pendingGovernment) return; + civ.revolutionTurns -= 1; + if (civ.revolutionTurns <= 0) { + civ.government = civ.pendingGovernment; + civ.pendingGovernment = null; + state.events.push({ type: 'newGovernment', civ: civ.id, government: civ.government }); + } +} + +// --------------------------------------------------------------------------- +// Units & movement + +export function spawnUnit(rules, state, civIdx, type, x, y, homeCity) { + const def = rules.units[type]; + const unit = { + id: state.nextUnitId, + civ: civIdx, + type, + x, y, + hp: def.hp, + mp: def.move * 3, + vet: false, + fortified: false, + sentry: false, + moved: false, + homeCity, + carriedBy: null, + order: null, + }; + state.nextUnitId += 1; + state.units.push(unit); + return unit; +} + +export function removeUnit(state, unit) { + for (const u of state.units) if (u.carriedBy === unit.id) removeUnit(state, u); + state.units = state.units.filter((x) => x.id !== unit.id); +} + +export function moveCost(rules, state, unit, fx, fy, tx, ty) { + const def = rules.units[unit.type]; + const { world } = state; + const toTerr = terrainAt(rules, world, tx, ty); + if (def.domain === 'air') return 3; + if (def.domain === 'sea') { + if (!toTerr.water && !cityAt(state, tx, ty)) return Infinity; + return 3; + } + // Land. + if (toTerr.water) return Infinity; // boarding handled in tryMove + const fi = tileIndex(world, fx, fy); + const ti = tileIndex(world, tx, ty); + const bothRail = (world.improvements[fi] & IMP.RAILROAD) && (world.improvements[ti] & IMP.RAILROAD); + if (bothRail) return 0; + const bothRoad = (world.improvements[fi] & (IMP.ROAD | IMP.RAILROAD)) + && (world.improvements[ti] & (IMP.ROAD | IMP.RAILROAD)); + if (bothRoad) return 1; + if (def.flags.includes('ignoreterrain')) return 1; + return toTerr.move * 3; +} + +// Whether `unit` may occupy (x,y) ignoring enemies (domain/terrain check). +export function canOccupy(rules, state, unit, x, y) { + if (!inBounds(state.world, x, y)) return false; + const def = rules.units[unit.type]; + const terr = terrainAt(rules, state.world, x, y); + const city = cityAt(state, x, y); + if (def.domain === 'sea') { + if (city) return city.civ === unit.civ; + if (!terr.water) return false; + if (def.flags.includes('coastal')) { + // Triremes hug the coast: some adjacent land required. + let coast = false; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (inBounds(state.world, nx, ny) && !terrainAt(rules, state.world, nx, ny).water) coast = true; + } + } + return coast; + } + return true; + } + if (def.domain === 'air') return true; + return !terr.water; +} + +// One-step move/attack/board. Returns an outcome object. +export function tryMove(rules, state, unit, dx, dy) { + if (state.over) return { result: 'invalid' }; + if (Math.abs(dx) > 1 || Math.abs(dy) > 1 || (dx === 0 && dy === 0)) return { result: 'invalid' }; + if (unit.mp <= 0 || unit.carriedBy) return { result: 'invalid' }; + const tx = unit.x + dx; + const ty = unit.y + dy; + if (!inBounds(state.world, tx, ty)) return { result: 'invalid' }; + const def = rules.units[unit.type]; + const targetCity = cityAt(state, tx, ty); + const targets = unitsAt(state, tx, ty).filter((u) => u.civ !== unit.civ); + + // Attack? + if (targets.length || (targetCity && targetCity.civ !== unit.civ)) { + const enemyCiv = targets.length ? targets[0].civ : targetCity.civ; + if (state.civs[unit.civ].relations[enemyCiv] !== 'war') { + return { result: 'blocked', needsWar: enemyCiv }; + } + if (def.attack <= 0) return { result: 'invalid' }; + if (def.domain === 'land' && terrainAt(rules, state.world, tx, ty).water) return { result: 'invalid' }; + if (def.domain === 'sea' && !terrainAt(rules, state.world, tx, ty).water && !targetCity) return { result: 'invalid' }; + if (targets.length === 0 && targetCity) { + // Undefended city: land units capture, sea/air just raid the walls. + if (def.domain !== 'land') return { result: 'invalid' }; + spendMove(unit, 3); + return captureCity(rules, state, unit, targetCity); + } + return resolveAttack(rules, state, unit, tx, ty); + } + + // Board a transport? + if (def.domain === 'land' && terrainAt(rules, state.world, tx, ty).water) { + const boat = unitsAt(state, tx, ty).find((u) => u.civ === unit.civ + && (rules.units[u.type].cargo ?? 0) > cargoCount(state, u)); + if (boat) { + unit.x = tx; unit.y = ty; unit.carriedBy = boat.id; unit.mp = 0; unit.moved = true; + return { result: 'boarded', boat: boat.id }; + } + return { result: 'invalid' }; + } + + if (!canOccupy(rules, state, unit, tx, ty)) return { result: 'invalid' }; + if (targetCity && targetCity.civ !== unit.civ) return { result: 'invalid' }; + + const cost = moveCost(rules, state, unit, unit.x, unit.y, tx, ty); + if (!Number.isFinite(cost)) return { result: 'invalid' }; + spendMove(unit, cost); + unit.x = tx; + unit.y = ty; + unit.fortified = false; + unit.moved = true; + dropCarried(rules, state, unit, tx, ty); + exploreAround(state, unit.civ, tx, ty, 2); + makeContacts(rules, state, unit.civ, tx, ty); + + const hutIdx = tileIndex(state.world, tx, ty); + if (state.world.huts[hutIdx]) { + state.world.huts[hutIdx] = 0; + return { result: 'moved', hut: resolveHut(rules, state, unit) }; + } + return { result: 'moved' }; +} + +function spendMove(unit, cost) { + unit.mp = Math.max(0, unit.mp - Math.max(0, cost)); + unit.moved = true; +} + +function cargoCount(state, boat) { + return state.units.filter((u) => u.carriedBy === boat.id).length; +} + +function dropCarried(rules, state, unit, x, y) { + // Units riding a transport move with it; disembark handled by their own move. + for (const u of state.units) { + if (u.carriedBy === unit.id) { u.x = x; u.y = y; } + } + if (unit.carriedBy) unit.carriedBy = null; +} + +export function disembark(rules, state, unit, dx, dy) { + if (!unit.carriedBy || unit.mp <= 0) return { result: 'invalid' }; + const boat = unitById(state, unit.carriedBy); + if (!boat) { unit.carriedBy = null; return { result: 'invalid' }; } + unit.carriedBy = null; + unit.mp = 3; // stepping ashore takes the turn's movement + const out = tryMove(rules, state, unit, dx, dy); + if (out.result === 'invalid' || out.result === 'blocked') { + unit.carriedBy = boat.id; + unit.mp = 0; + } + return out; +} + +function resolveHut(rules, state, unit) { + const civ = state.civs[unit.civ]; + const roll = rand(state); + if (roll < 0.4) { + const gold = 25 * (1 + randInt(state, 4)); + civ.gold += gold; + state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold }); + return { outcome: 'gold', gold }; + } + if (roll < 0.65) { + const options = availableTechs(rules, civ).filter((t) => t.era === 'ancient' && !t.repeatable); + if (options.length) { + const tech = options[randInt(state, options.length)]; + grantTech(rules, state, civ, tech.id); + state.events.push({ type: 'hut', civ: civ.id, outcome: 'tech', tech: tech.id }); + return { outcome: 'tech', tech: tech.id }; + } + civ.gold += 50; + state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold: 50 }); + return { outcome: 'gold', gold: 50 }; + } + if (roll < 0.85) { + const type = civ.known.chivalry ? 'knights' : (civ.known.ironworking ? 'legion' : 'horsemen'); + spawnUnit(rules, state, unit.civ, type, unit.x, unit.y, null); + state.events.push({ type: 'hut', civ: civ.id, outcome: 'unit', unit: type }); + return { outcome: 'unit', unit: type }; + } + // Ambush: fight a phantom era-scaled hostile on the spot. + const phantomType = civ.known.conscription ? 'riflemen' : (civ.known.gunpowder ? 'musketeers' : 'legion'); + const phantom = rules.units[phantomType]; + const def = rules.units[unit.type]; + const survived = simulateDuel(state, Math.max(1, def.attack), def.hp, def.fp, + phantom.defense, phantom.hp, phantom.fp, unit.hp); + if (!survived.attackerWon) { + removeUnit(state, unit); + state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushLost' }); + return { outcome: 'ambushLost' }; + } + unit.hp = survived.attackerHp; + state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushWon' }); + return { outcome: 'ambushWon' }; +} + +// --------------------------------------------------------------------------- +// Combat + +export function defenderStrength(rules, state, defUnit, attacker) { + const def = rules.units[defUnit.type]; + const attDef = rules.units[attacker.type]; + const terr = terrainAt(rules, state.world, defUnit.x, defUnit.y); + const city = cityAt(state, defUnit.x, defUnit.y); + const idx = tileIndex(state.world, defUnit.x, defUnit.y); + let d = def.defense * (defUnit.vet ? VET_BONUS : 1) * (defUnit.hp / def.hp); + d *= terr.defense; + if (defUnit.fortified) d *= FORTIFY_BONUS; + if (state.world.improvements[idx] & IMP.FORTRESS) d *= FORTRESS_BONUS; + if (city) { + if (city.buildings.citywalls && attDef.domain === 'land' && !attDef.flags.includes('ignorewalls')) { + d *= rules.buildings.citywalls.value; + } else if (!defUnit.fortified) { + d *= CITY_BASE_DEF; + } + if (attDef.domain === 'sea' && city.buildings.coastalfortress) d *= rules.buildings.coastalfortress.value; + if (attDef.domain === 'air' && city.buildings.sambattery) d *= rules.buildings.sambattery.value; + } + if (def.flags.includes('antimounted') && attDef.flags.includes('mounted')) d *= 2; + return d; +} + +export function attackerStrength(rules, state, unit) { + const def = rules.units[unit.type]; + return def.attack * (unit.vet ? VET_BONUS : 1) * (unit.hp / def.hp); +} + +export function pickDefender(rules, state, x, y, attacker) { + const targets = unitsAt(state, x, y).filter((u) => u.civ !== attacker.civ); + if (!targets.length) return null; + let best = targets[0]; + let bestD = -1; + for (const t of targets) { + const d = defenderStrength(rules, state, t, attacker); + if (d > bestD) { bestD = d; best = t; } + } + return best; +} + +export function simulateDuel(state, A, aHpMax, aFp, D, dHpMax, dFp, aHpStart) { + let aHp = aHpStart; + let dHp = dHpMax; + const p = (A + D) > 0 ? A / (A + D) : 1; + while (aHp > 0 && dHp > 0) { + if (rand(state) < p) dHp -= aFp; + else aHp -= dFp; + } + return { attackerWon: dHp <= 0, attackerHp: Math.max(0, aHp), defenderHp: Math.max(0, dHp) }; +} + +export function resolveAttack(rules, state, attacker, tx, ty) { + const attDef = rules.units[attacker.type]; + const defender = pickDefender(rules, state, tx, ty, attacker); + if (!defender) return { result: 'invalid' }; + const defDef = rules.units[defender.type]; + + // Nukes: no combat — obliterate the tile (SDI blocks). + if (attDef.flags.includes('nuke')) { + const city = cityAt(state, tx, ty); + if (city && city.buildings.sdidefense) { + removeUnit(state, attacker); + state.events.push({ type: 'nukeBlocked', cityId: city.id }); + return { result: 'nukeBlocked' }; + } + for (const u of unitsAt(state, tx, ty)) removeUnit(state, u); + if (city) city.size = Math.max(1, Math.ceil(city.size / 2)); + removeUnit(state, attacker); + state.events.push({ type: 'nuke', x: tx, y: ty, cityId: city?.id ?? null }); + checkVictory(rules, state); + return { result: 'nuked' }; + } + + const A = attackerStrength(rules, state, attacker); + const D = defenderStrength(rules, state, defender, attacker); + const duel = simulateDuel(state, A, attDef.hp, attDef.fp, D, defDef.hp, defDef.fp, attacker.hp); + + const city = cityAt(state, tx, ty); + const idx = tileIndex(state.world, tx, ty); + const protectedStack = !!city || !!(state.world.improvements[idx] & IMP.FORTRESS); + + if (duel.attackerWon) { + removeUnit(state, defender); + if (!protectedStack) { + for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) removeUnit(state, u); + } + attacker.hp = Math.max(1, duel.attackerHp); + if (!attacker.vet && rand(state) < 0.5) attacker.vet = true; + } else { + defender.hp = Math.max(1, duel.defenderHp); + if (!defender.vet && rand(state) < 0.5) defender.vet = true; + removeUnit(state, attacker); + } + spendMove(attacker, 3); + if (attDef.flags.includes('missile') && duel.attackerWon) removeUnit(state, attacker); + + state.events.push({ + type: 'combat', x: tx, y: ty, + attacker: { civ: attacker.civ, type: attacker.type }, + defender: { civ: defender.civ, type: defender.type }, + attackerWon: duel.attackerWon, + }); + checkVictory(rules, state); + return { result: 'combat', won: duel.attackerWon }; +} + +export function captureCity(rules, state, unit, city) { + const oldCiv = state.civs[city.civ]; + const newCivIdx = unit.civ; + const loot = 50 + 10 * city.size; + const wasCapital = !!city.buildings.palace; + state.civs[newCivIdx].gold += Math.min(oldCiv.gold, loot); + oldCiv.gold = Math.max(0, oldCiv.gold - loot); + delete city.buildings.palace; + city.civ = newCivIdx; + city.size = Math.max(1, city.size - 1); + city.routes = []; + city.shieldBox = 0; + city.build = { type: 'unit', id: 'warriors' }; + for (const u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null; + unit.x = city.x; + unit.y = city.y; + unit.moved = true; + exploreAround(state, newCivIdx, city.x, city.y, 2); + + // A capital lost mid-flight destroys the spaceship. + if (wasCapital && oldCiv.spaceship.launched) { + oldCiv.spaceship = { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }; + state.events.push({ type: 'spaceshipLost', civ: oldCiv.id }); + } + // Relocate palace to another city (free) if any remain. + const remaining = civCities(state, oldCiv.id); + if (wasCapital && remaining.length) remaining[0].buildings.palace = true; + + state.events.push({ type: 'cityCaptured', cityId: city.id, name: city.name, from: oldCiv.id, to: newCivIdx }); + if (!remaining.length) eliminateCiv(rules, state, oldCiv.id); + checkVictory(rules, state); + return { result: 'captured', cityId: city.id }; +} + +export function eliminateCiv(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (!civ.alive) return; + civ.alive = false; + for (const u of civUnits(state, civIdx)) removeUnit(state, u); + state.events.push({ type: 'civEliminated', civ: civIdx }); +} + +// --------------------------------------------------------------------------- +// Worked orders (settlers/engineers) + +export function canWork(rules, state, unit, impId) { + const def = rules.units[unit.type]; + if (!def.flags.includes('settler')) return false; + const imp = rules.improvements[impId]; + if (!imp) return false; + if (imp.engineerOnly && !def.flags.includes('engineer')) return false; + const civ = state.civs[unit.civ]; + if (imp.prereq && !civ.known[imp.prereq]) return false; + const { world } = state; + const idx = tileIndex(world, unit.x, unit.y); + const terr = terrainAt(rules, world, unit.x, unit.y); + if (terr.water) return false; + const bits = world.improvements[idx]; + switch (impId) { + case 'road': return !(bits & IMP.ROAD); + case 'railroad': return !!(bits & IMP.ROAD) && !(bits & IMP.RAILROAD); + case 'irrigation': + return terr.irrigate !== null && !(bits & IMP.IRRIGATION) && hasWaterAccess(rules, state, unit.x, unit.y); + case 'farmland': return !!(bits & IMP.IRRIGATION) && !(bits & IMP.FARMLAND); + case 'mine': return terr.mine !== null && !(bits & IMP.MINE); + case 'fortress': return !(bits & IMP.FORTRESS) && !cityAt(state, unit.x, unit.y); + case 'transform': return terr.transform !== null; + default: return false; + } +} + +function hasWaterAccess(rules, state, x, y) { + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (!inBounds(state.world, nx, ny)) continue; + if (terrainAt(rules, state.world, nx, ny).water) return true; + if (state.world.improvements[tileIndex(state.world, nx, ny)] & IMP.IRRIGATION) return true; + } + } + return false; +} + +export function startWork(rules, state, unit, impId) { + if (!canWork(rules, state, unit, impId)) return false; + unit.order = { kind: 'work', imp: impId, progress: 0 }; + unit.mp = 0; + return true; +} + +function progressWork(rules, state, unit) { + if (!unit.order || unit.order.kind !== 'work') return; + const def = rules.units[unit.type]; + unit.order.progress += def.flags.includes('engineer') ? 2 : 1; + unit.mp = 0; + const imp = rules.improvements[unit.order.imp]; + if (unit.order.progress < imp.work) return; + const { world } = state; + const idx = tileIndex(world, unit.x, unit.y); + switch (unit.order.imp) { + case 'road': world.improvements[idx] |= IMP.ROAD; break; + case 'railroad': world.improvements[idx] |= IMP.RAILROAD; break; + case 'irrigation': + world.improvements[idx] = (world.improvements[idx] | IMP.IRRIGATION) & ~IMP.MINE; + break; + case 'farmland': world.improvements[idx] |= IMP.FARMLAND; break; + case 'mine': + world.improvements[idx] = (world.improvements[idx] | IMP.MINE) + & ~(IMP.IRRIGATION | IMP.FARMLAND); + break; + case 'fortress': world.improvements[idx] |= IMP.FORTRESS; break; + case 'transform': { + const terr = terrainAt(rules, world, unit.x, unit.y); + if (terr.transform) { + const target = rules.terrainList.findIndex((t) => t.id === terr.transform); + world.terrain[idx] = target; + world.improvements[idx] = 0; + } + break; + } + default: break; + } + unit.order = null; + state.events.push({ type: 'workDone', civ: unit.civ, x: unit.x, y: unit.y }); +} + +// --------------------------------------------------------------------------- +// Pathfinding (Dijkstra in move-thirds, capped) + +export function findPath(rules, state, unit, tx, ty) { + const { world } = state; + if (!inBounds(world, tx, ty)) return null; + const start = tileIndex(world, unit.x, unit.y); + const goal = tileIndex(world, tx, ty); + if (start === goal) return []; + const dist = new Map([[start, 0]]); + const prev = new Map(); + const frontier = [{ idx: start, d: 0 }]; + let expansions = 0; + while (frontier.length && expansions < PATH_EXPANSION_CAP) { + // Linear min-extract: frontier stays small under the expansion cap. + let minI = 0; + for (let i = 1; i < frontier.length; i += 1) { + if (frontier[i].d < frontier[minI].d) minI = i; + } + const { idx, d } = frontier[minI]; + frontier[minI] = frontier[frontier.length - 1]; + frontier.pop(); + if (idx === goal) break; + if (d > (dist.get(idx) ?? Infinity)) continue; + expansions += 1; + const x = idx % world.cols; + const y = (idx / world.cols) | 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (!inBounds(world, nx, ny)) continue; + const nIdx = tileIndex(world, nx, ny); + if (nIdx !== goal) { + if (!canOccupy(rules, state, unit, nx, ny)) continue; + const blockers = unitsAt(state, nx, ny).filter((u) => u.civ !== unit.civ); + const enemyCity = cityAt(state, nx, ny); + if (blockers.length || (enemyCity && enemyCity.civ !== unit.civ)) continue; + } + const cost = moveCost(rules, state, unit, x, y, nx, ny); + if (!Number.isFinite(cost)) continue; + const nd = d + cost + 0.01; // slight step bias keeps rail paths short + if (nd < (dist.get(nIdx) ?? Infinity)) { + dist.set(nIdx, nd); + prev.set(nIdx, idx); + frontier.push({ idx: nIdx, d: nd }); + } + } + } + } + if (!prev.has(goal)) return null; + const path = []; + let cur = goal; + while (cur !== start) { + path.unshift([cur % world.cols, (cur / world.cols) | 0]); + cur = prev.get(cur); + } + return path; +} + +// --------------------------------------------------------------------------- +// Visibility / exploration / contact + +export function exploreAround(state, civIdx, x, y, radius) { + const grid = state.explored[civIdx]; + const { world } = state; + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (inBounds(world, nx, ny)) grid[tileIndex(world, nx, ny)] = 1; + } + } +} + +export function computeVisible(state, civIdx) { + const { world } = state; + const vis = new Set(); + const mark = (x, y, r) => { + for (let dy = -r; dy <= r; dy += 1) { + for (let dx = -r; dx <= r; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (inBounds(world, nx, ny)) vis.add(tileIndex(world, nx, ny)); + } + } + }; + for (const u of civUnits(state, civIdx)) mark(u.x, u.y, 2); + for (const c of civCities(state, civIdx)) mark(c.x, c.y, 2); + return vis; +} + +export function isUnitVisibleTo(rules, state, unit, civIdx) { + if (unit.civ === civIdx) return true; + const def = rules.units[unit.type]; + if (def.flags.includes('submarine')) { + // Subs only show when something of ours is adjacent. + return civUnits(state, civIdx).some((u) => cheb(u.x, u.y, unit.x, unit.y) <= 1) + || civCities(state, civIdx).some((c) => cheb(c.x, c.y, unit.x, unit.y) <= 1); + } + return true; +} + +// Full proximity sweep: any of my units/cities within 3 of theirs = contact. +export function contactSweep(rules, state, civIdx) { + const civ = state.civs[civIdx]; + const minePoints = [ + ...civUnits(state, civIdx).map((u) => [u.x, u.y]), + ...civCities(state, civIdx).map((c) => [c.x, c.y]), + ]; + for (const other of state.civs) { + if (other.id === civIdx || !other.alive) continue; + if (civ.relations[other.id] !== 'nocontact') continue; + const theirs = [ + ...civUnits(state, other.id).map((u) => [u.x, u.y]), + ...civCities(state, other.id).map((c) => [c.x, c.y]), + ]; + let met = false; + for (const [mx, my] of minePoints) { + for (const [tx, ty] of theirs) { + if (cheb(mx, my, tx, ty) <= 3) { met = true; break; } + } + if (met) break; + } + if (met) { + civ.relations[other.id] = 'contact'; + other.relations[civIdx] = 'contact'; + state.events.push({ type: 'contact', a: civIdx, b: other.id }); + } + } +} + +export function makeContacts(rules, state, civIdx, x, y) { + for (const other of state.civs) { + if (other.id === civIdx || !other.alive) continue; + if (state.civs[civIdx].relations[other.id] !== 'nocontact') continue; + const near = civUnits(state, other.id).some((u) => cheb(u.x, u.y, x, y) <= 1) + || civCities(state, other.id).some((c) => cheb(c.x, c.y, x, y) <= 1); + if (near) { + state.civs[civIdx].relations[other.id] = 'contact'; + other.relations[civIdx] = 'contact'; + state.events.push({ type: 'contact', a: civIdx, b: other.id }); + } + } +} + +// --------------------------------------------------------------------------- +// Trade routes (caravans) + +export const TRADE_ROUTE_MIN_DIST = 8; +export const MAX_ROUTES = 3; + +export function canEstablishRoute(rules, state, unit) { + const def = rules.units[unit.type]; + if (!def.flags.includes('caravan')) return null; + const here = cityAt(state, unit.x, unit.y); + const home = cityById(state, unit.homeCity); + if (!here || !home || here.id === home.id) return null; + if (here.civ !== unit.civ && state.civs[unit.civ].relations[here.civ] === 'war') return null; + if (cheb(here.x, here.y, home.x, home.y) < TRADE_ROUTE_MIN_DIST) return null; + return { here, home }; +} + +export function establishTradeRoute(rules, state, unit) { + const pair = canEstablishRoute(rules, state, unit); + if (!pair) return null; + const { here, home } = pair; + const civ = state.civs[unit.civ]; + const dist = cheb(here.x, here.y, home.x, home.y); + const yHome = cityYields(rules, state, home); + const yHere = cityYields(rules, state, here); + const foreign = here.civ !== unit.civ; + const bonus = Math.floor((dist + yHome.netTrade + yHere.netTrade) / 2) * (foreign ? 2 : 1); + civ.gold += bonus; + civ.beakers += bonus; + const amount = Math.max(1, Math.floor(dist / 4)) + (foreign ? 1 : 0); + addRoute(home, { cityId: here.id, amount }); + addRoute(here, { cityId: home.id, amount }); + removeUnit(state, unit); + state.events.push({ + type: 'tradeRoute', civ: unit.civ, from: home.id, to: here.id, bonus, amount, + }); + return { bonus, amount, from: home, to: here }; +} + +function addRoute(city, route) { + const existing = city.routes.find((r) => r.cityId === route.cityId); + if (existing) { existing.amount = Math.max(existing.amount, route.amount); return; } + city.routes.push(route); + if (city.routes.length > MAX_ROUTES) { + city.routes.sort((a, b) => b.amount - a.amount); + city.routes.length = MAX_ROUTES; + } +} + +// --------------------------------------------------------------------------- +// Diplomacy +// +// Pairwise relation states and the legal proposal steps between them. War can +// be declared from any contacted state; breaking peace/alliance that way is a +// "sneak attack" and permanently scars the aggressor's reputation. + +export const DIPLO_PROPOSALS = { + war: ['ceasefire'], + ceasefire: ['peace'], + contact: ['peace'], + peace: ['alliance'], + alliance: [], + nocontact: [], +}; + +export function canPropose(state, a, b, kind) { + const rel = state.civs[a].relations[b]; + return (DIPLO_PROPOSALS[rel] ?? []).includes(kind); +} + +export function applyTreaty(state, a, b, kind) { + if (!canPropose(state, a, b, kind)) return false; + state.civs[a].relations[b] = kind; + state.civs[b].relations[a] = kind; + bumpAttitude(state, a, b, 15); + bumpAttitude(state, b, a, 15); + state.events.push({ type: 'treaty', a, b, kind }); + return true; +} + +export function declareWar(rules, state, a, b) { + const civA = state.civs[a]; + const rel = civA.relations[b]; + if (rel === 'nocontact' || rel === 'war') return false; + const sneak = rel === 'peace' || rel === 'alliance'; + civA.relations[b] = 'war'; + state.civs[b].relations[a] = 'war'; + bumpAttitude(state, b, a, -50); + if (sneak) { + const gov = rules.governments[civA.government]; + civA.reputation -= 25 * (gov.warPenalty ?? 1); + for (const other of state.civs) { + if (other.id !== a && other.alive) bumpAttitude(state, other.id, a, -20); + } + } + state.events.push({ type: 'war', a, b, sneak }); + return true; +} + +export function cancelTreaty(state, a, b) { + const rel = state.civs[a].relations[b]; + if (rel !== 'peace' && rel !== 'alliance' && rel !== 'ceasefire') return false; + state.civs[a].relations[b] = 'contact'; + state.civs[b].relations[a] = 'contact'; + bumpAttitude(state, b, a, rel === 'alliance' ? -20 : -10); + state.events.push({ type: 'treatyCancelled', a, b, was: rel }); + return true; +} + +export function giftGold(state, a, b, amount) { + const civA = state.civs[a]; + if (amount <= 0 || civA.gold < amount) return false; + civA.gold -= amount; + state.civs[b].gold += amount; + bumpAttitude(state, b, a, Math.min(20, Math.ceil(amount / 25))); + state.events.push({ type: 'gift', a, b, gold: amount }); + return true; +} + +export function giftTech(rules, state, a, b, techId) { + const civA = state.civs[a]; + const civB = state.civs[b]; + if (!civA.known[techId] || civB.known[techId]) return false; + grantTech(rules, state, civB, techId); + bumpAttitude(state, b, a, 15); + state.events.push({ type: 'gift', a, b, tech: techId }); + return true; +} + +export function exchangeTechs(rules, state, a, b, giveId, getId) { + const civA = state.civs[a]; + const civB = state.civs[b]; + if (!civA.known[giveId] || civB.known[giveId]) return false; + if (!civB.known[getId] || civA.known[getId]) return false; + grantTech(rules, state, civB, giveId); + grantTech(rules, state, civA, getId); + bumpAttitude(state, a, b, 5); + bumpAttitude(state, b, a, 5); + state.events.push({ type: 'techExchange', a, b, giveId, getId }); + return true; +} + +export function payTribute(state, a, b, amount) { + const civA = state.civs[a]; + const paid = Math.min(civA.gold, amount); + if (paid <= 0) return 0; + civA.gold -= paid; + state.civs[b].gold += paid; + bumpAttitude(state, a, b, -15); // being shaken down breeds resentment + state.events.push({ type: 'tribute', a, b, gold: paid }); + return paid; +} + +export function bumpAttitude(state, ofCiv, towardCiv, delta) { + const civ = state.civs[ofCiv]; + civ.attitude[towardCiv] = clampAttitude((civ.attitude[towardCiv] ?? 0) + delta); +} +function clampAttitude(v) { return Math.max(-100, Math.min(100, v)); } + +export function civPower(rules, state, civIdx) { + let power = 0; + for (const u of civUnits(state, civIdx)) { + const def = rules.units[u.type]; + power += def.attack + def.defense; + } + for (const c of civCities(state, civIdx)) power += c.size * 2; + return power; +} + +// Per-turn attitude drift toward a situational baseline. Reputation scars from +// sneak attacks hold the ceiling down permanently. +export function updateAttitudes(rules, state, civIdx) { + const civ = state.civs[civIdx]; + const myPower = civPower(rules, state, civIdx); + for (const other of state.civs) { + if (other.id === civIdx || !other.alive) continue; + if (civ.relations[other.id] === 'nocontact') continue; + let baseline = 0; + const theirPower = civPower(rules, state, other.id); + if (theirPower > myPower * 2) baseline -= 20; // fear the runaway + // Shared enemies build friendship. + for (const third of state.civs) { + if (third.id === civIdx || third.id === other.id || !third.alive) continue; + if (civ.relations[third.id] === 'war' && other.relations[third.id] === 'war') baseline += 20; + } + // Border friction: their combat units close to my cities. + let friction = 0; + for (const u of civUnits(state, other.id)) { + if (rules.units[u.type].flags.includes('noncombat')) continue; + if (civCities(state, civIdx).some((c) => cheb(c.x, c.y, u.x, u.y) <= 3)) friction += 1; + } + baseline -= Math.min(30, friction * 5); + if (civ.relations[other.id] === 'war') baseline -= 40; + if (civ.relations[other.id] === 'alliance') baseline += 30; + baseline += Math.max(-50, other.reputation / 2); + baseline = clampAttitude(baseline); + const cur = civ.attitude[other.id] ?? 0; + civ.attitude[other.id] = clampAttitude(cur + Math.sign(baseline - cur) * Math.min(3, Math.abs(baseline - cur))); + } +} + +export function attitudeMood(value) { + if (value <= -25) return 'upset'; + if (value >= 25) return 'happy'; + return 'idle'; +} + +// --------------------------------------------------------------------------- +// Turn structure + +export function beginCivTurn(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (!civ.alive) return; + // Trim the event log instead of clearing it: the scene reads events from + // AI turns at the start of the human turn (toasts, terrain repaints), and + // headless soaks must not grow it unboundedly. + if (state.events.length > 400) { + state.events = [ + ...state.events.filter((e) => e.keep), + ...state.events.slice(-200).filter((e) => !e.keep), + ]; + } + progressRevolution(rules, state, civ); + updateAttitudes(rules, state, civIdx); + + // Cities produce/grow/research. + for (const city of civCities(state, civIdx)) processCity(rules, state, city); + progressResearch(rules, state, civ); + if (!civ.researching && !civ.human) { + // AIs always keep researching something (module CivilizationAI refines this). + const options = availableTechs(rules, civ); + if (options.length) civ.researching = options[randInt(state, options.length)].id; + } + + // Units: reset movement, heal stationary ones, progress work orders. + for (const unit of civUnits(state, civIdx)) { + const def = rules.units[unit.type]; + if (!unit.moved && !unit.order) { + const city = cityAt(state, unit.x, unit.y); + const healFrac = city ? (city.buildings.barracks ? 1 : HEAL_CITY) : HEAL_FIELD; + unit.hp = Math.min(def.hp, unit.hp + Math.ceil(def.hp * healFrac)); + } + unit.mp = def.move * 3; + unit.moved = false; + progressWork(rules, state, unit); + } +} + +export function endCivTurn(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (civ.alive) { + contactSweep(rules, state, civIdx); + // Air units must end the turn on a city or carrier. + for (const unit of civUnits(state, civIdx)) { + const def = rules.units[unit.type]; + if (def.domain !== 'air') continue; + const city = cityAt(state, unit.x, unit.y); + const carrier = unitsAt(state, unit.x, unit.y) + .find((u) => u.civ === civIdx && (rules.units[u.type].cargoAir ?? 0) > 0); + if (!city && !carrier) { + removeUnit(state, unit); + state.events.push({ type: 'airCrash', civ: civIdx, unitType: unit.type }); + } + } + } + // Advance to the next living civ; wrap advances the game turn. + let next = civIdx; + for (let i = 0; i < state.civs.length; i += 1) { + next = (next + 1) % state.civs.length; + if (next === 0) { + state.turn += 1; + checkSpaceshipArrivals(rules, state); + } + if (state.civs[next].alive) break; + } + state.current = next; + return next; +} + +export function launchSpaceship(rules, state, civ) { + const ship = civ.spaceship; + if (ship.launched) return false; + if (ship.structural < rules.spaceship.structuralNeeded + || ship.component < rules.spaceship.componentsNeeded + || ship.module < rules.spaceship.modulesNeeded) return false; + ship.launched = true; + ship.arrivalTurn = state.turn + rules.spaceship.travelTurns; + state.events.push({ type: 'spaceshipLaunched', civ: civ.id, arrivalTurn: ship.arrivalTurn }); + return true; +} + +function checkSpaceshipArrivals(rules, state) { + if (state.over) return; + for (const civ of state.civs) { + if (civ.alive && civ.spaceship.launched && state.turn >= civ.spaceship.arrivalTurn) { + state.over = { type: 'spaceship', winner: civ.id }; + state.events.push({ type: 'victory', mode: 'spaceship', civ: civ.id, keep: true }); + return; + } + } +} + +export function checkVictory(rules, state) { + if (state.over) return state.over; + for (const civ of state.civs) { + if (civ.alive && civCities(state, civ.id).length === 0 + && !civUnits(state, civ.id).some((u) => rules.units[u.type].flags.includes('settler')) + && state.turn > 0) { + eliminateCiv(rules, state, civ.id); + } + } + const living = state.civs.filter((c) => c.alive); + if (living.length === 1) { + state.over = { type: 'conquest', winner: living[0].id }; + state.events.push({ type: 'victory', mode: 'conquest', civ: living[0].id, keep: true }); + } else if (living.length === 0) { + state.over = { type: 'conquest', winner: -1 }; + } + return state.over; +} + +export function civScore(rules, state, civIdx) { + const civ = state.civs[civIdx]; + let pop = 0; + for (const c of civCities(state, civIdx)) pop += c.size; + return pop * 2 + knownCount(civ) * 2 + civ.futureCount * 5 + + civCities(state, civIdx).length * 3; +} + +// --------------------------------------------------------------------------- +// Serialization + +export function serialize(state) { + const { rules, ...rest } = state; + return JSON.stringify(rest); +} + +export function deserialize(json) { + const state = JSON.parse(json); + if (state.version !== 1) return null; + return state; +} + +export function hashState(state) { + const str = serialize(state); + let h = 2166136261; + for (let i = 0; i < str.length; i += 1) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(16); +} diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js new file mode 100644 index 0000000..26616e3 --- /dev/null +++ b/src/games/civilization/CivilizationMapView.js @@ -0,0 +1,667 @@ +// Civilization — isometric map renderer. +// +// The engine works on a square grid; this view draws it as Civ II-style +// diamonds. Terrain (plus improvements/specials/huts) is baked into one +// RenderTexture and repainted per-tile when the world changes; fog is a second +// RenderTexture of black diamonds erased as the human explores. Units and +// cities are lightweight display objects rebuilt on refresh() — the game is +// turn-based, so refreshes happen on actions, not per frame. +// +// Art: uses the optional civilization-* sheets when loaded (see sprites.md), +// otherwise draws flat-shaded diamonds, glyphs and roundels procedurally. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { + IMP, tileIndex, inBounds, cityAt, unitsAt, civUnits, civCities, + computeVisible, isUnitVisibleTo, shieldGrassAt, +} from './CivilizationLogic.js'; + +export const TILE_W = 128; +export const TILE_H = 64; +export const FRAME_H = 96; // sprite frames carry 32px of headroom above the diamond + +const ZOOMS = [0.5, 0.75, 1.0]; + +export class CivilizationMapView { + constructor(scene, rules, state, callbacks = {}) { + this.scene = scene; + this.rules = rules; + this.state = state; + this.cb = callbacks; + this.humanIdx = state.humanIndex; + this.zoomIdx = 1; + this.selectedUnitId = null; + this.exploredDrawn = null; + + const { world } = state; + this.originX = world.rows * (TILE_W / 2); // keeps iso x positive + this.worldW = (world.cols + world.rows) * (TILE_W / 2); + this.worldH = (world.cols + world.rows) * (TILE_H / 2) + FRAME_H; + + this.root = scene.add.container(0, 0).setDepth(1); + // Chunked RenderTextures: a Large map is ~8200px wide — beyond one safe + // GPU texture — so terrain and fog are tiled into <=2048px chunks. + const CHUNK = 2048; + this.chunkSize = CHUNK; + this.terrainChunks = []; + this.fogChunks = []; + for (let oy = 0; oy < this.worldH; oy += CHUNK) { + for (let ox = 0; ox < this.worldW; ox += CHUNK) { + const w = Math.min(CHUNK, this.worldW - ox); + const h = Math.min(CHUNK, this.worldH - oy); + const trt = scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0); + const frt = scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0); + this.terrainChunks.push({ rt: trt, ox, oy, w, h }); + this.fogChunks.push({ rt: frt, ox, oy, w, h }); + } + } + this.pathGfx = scene.add.graphics(); + this.dynamic = scene.add.container(0, 0); + this.root.add([ + ...this.terrainChunks.map((c) => c.rt), + this.pathGfx, this.dynamic, + ...this.fogChunks.map((c) => c.rt), + ]); + + this.stamp = scene.add.graphics().setVisible(false); + this.miniGfx = null; + + this.bakeTerrain(); + this.initFog(); + this.setZoom(1); + } + + // Run fn(rt, ox, oy) on every chunk overlapping the given world-space box. + forEachChunk(chunks, x0, y0, x1, y1, fn) { + for (const c of chunks) { + if (x1 < c.ox || x0 > c.ox + c.w || y1 < c.oy || y0 > c.oy + c.h) continue; + fn(c.rt, c.ox, c.oy); + } + } + + // Tile bounding box in world space (frame headroom included). + tileBounds(x, y) { + return [x - TILE_W / 2 - 4, y - (FRAME_H - TILE_H) - 4, x + TILE_W / 2 + 4, y + FRAME_H + 4]; + } + + drawStampAt(gfx, x, y) { + const [x0, y0, x1, y1] = this.tileBounds(x, y); + this.forEachChunk(this.terrainChunks, x0, y0, x1, y1, + (rt, ox, oy) => rt.draw(gfx, x - ox, y - oy)); + } + + drawFrameAt(key, frame, x, y) { + const [x0, y0, x1, y1] = this.tileBounds(x + 64, y); + this.forEachChunk(this.terrainChunks, x0, y0, x1, y1, + (rt, ox, oy) => rt.drawFrame(key, frame, x - ox, y - oy)); + } + + destroy() { + this.root.destroy(true); + this.stamp.destroy(); + this.fogStamp?.destroy(); + this.miniRoot?.destroy(true); + } + + // ------------------------------------------------------------------------- + // Coordinates + + isoX(c, r) { return this.originX + (c - r) * (TILE_W / 2); } + isoY(c, r) { return (c + r) * (TILE_H / 2); } + + // Screen -> tile (or null). Checks the two diamond candidates around the + // inverse transform. + screenToTile(px, py) { + const wx = (px - this.root.x) / this.root.scaleX; + const wy = (py - this.root.y) / this.root.scaleY; + const a = (wx - this.originX) / (TILE_W / 2); + const b = (wy - TILE_H / 2) / (TILE_H / 2); // diamond centre offset + const cf = (a + b) / 2; + const rf = (b - a) / 2; + let best = null; + let bestDist = Infinity; + for (const [c, r] of [ + [Math.floor(cf), Math.floor(rf)], [Math.ceil(cf), Math.floor(rf)], + [Math.floor(cf), Math.ceil(rf)], [Math.ceil(cf), Math.ceil(rf)], + [Math.round(cf), Math.round(rf)], + ]) { + if (!inBounds(this.state.world, c, r)) continue; + const cx = this.isoX(c, r); + const cy = this.isoY(c, r) + TILE_H / 2; + // Diamond containment via L1 distance in tile units. + const dx = Math.abs(wx - cx) / (TILE_W / 2); + const dy = Math.abs(wy - cy) / (TILE_H / 2); + if (dx + dy <= 1.02) { + const d = dx + dy; + if (d < bestDist) { bestDist = d; best = [c, r]; } + } + } + return best; + } + + setZoom(idx) { + this.zoomIdx = Phaser.Math.Clamp(idx, 0, ZOOMS.length - 1); + this.root.setScale(ZOOMS[this.zoomIdx]); + this.clampPan(); + } + zoomBy(delta) { this.setZoom(this.zoomIdx + delta); } + + panBy(dx, dy) { + this.root.x += dx; + this.root.y += dy; + this.clampPan(); + } + + clampPan() { + const s = this.root.scaleX; + const minX = GAME_WIDTH - this.worldW * s - 100; + const minY = GAME_HEIGHT - this.worldH * s - 100; + this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(100, minX), 100); + this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(100, minY), 100); + } + + centerOn(c, r) { + const s = this.root.scaleX; + this.root.x = GAME_WIDTH / 2 - this.isoX(c, r) * s; + this.root.y = GAME_HEIGHT / 2 - (this.isoY(c, r) + TILE_H / 2) * s; + this.clampPan(); + } + + // ------------------------------------------------------------------------- + // Terrain baking + + bakeTerrain() { + for (const c of this.terrainChunks) c.rt.clear(); + const { world } = this.state; + // Paint back-to-front (row-major works: greater c+r paints later). + for (let sum = 0; sum <= world.cols + world.rows - 2; sum += 1) { + for (let c = Math.max(0, sum - world.rows + 1); c <= Math.min(sum, world.cols - 1); c += 1) { + const r = sum - c; + this.paintTile(c, r); + } + } + } + + repaintTileAndNeighbors(c, r) { + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (inBounds(this.state.world, c + dx, r + dy)) this.paintTile(c + dx, r + dy); + } + } + } + + paintTile(c, r) { + const { world } = this.state; + const idx = tileIndex(world, c, r); + const terr = this.rules.terrainList[world.terrain[idx]]; + const x = this.isoX(c, r); + const y = this.isoY(c, r); + const useSheet = this.scene.textures.exists('civilization-terrain'); + if (useSheet) { + let frame = terr.frame; + if (terr.id === 'grassland' && shieldGrassAt(c, r) && world.special[idx] < 0) { + frame = this.rules.grasslandShieldFrame; + } + this.drawFrameAt('civilization-terrain', frame, x - TILE_W / 2, y + TILE_H - FRAME_H); + } else { + this.paintProceduralTile(c, r, terr, x, y); + } + this.paintImprovements(c, r, x, y); + this.paintSpecial(c, r, x, y); + } + + paintProceduralTile(c, r, terr, x, y) { + const g = this.stamp; + g.clear(); + const base = Phaser.Display.Color.HexStringToColor(terr.color).color; + const cy = TILE_H / 2; // stamp-local diamond centre + g.fillStyle(base, 1); + g.beginPath(); + g.moveTo(0, cy - TILE_H / 2); + g.lineTo(TILE_W / 2, cy); + g.lineTo(0, cy + TILE_H / 2); + g.lineTo(-TILE_W / 2, cy); + g.closePath(); + g.fillPath(); + g.lineStyle(1, 0x000000, 0.18); + g.strokePath(); + + // Simple per-terrain glyphs. + const darker = Phaser.Display.Color.ValueToColor(base).darken(25).color; + const lighter = Phaser.Display.Color.ValueToColor(base).lighten(20).color; + if (terr.id === 'forest' || terr.id === 'jungle') { + g.fillStyle(darker, 1); + for (const [tx, ty] of [[-24, 0], [0, -8], [22, 2]]) { + g.fillTriangle(tx - 9, cy + ty + 8, tx + 9, cy + ty + 8, tx, cy + ty - 12); + } + } else if (terr.id === 'mountains') { + g.fillStyle(darker, 1); + g.fillTriangle(-28, cy + 12, 4, cy + 12, -12, cy - 22); + g.fillTriangle(-4, cy + 14, 30, cy + 14, 13, cy - 16); + g.fillStyle(0xffffff, 0.9); + g.fillTriangle(-16, cy - 14, -8, cy - 14, -12, cy - 22); + } else if (terr.id === 'hills') { + g.fillStyle(darker, 1); + g.fillEllipse(-16, cy + 4, 34, 16); + g.fillEllipse(14, cy + 8, 38, 18); + } else if (terr.id === 'ocean') { + g.lineStyle(2, lighter, 0.7); + for (const [tx, ty] of [[-24, -6], [8, 2], [-8, 10]]) { + g.beginPath(); + g.moveTo(tx, cy + ty); + g.lineTo(tx + 14, cy + ty); + g.strokePath(); + } + } else if (terr.id === 'swamp') { + g.lineStyle(2, darker, 0.8); + for (const [tx, ty] of [[-20, 4], [4, -4], [18, 8]]) { + g.beginPath(); + g.moveTo(tx, cy + ty); + g.lineTo(tx, cy + ty - 8); + g.strokePath(); + } + } else if (terr.id === 'desert') { + g.fillStyle(darker, 0.6); + g.fillEllipse(-14, cy + 4, 10, 4); + g.fillEllipse(12, cy - 4, 12, 4); + } else if (terr.id === 'grassland') { + const idx = tileIndex(this.state.world, c, r); + if (shieldGrassAt(c, r) && this.state.world.special[idx] < 0) { + g.fillStyle(lighter, 1); + g.fillCircle(18, cy - 6, 5); + } + } + this.drawStampAt(g, x, y); + } + + paintImprovements(c, r, x, y) { + const { world } = this.state; + const idx = tileIndex(world, c, r); + const bits = world.improvements[idx]; + const g = this.stamp; + const cy = TILE_H / 2; + const hasCity = !!cityAt(this.state, c, r); + + // Roads/rails connect toward neighbours that also have them (or cities). + if (bits & (IMP.ROAD | IMP.RAILROAD)) { + g.clear(); + const rail = !!(bits & IMP.RAILROAD); + let drewAny = false; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) continue; + const nc = c + dx; + const nr = r + dy; + if (!inBounds(world, nc, nr)) continue; + const nBits = world.improvements[tileIndex(world, nc, nr)]; + if (!(nBits & (IMP.ROAD | IMP.RAILROAD))) continue; + const ex = (this.isoX(nc, nr) - this.isoX(c, r)) / 2; + const ey = (this.isoY(nc, nr) - this.isoY(c, r)) / 2; + g.lineStyle(rail ? 4 : 3, rail ? 0x4a4038 : 0x8a6f4d, 0.9); + g.beginPath(); + g.moveTo(0, cy); + g.lineTo(ex, cy + ey); + g.strokePath(); + drewAny = true; + } + } + if (!drewAny && !hasCity) { + g.lineStyle(3, 0x8a6f4d, 0.9); + g.beginPath(); + g.moveTo(-14, cy); + g.lineTo(14, cy); + g.strokePath(); + } + this.drawStampAt(g, x, y); + } + + const useSheet = this.scene.textures.exists('civilization-improvements'); + const drawBadge = (frame, fallback) => { + if (useSheet) { + this.drawFrameAt('civilization-improvements', frame, x - 32, y); + } else { + fallback(); + } + }; + if ((bits & IMP.IRRIGATION) && !hasCity) { + drawBadge(bits & IMP.FARMLAND ? 1 : 0, () => { + g.clear(); + g.lineStyle(2, 0x2f8fbf, 0.8); + for (let i = -1; i <= 1; i += 1) { + g.beginPath(); + g.moveTo(-18, cy + i * 7); + g.lineTo(18, cy + i * 7); + g.strokePath(); + } + this.drawStampAt(g, x, y); + }); + } + if (bits & IMP.MINE) { + drawBadge(2, () => { + g.clear(); + g.fillStyle(0x3a3a3a, 1); + g.fillTriangle(-8, cy + 6, 8, cy + 6, 0, cy - 8); + g.fillStyle(0x111111, 1); + g.fillRect(-2, cy - 2, 4, 8); + this.drawStampAt(g, x, y); + }); + } + if (bits & IMP.FORTRESS) { + drawBadge(3, () => { + g.clear(); + g.lineStyle(3, 0x9a8866, 1); + g.strokeRect(-20, cy - 12, 40, 24); + this.drawStampAt(g, x, y); + }); + } + if (world.huts[idx]) { + drawBadge(4, () => { + g.clear(); + g.fillStyle(0x8a5a2a, 1); + g.fillRect(-8, cy - 4, 16, 10); + g.fillStyle(0xb08040, 1); + g.fillTriangle(-11, cy - 4, 11, cy - 4, 0, cy - 14); + this.drawStampAt(g, x, y); + }); + } + } + + paintSpecial(c, r, x, y) { + const { world } = this.state; + const idx = tileIndex(world, c, r); + if (world.special[idx] < 0) return; + const spec = this.rules.specialList[world.special[idx]]; + if (this.scene.textures.exists('civilization-resources')) { + this.drawFrameAt('civilization-resources', spec.frame, x - 32, y); + return; + } + const g = this.stamp; + const cy = TILE_H / 2; + g.clear(); + g.fillStyle(0xffffff, 0.85); + g.fillCircle(0, cy, 9); + g.fillStyle(specialColor(spec.id), 1); + g.fillCircle(0, cy, 7); + this.drawStampAt(g, x, y); + } + + // ------------------------------------------------------------------------- + // Fog + + initFog() { + const { world } = this.state; + for (const c of this.fogChunks) { + c.rt.clear(); + c.rt.fill(0x05060a, 1); + } + // Erase diamonds that are already explored; remember what we've erased. + // The stamp is oversized by a pixel so adjacent erases leave no seams. + this.exploredDrawn = new Array(world.cols * world.rows).fill(0); + const g = this.scene.add.graphics().setVisible(false); + const cy = TILE_H / 2; + g.fillStyle(0xffffff, 1); + g.beginPath(); + g.moveTo(TILE_W / 2, cy - TILE_H / 2 - 2); + g.lineTo(TILE_W + 3, cy); + g.lineTo(TILE_W / 2, cy + TILE_H / 2 + 2); + g.lineTo(-3, cy); + g.closePath(); + g.fillPath(); + this.fogStamp = g; + this.updateFog(); + } + + updateFog() { + if (this.humanIdx < 0) { + for (const c of this.fogChunks) c.rt.setVisible(false); + return; + } + const { world } = this.state; + const explored = this.state.explored[this.humanIdx]; + for (let r = 0; r < world.rows; r += 1) { + for (let c = 0; c < world.cols; c += 1) { + const idx = tileIndex(world, c, r); + if (explored[idx] && !this.exploredDrawn[idx]) { + this.exploredDrawn[idx] = 1; + const x = this.isoX(c, r) - TILE_W / 2; + const y = this.isoY(c, r); + this.forEachChunk(this.fogChunks, x - 4, y - 4, x + TILE_W + 4, y + TILE_H + 4, + (rt, ox, oy) => rt.erase(this.fogStamp, x - ox, y - oy)); + } + } + } + } + + // ------------------------------------------------------------------------- + // Dynamic layer (units + cities) + + refresh() { + this.updateFog(); + this.dynamic.removeAll(true); + const { state, rules } = this; + const povIdx = this.humanIdx >= 0 ? this.humanIdx : state.current; + const visible = computeVisible(state, povIdx); + const explored = this.humanIdx >= 0 ? state.explored[povIdx] : null; + + for (const city of state.cities) { + const idx = tileIndex(state.world, city.x, city.y); + if (explored && !explored[idx]) continue; + this.drawCity(city); + } + + // One marker per occupied tile (top defender), with a stack badge. + const byTile = new Map(); + for (const u of state.units) { + if (u.carriedBy) continue; + const idx = tileIndex(state.world, u.x, u.y); + if (u.civ !== povIdx) { + if (!visible.has(idx)) continue; + if (!isUnitVisibleTo(rules, state, u, povIdx)) continue; + } + (byTile.get(idx) ?? byTile.set(idx, []).get(idx)).push(u); + } + for (const [idx, units] of byTile) { + const c = idx % state.world.cols; + const r = (idx / state.world.cols) | 0; + const selected = units.find((u) => u.id === this.selectedUnitId); + const top = selected ?? units[0]; + this.drawUnit(top, units.length, c, r); + } + this.drawSelection(); + this.refreshMinimap(); + } + + drawCity(city) { + const { scene } = this; + const x = this.isoX(city.x, city.y); + const y = this.isoY(city.x, city.y); + const civ = this.state.civs[city.civ]; + const color = Phaser.Display.Color.HexStringToColor(civ.color).color; + const container = scene.add.container(x, y).setDepth(y); + const walled = !!city.buildings.citywalls; + + const themeKey = 'civilization-cities-classic'; + if (scene.textures.exists(themeKey)) { + const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0; + const img = scene.add.image(0, TILE_H - FRAME_H + 48, themeKey, walled ? 4 + tier : tier); + container.add(img); + } else { + const g = scene.add.graphics(); + const cy = TILE_H / 2; + const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0; + g.fillStyle(0x6b6255, 1); + for (let i = 0; i <= tier; i += 1) { + const bw = 26 - i * 3; + const bh = 16 + i * 8; + const bx = -24 + i * 16; + g.fillRect(bx, cy - bh + 4, bw, bh); + g.fillStyle(0x7d7466, 1); + } + g.fillStyle(0x332f28, 1); + for (let i = 0; i <= tier; i += 1) g.fillRect(-18 + i * 16, cy - 6, 5, 6); + if (walled) { + g.lineStyle(3, 0x9a8866, 1); + g.strokeRect(-34, cy - 14, 68, 22); + } + container.add(g); + } + + const banner = scene.add.rectangle(0, TILE_H + 10, 0, 22, 0x000000, 0.65) + .setStrokeStyle(1, color, 1); + const label = scene.add.text(0, TILE_H + 10, `${city.size} ${city.name}`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: civ.color, + }).setOrigin(0.5); + banner.width = label.width + 16; + container.add([banner, label]); + container.setDepth(y + 1); + banner.setInteractive({ useHandCursor: true }); + banner.on('pointerdown', (pointer, lx, ly, event) => { + event.stopPropagation(); + this.cb.onCityClick?.(city); + }); + this.dynamic.add(container); + } + + drawUnit(unit, stackCount, c, r) { + const { scene, rules } = this; + const def = rules.units[unit.type]; + const civ = this.state.civs[unit.civ]; + const color = Phaser.Display.Color.HexStringToColor(civ.color).color; + const x = this.isoX(c, r); + const y = this.isoY(c, r) + TILE_H / 2; + const container = scene.add.container(x, y); + + if (scene.textures.exists('civilization-units')) { + const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1); + const img = scene.add.image(0, -6, 'civilization-units', def.frame); + container.add([ring, img]); + } else { + const g = scene.add.graphics(); + g.fillStyle(0x000000, 0.35); + g.fillEllipse(0, 12, 40, 14); + g.fillStyle(color, 1); + g.fillCircle(0, -2, 17); + g.lineStyle(2, 0xffffff, unit.fortified ? 1 : 0.5); + g.strokeCircle(0, -2, 17); + container.add(g); + const label = scene.add.text(0, -2, def.abbr, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0.5); + container.add(label); + } + if (unit.vet) { + container.add(scene.add.circle(12, -14, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6)); + } + if (stackCount > 1) { + const badge = scene.add.circle(-16, -14, 8, 0x000000, 0.8); + const num = scene.add.text(-16, -14, `${stackCount}`, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff', + }).setOrigin(0.5); + container.add([badge, num]); + } + container.setDepth(y + 2); + this.dynamic.add(container); + if (unit.id === this.selectedUnitId) this.selectedContainer = container; + } + + drawSelection() { + if (!this.selectedUnitId) return; + const unit = this.state.units.find((u) => u.id === this.selectedUnitId); + if (!unit) { this.selectedUnitId = null; return; } + const x = this.isoX(unit.x, unit.y); + const y = this.isoY(unit.x, unit.y) + TILE_H / 2; + const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1); + ring.setDepth(y + 3); + this.dynamic.add(ring); + this.scene.tweens.add({ + targets: ring, alpha: 0.25, duration: 420, yoyo: true, repeat: -1, + }); + } + + showPath(path) { + this.pathGfx.clear(); + if (!path || !path.length) return; + this.pathGfx.lineStyle(3, 0xffffff, 0.6); + for (const [c, r] of path) { + const x = this.isoX(c, r); + const y = this.isoY(c, r) + TILE_H / 2; + this.pathGfx.strokeCircle(x, y, 7); + } + } + + // ------------------------------------------------------------------------- + // Minimap + + buildMinimap(x, y, width) { + const { world } = this.state; + const scale = width / (world.cols + world.rows); + this.miniScale = scale; + this.miniRoot = this.scene.add.container(x, y).setDepth(40); + const h = (world.cols + world.rows) * (scale / 2) + 8; + const bg = this.scene.add.rectangle(0, 0, width + 12, h + 12, COLORS.panel, 0.92) + .setOrigin(0, 0).setStrokeStyle(2, COLORS.accent); + this.miniGfx = this.scene.add.graphics(); + this.miniGfx.setPosition(6, 6); + this.miniRoot.add([bg, this.miniGfx]); + this.miniW = width; + this.miniH = h; + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', (pointer, lx, ly) => { + // Invert the mini iso transform. + const mx = lx - 6; + const my = ly - 6; + const a = (mx - world.rows * (scale / 2)) / (scale / 2); + const b = my / (scale / 4); + const c = Math.round((a + b / 2) / 2); + const r = Math.round((b / 2 - a) / 2); + if (inBounds(world, c, r)) { + this.centerOn(c, r); + this.cb.onMinimapJump?.(); + } + }); + this.refreshMinimap(); + } + + refreshMinimap() { + if (!this.miniGfx) return; + const { world } = this.state; + const scale = this.miniScale; + const explored = this.humanIdx >= 0 ? this.state.explored[this.humanIdx] : null; + const g = this.miniGfx; + g.clear(); + for (let r = 0; r < world.rows; r += 1) { + for (let c = 0; c < world.cols; c += 1) { + const idx = tileIndex(world, c, r); + const px = (world.rows + c - r) * (scale / 2); + const py = (c + r) * (scale / 4); + if (explored && !explored[idx]) { + g.fillStyle(0x05060a, 1); + } else { + const terr = this.rules.terrainList[world.terrain[idx]]; + g.fillStyle(Phaser.Display.Color.HexStringToColor(terr.color).color, 1); + } + g.fillRect(px, py, Math.max(1.5, scale / 2), Math.max(1.5, scale / 2)); + } + } + for (const city of this.state.cities) { + const idx = tileIndex(world, city.x, city.y); + if (explored && !explored[idx]) continue; + const px = (world.rows + city.x - city.y) * (scale / 2); + const py = (city.x + city.y) * (scale / 4); + g.fillStyle(Phaser.Display.Color.HexStringToColor(this.state.civs[city.civ].color).color, 1); + g.fillRect(px - 1, py - 1, 4, 4); + } + } +} + +function specialColor(id) { + const map = { + buffalo: 0x8a5a2a, wheat: 0xe8c84a, pheasant: 0xc06030, silk: 0xe8e8f0, + coal: 0x30302e, wine: 0x7a2050, gold: 0xffd700, iron: 0x8a8a92, + oasis: 0x30a060, oil: 0x1a1a1a, game: 0x9a6a3a, furs: 0xb0885a, + ivory: 0xf0ead8, glacieroil: 0x1a1a1a, peat: 0x5a4a2a, spice: 0xd07030, + gems: 0x30c0c0, fruit: 0xe07040, fish: 0x60a0e0, whales: 0x4060a0, + }; + return map[id] ?? 0xffffff; +} diff --git a/src/games/civilization/CivilizationRules.js b/src/games/civilization/CivilizationRules.js new file mode 100644 index 0000000..ea3bb3b --- /dev/null +++ b/src/games/civilization/CivilizationRules.js @@ -0,0 +1,153 @@ +// Civilization — rules compiler. Pure data module, no Phaser imports, so it can +// run headless in Node (tools/verifyCivilization.js) and in the browser scene. +// +// compileRules(json) validates data/civilization-rules.json and returns an +// indexed, derived rule set the engine and UI both consume. + +export function compileRules(json) { + const errors = []; + const need = (cond, msg) => { if (!cond) errors.push(msg); }; + + need(Array.isArray(json.techs) && json.techs.length > 0, 'techs missing'); + need(Array.isArray(json.units) && json.units.length > 0, 'units missing'); + need(Array.isArray(json.terrains) && json.terrains.length > 0, 'terrains missing'); + need(Array.isArray(json.buildings) && json.buildings.length > 0, 'buildings missing'); + need(Array.isArray(json.governments) && json.governments.length > 0, 'governments missing'); + need(Array.isArray(json.difficulties) && json.difficulties.length > 0, 'difficulties missing'); + if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); + + const byId = (list, label) => { + const map = {}; + for (const item of list) { + need(typeof item.id === 'string' && item.id.length > 0, `${label} entry missing id`); + need(!map[item.id], `${label} duplicate id ${item.id}`); + map[item.id] = item; + } + return map; + }; + + const techs = byId(json.techs, 'tech'); + const units = byId(json.units, 'unit'); + const terrains = byId(json.terrains, 'terrain'); + const specials = byId(json.specials ?? [], 'special'); + const buildings = byId(json.buildings, 'building'); + const governments = byId(json.governments, 'government'); + const difficulties = byId(json.difficulties, 'difficulty'); + const improvements = byId(json.improvements ?? [], 'improvement'); + const worldSizes = byId(json.worldSizes ?? [], 'worldSize'); + + // --- tech graph validation: prereqs resolve, <=2 each, acyclic, all reachable + for (const t of json.techs) { + need(Array.isArray(t.prereqs) && t.prereqs.length <= 2, `tech ${t.id} needs 0-2 prereqs`); + for (const p of t.prereqs) need(!!techs[p], `tech ${t.id} prereq ${p} unknown`); + need(['ancient', 'medieval', 'industrial', 'modern'].includes(t.era), `tech ${t.id} bad era`); + } + if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); + + // Topological rank: rank 0 = no prereqs; rank(t) = 1 + max(rank(prereqs)). + // Also proves acyclicity and reachability (unrankable => cycle or dangling). + const rank = {}; + let assigned = 0; + let progress = true; + while (progress) { + progress = false; + for (const t of json.techs) { + if (rank[t.id] !== undefined) continue; + if (t.prereqs.every((p) => rank[p] !== undefined)) { + rank[t.id] = t.prereqs.length ? 1 + Math.max(...t.prereqs.map((p) => rank[p])) : 0; + assigned += 1; + progress = true; + } + } + } + need(assigned === json.techs.length, + `tech graph has a cycle or unreachable techs (${json.techs.length - assigned} unranked)`); + + // --- cross references + for (const u of json.units) { + if (u.prereq) need(!!techs[u.prereq], `unit ${u.id} prereq tech ${u.prereq} unknown`); + if (u.obsoletedBy) need(!!units[u.obsoletedBy], `unit ${u.id} obsoletedBy ${u.obsoletedBy} unknown`); + need(['land', 'sea', 'air', 'project'].includes(u.domain), `unit ${u.id} bad domain`); + } + for (const b of json.buildings) { + if (b.prereq) need(!!techs[b.prereq], `building ${b.id} prereq tech ${b.prereq} unknown`); + if (b.requires) need(!!buildings[b.requires], `building ${b.id} requires ${b.requires} unknown`); + } + for (const g of json.governments) { + if (g.prereq) need(!!techs[g.prereq], `government ${g.id} prereq tech ${g.prereq} unknown`); + } + for (const s of json.specials ?? []) { + need(!!terrains[s.terrain], `special ${s.id} terrain ${s.terrain} unknown`); + } + for (const t of json.terrains) { + if (t.transform) need(!!terrains[t.transform], `terrain ${t.id} transform ${t.transform} unknown`); + } + for (const imp of json.improvements ?? []) { + if (imp.prereq) need(!!techs[imp.prereq], `improvement ${imp.id} prereq tech ${imp.prereq} unknown`); + if (imp.requires) need(!!improvements[imp.requires], `improvement ${imp.id} requires ${imp.requires} unknown`); + } + if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); + + // --- derived: what each tech unlocks (for UI hovers and the "every tech + // matters" verify check) + const gates = {}; + for (const id of Object.keys(techs)) gates[id] = { units: [], buildings: [], governments: [], improvements: [], prereqOf: [] }; + for (const u of json.units) if (u.prereq) gates[u.prereq].units.push(u.id); + for (const b of json.buildings) if (b.prereq) gates[b.prereq].buildings.push(b.id); + for (const g of json.governments) if (g.prereq) gates[g.prereq].governments.push(g.id); + for (const imp of json.improvements ?? []) if (imp.prereq) gates[imp.prereq].improvements.push(imp.id); + for (const t of json.techs) for (const p of t.prereqs) gates[p].prereqOf.push(t.id); + + // Specials grouped by terrain for worldgen. + const specialsByTerrain = {}; + for (const s of json.specials ?? []) { + (specialsByTerrain[s.terrain] ??= []).push(s); + } + + return { + version: json.version ?? 1, + raw: json, + techs, units, terrains, specials, buildings, governments, difficulties, + improvements, worldSizes, + techList: json.techs, + unitList: json.units, + terrainList: json.terrains, + specialList: json.specials ?? [], + buildingList: json.buildings, + governmentList: json.governments, + difficultyList: json.difficulties, + improvementList: json.improvements ?? [], + worldSizeList: json.worldSizes ?? [], + techRank: rank, + techGates: gates, + specialsByTerrain, + grasslandShieldFrame: json.grasslandShieldFrame ?? 1, + spaceship: json.spaceship, + playerColors: json.playerColors, + cityNames: json.cityNames, + yearCurve: json.yearCurve, + }; +} + +// Beaker cost of the (n+1)-th tech when n techs are already known. +export function techCost(nKnown, researchFactor = 1) { + return Math.round((10 + 10 * (nKnown + 1)) * researchFactor); +} + +// Civ II-style turn -> calendar year. +export function turnToYear(turn, yearCurve) { + let year = -4000; + for (let i = 0; i < turn; i += 1) { + let step = 1; + for (const seg of yearCurve) { + if (year < seg.until) { step = seg.step; break; } + } + year += step; + if (year === 0) year = 1; // no year zero + } + return year; +} + +export function formatYear(year) { + return year < 0 ? `${-year} BC` : `${year} AD`; +} diff --git a/src/games/civilization/CivilizationScreens.js b/src/games/civilization/CivilizationScreens.js new file mode 100644 index 0000000..16c1952 --- /dev/null +++ b/src/games/civilization/CivilizationScreens.js @@ -0,0 +1,412 @@ +// Civilization — secondary screens: tech tree, diplomacy (with the standard +// opponents' mood videos via Portrait.js), spaceship status, victory overlay. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { createOpponentPortrait } from '../../ui/Portrait.js'; +import * as Logic from './CivilizationLogic.js'; + +const FONT = '"Julius Sans One"'; +const ERAS = ['ancient', 'medieval', 'industrial', 'modern']; +const ERA_LABEL = { ancient: 'ANCIENT', medieval: 'MEDIEVAL', industrial: 'INDUSTRIAL', modern: 'MODERN' }; + +function modalShell(scene, title, onClose, { width = 1700, height = 940 } = {}) { + const root = scene.add.container(0, 0).setDepth(65); + const dim = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65) + .setInteractive(); + const panel = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, width, height, COLORS.panel) + .setStrokeStyle(3, COLORS.accent); + const titleTxt = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - height / 2 + 34, title, { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex, + }).setOrigin(0.5); + root.add([dim, panel, titleTxt]); + const close = () => { root.destroy(true); onClose(); }; + const closeBtn = new Button(scene, GAME_WIDTH / 2 + width / 2 - 70, GAME_HEIGHT / 2 - height / 2 + 40, + '✕', close, { width: 60, height: 44, fontSize: 22, variant: 'ghost' }); + root.add(closeBtn); + return { root, close }; +} + +// --------------------------------------------------------------------------- +// Tech tree + +export function openTechScreen(scene, rules, state, onClose) { + const civ = state.civs[state.humanIndex]; + const { root, close } = modalShell(scene, 'RESEARCH', onClose); + const width = 1700; + const height = 940; + const left = GAME_WIDTH / 2 - width / 2 + 30; + const top = GAME_HEIGHT / 2 - height / 2 + 70; + + const cost = Logic.currentResearchCost(rules, state, civ); + const current = civ.researching + ? `Researching: ${rules.techs[civ.researching].name} (${civ.beakers}/${cost} beakers)` + : 'Pick a technology to research'; + root.add(scene.add.text(GAME_WIDTH / 2, top - 6, current, { + fontFamily: FONT, fontSize: '21px', color: COLORS.goldHex, + }).setOrigin(0.5)); + + // Scrollable grid: 4 era columns, techs sorted by rank inside each era. + const scrollArea = scene.add.container(0, 0); + root.add(scrollArea); + const maskShape = scene.make.graphics({ add: false }); + maskShape.fillStyle(0xffffff); + maskShape.fillRect(left, top + 20, width - 60, height - 130); + scrollArea.setMask(maskShape.createGeometryMask()); + + const available = new Set(Logic.availableTechs(rules, civ).map((t) => t.id)); + const colW = (width - 60) / 4; + const rowH = 42; + let maxRows = 0; + ERAS.forEach((era, col) => { + const list = rules.techList.filter((t) => t.era === era) + .sort((a, b) => rules.techRank[a.id] - rules.techRank[b.id]); + maxRows = Math.max(maxRows, list.length); + const cx = left + col * colW; + scrollArea.add(scene.add.text(cx + colW / 2, top + 34, ERA_LABEL[era], { + fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + list.forEach((t, row) => { + const y = top + 70 + row * rowH; + const known = !!civ.known[t.id] || (t.repeatable && civ.futureCount > 0); + const canPick = available.has(t.id); + const isCurrent = civ.researching === t.id; + const bg = known ? 0x24401f : canPick ? 0x3a3222 : 0x181510; + const stroke = isCurrent ? COLORS.gold : known ? 0x4a9e44 : canPick ? COLORS.accent : COLORS.muted; + const rect = scene.add.rectangle(cx + colW / 2, y, colW - 18, rowH - 8, bg) + .setStrokeStyle(isCurrent ? 3 : 1, stroke, canPick || known || isCurrent ? 1 : 0.4); + const name = t.repeatable && civ.futureCount > 0 ? `${t.name} (${civ.futureCount})` : t.name; + const txt = scene.add.text(cx + colW / 2 - (colW - 18) / 2 + 10, y, name, { + fontFamily: FONT, fontSize: '16px', + color: known ? '#9fdf9a' : canPick ? COLORS.textHex : COLORS.mutedHex, + }).setOrigin(0, 0.5); + scrollArea.add(rect); + scrollArea.add(txt); + // Gate summary on hover. + const g = rules.techGates[t.id]; + const unlocks = [ + ...g.units.map((u) => rules.units[u].name), + ...g.buildings.map((b) => rules.buildings[b].name), + ...g.governments.map((gv) => rules.governments[gv].name), + ...g.improvements.map((im) => rules.improvements[im].name), + ]; + rect.setInteractive({ useHandCursor: canPick }); + rect.on('pointerover', () => { + hoverText.setText(`${t.name}${t.prereqs.length ? ` ⟵ ${t.prereqs.map((p) => rules.techs[p].name).join(' + ')}` : ''}` + + (unlocks.length ? `\nUnlocks: ${unlocks.join(', ')}` : '')); + }); + rect.on('pointerout', () => hoverText.setText('')); + if (canPick) { + rect.on('pointerdown', () => { + Logic.setResearch(rules, state, civ, t.id); + close(); + }); + } + }); + }); + + const hoverText = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + height / 2 - 46, '', { + fontFamily: FONT, fontSize: '17px', color: COLORS.goldHex, align: 'center', + }).setOrigin(0.5); + root.add(hoverText); + + // Wheel scroll for tall columns. + const contentH = 70 + maxRows * rowH; + const viewH = height - 130; + let scrollY = 0; + scene.input.on('wheel', onWheel); + function onWheel(pointer, objs, dx, dy) { + scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, Math.max(0, contentH - viewH)); + scrollArea.y = -scrollY; + } + root.once('destroy', () => scene.input.off('wheel', onWheel)); +} + +// --------------------------------------------------------------------------- +// Diplomacy + +export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose) { + const human = state.humanIndex; + const civ = state.civs[human]; + let portrait = null; + const { root, close } = modalShell(scene, 'DIPLOMACY', () => { + portrait?.destroy(); + onClose(); + }); + const width = 1700; + const height = 940; + const left = GAME_WIDTH / 2 - width / 2 + 40; + const top = GAME_HEIGHT / 2 - height / 2 + 80; + + const contacts = state.civs.filter((c) => c.id !== human && c.alive + && civ.relations[c.id] !== 'nocontact'); + + if (!contacts.length) { + root.add(scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, + 'You have not met any other civilizations yet.\nSend units out to explore the world.', { + fontFamily: FONT, fontSize: '26px', color: COLORS.mutedHex, align: 'center', + }).setOrigin(0.5)); + return; + } + + let detail = scene.add.container(0, 0); + root.add(detail); + let selected = contacts[0]; + + // Left rail: contacted civs. + contacts.forEach((other, i) => { + const y = top + 30 + i * 74; + const rel = civ.relations[other.id]; + const rect = scene.add.rectangle(left + 190, y, 380, 64, 0x181510) + .setStrokeStyle(2, other === selected ? COLORS.gold : COLORS.muted, 0.9); + const mood = Logic.attitudeMood(civ.attitude[other.id] ?? 0); + const moodDot = { upset: 0xe06c75, idle: 0xc8a84b, happy: 0x4a9e44 }[mood]; + const dot = scene.add.circle(left + 30, y, 9, moodDot); + const txt = scene.add.text(left + 52, y, `${other.name}\n${relLabel(rel)}`, { + fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, lineSpacing: 3, + }).setOrigin(0, 0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { selected = other; drawDetail(); }); + root.add([rect, dot, txt]); + }); + + function relLabel(rel) { + return { contact: 'No treaty', war: 'AT WAR', ceasefire: 'Cease-fire', peace: 'Peace treaty', alliance: 'Alliance' }[rel] ?? rel; + } + + function drawDetail() { + portrait?.destroy(); + portrait = null; + detail.destroy(true); + detail = scene.add.container(0, 0); + root.add(detail); + const other = selected; + const rel = civ.relations[other.id]; + const attitude = civ.attitude[other.id] ?? 0; + const mood = Logic.attitudeMood(attitude); + const cx = left + 700; + + // Video portrait with the character's current mood. + const opData = opponentsData.find((o) => o.id === other.leaderId) + ?? { id: other.leaderId, name: other.name, spriteIndex: 0 }; + try { + portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: false }); + if (mood !== 'idle') portrait.playEmotion(mood); + } catch (_) { /* portrait optional */ } + + const moodWord = { upset: 'is furious with you', idle: 'is indifferent', happy: 'is friendly' }[mood]; + detail.add(scene.add.text(cx, top + 330, + `${other.name} of the ${other.name}ites ${moodWord}.\nStatus: ${relLabel(rel)}`, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5, 0)); + + // Their known-tech / power intel. + detail.add(scene.add.text(cx, top + 410, + `Technologies: ${Logic.knownCount(other)} · Cities: ${Logic.civCities(state, other.id).length}`, { + fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5, 0)); + + // Action buttons. + const actions = []; + if (rel === 'war') actions.push(['PROPOSE CEASE-FIRE', () => propose('ceasefire')]); + if (rel === 'ceasefire') actions.push(['PROPOSE PEACE', () => propose('peace')]); + if (rel === 'contact') actions.push(['PROPOSE PEACE', () => propose('peace')]); + if (rel === 'peace') actions.push(['PROPOSE ALLIANCE', () => propose('alliance')]); + if (rel !== 'war') actions.push(['DECLARE WAR', () => { + Logic.declareWar(rules, state, human, other.id); + drawDetail(); + }]); + actions.push(['GIFT 50 GOLD', () => { + if (Logic.giftGold(state, human, other.id, 50)) drawDetail(); + }]); + actions.push(['EXCHANGE TECH', () => openExchange(other)]); + actions.push(['GIFT TECH', () => openGift(other)]); + + actions.forEach(([label, fn], i) => { + const bx = cx - 340 + (i % 2) * 360; + const by = top + 480 + Math.floor(i / 2) * 66; + detail.add(new Button(scene, bx + 170, by, label, fn, { width: 330, height: 52, fontSize: 18 })); + }); + + function propose(kind) { + if (!Logic.canPropose(state, human, other.id, kind)) return; + const accepted = respondToProposal(rules, state, other.id, human, kind); + if (accepted) { + Logic.applyTreaty(state, human, other.id, kind); + portrait?.playEmotion('happy'); + } else { + portrait?.playEmotion('upset'); + Logic.bumpAttitude(state, other.id, human, -3); + } + scene.time.delayedCall(900, drawDetail); + } + + function openGift(target) { + const mine = Object.keys(civ.known).filter((t) => !target.known[t]); + pickTech(scene, rules, detail, cx, top + 120, 'GIFT A TECHNOLOGY', mine, (techId) => { + Logic.giftTech(rules, state, human, target.id, techId); + portrait?.playEmotion('happy'); + drawDetail(); + }); + } + + function openExchange(target) { + const mine = Object.keys(civ.known).filter((t) => !target.known[t]); + const theirs = Object.keys(target.known).filter((t) => !civ.known[t]); + if (!mine.length || !theirs.length) return; + pickTech(scene, rules, detail, cx, top + 120, 'OFFER WHICH TECH?', mine, (giveId) => { + pickTech(scene, rules, detail, cx, top + 120, 'ASK FOR WHICH TECH?', theirs, (getId) => { + const ok = respondToProposal(rules, state, target.id, human, 'exchange', { giveId, getId }); + if (ok) { + Logic.exchangeTechs(rules, state, human, target.id, giveId, getId); + portrait?.playEmotion('happy'); + } else { + portrait?.playEmotion('upset'); + } + scene.time.delayedCall(900, drawDetail); + }); + }); + } + } + + drawDetail(); +} + +// Small chooser list overlaid on the diplomacy panel. +function pickTech(scene, rules, parent, cx, cy, title, techIds, onPick) { + const box = scene.add.container(0, 0); + parent.add(box); + const h = Math.min(560, 80 + techIds.length * 34); + box.add(scene.add.rectangle(cx + 480, cy + h / 2, 360, h, 0x11100c, 0.98) + .setStrokeStyle(2, COLORS.accent)); + box.add(scene.add.text(cx + 480, cy + 24, title, { + fontFamily: FONT, fontSize: '18px', color: COLORS.goldHex, + }).setOrigin(0.5)); + techIds.slice(0, 14).forEach((id, i) => { + const t = scene.add.text(cx + 480, cy + 58 + i * 34, rules.techs[id].name, { + fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, + }).setOrigin(0.5).setInteractive({ useHandCursor: true }); + t.on('pointerover', () => t.setColor(COLORS.goldHex)); + t.on('pointerout', () => t.setColor(COLORS.textHex)); + t.on('pointerdown', () => { box.destroy(true); onPick(id); }); + box.add(t); + }); +} + +// --------------------------------------------------------------------------- +// Spaceship + +export function openSpaceshipScreen(scene, rules, state, onClose) { + const { root, close } = modalShell(scene, 'SPACESHIP TO ALPHA CENTAURI', onClose, + { width: 1100, height: 760 }); + const human = state.humanIndex; + const civ = state.civs[human]; + const cx = GAME_WIDTH / 2; + const top = GAME_HEIGHT / 2 - 380 + 80; + const need = rules.spaceship; + + const rows = [ + ['Structural', civ.spaceship.structural, need.structuralNeeded, 'ssstructural'], + ['Components', civ.spaceship.component, need.componentsNeeded, 'sscomponent'], + ['Modules', civ.spaceship.module, need.modulesNeeded, 'ssmodule'], + ]; + rows.forEach(([label, have, needed, unitId], i) => { + const y = top + 60 + i * 90; + const unit = rules.units[unitId]; + const gated = unit.prereq && !civ.known[unit.prereq]; + root.add(scene.add.text(cx - 380, y, label, { + fontFamily: FONT, fontSize: '26px', color: COLORS.textHex, + }).setOrigin(0, 0.5)); + for (let s = 0; s < needed; s += 1) { + const filled = s < have; + root.add(scene.add.rectangle(cx - 120 + s * 56, y, 44, 44, + filled ? 0x4a9e44 : 0x181510).setStrokeStyle(2, filled ? 0x9fdf9a : COLORS.muted)); + } + root.add(scene.add.text(cx + 400, y, + gated ? `needs ${rules.techs[unit.prereq].name}` : `${have}/${needed}`, { + fontFamily: FONT, fontSize: '20px', color: gated ? COLORS.mutedHex : COLORS.goldHex, + }).setOrigin(1, 0.5)); + }); + + const shipY = top + 360; + if (civ.spaceship.launched) { + root.add(scene.add.text(cx, shipY, `Spaceship en route!\nArrival: ${civ.spaceship.arrivalTurn - state.turn} turns`, { + fontFamily: FONT, fontSize: '28px', color: '#9fdf9a', align: 'center', lineSpacing: 8, + }).setOrigin(0.5)); + root.add(scene.add.text(cx, shipY + 100, 'Guard your capital — losing it destroys the ship.', { + fontFamily: FONT, fontSize: '19px', color: COLORS.dangerHex, + }).setOrigin(0.5)); + } else { + const ready = civ.spaceship.structural >= need.structuralNeeded + && civ.spaceship.component >= need.componentsNeeded + && civ.spaceship.module >= need.modulesNeeded; + const launch = new Button(scene, cx, shipY, ready ? 'LAUNCH!' : 'LAUNCH (parts missing)', () => { + if (Logic.launchSpaceship(rules, state, civ)) close(); + }, { width: 420, height: 64, fontSize: 26, variant: ready ? 'solid' : 'ghost' }); + root.add(launch); + } + + // Rival intel. + const rivals = state.civs.filter((c) => c.alive && c.id !== human + && state.civs[human].relations[c.id] !== 'nocontact'); + const intel = rivals.map((c) => { + const s = c.spaceship; + if (s.launched) return `${c.name}: LAUNCHED (arrives turn ${s.arrivalTurn})`; + const parts = s.structural + s.component + s.module; + return `${c.name}: ${parts ? `${parts} parts built` : 'no spaceship'}`; + }); + root.add(scene.add.text(cx, shipY + 180, intel.join('\n') || 'No rival intelligence.', { + fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5, 0)); +} + +// --------------------------------------------------------------------------- +// Victory / defeat + +export function showVictoryOverlay(scene, rules, state, opponentsData, onPlayAgain) { + const root = scene.add.container(0, 0).setDepth(90); + root.add(scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.8) + .setInteractive()); + const human = state.humanIndex; + const winnerIdx = state.over?.winner ?? -1; + const winner = winnerIdx >= 0 ? state.civs[winnerIdx] : null; + const humanWon = winnerIdx === human; + const mode = state.over?.type; + + const title = humanWon + ? (mode === 'spaceship' ? 'YOUR SHIP REACHES ALPHA CENTAURI!' : 'THE WORLD IS YOURS!') + : (winner ? `${winner.name.toUpperCase()} HAS ${mode === 'spaceship' ? 'REACHED THE STARS' : 'CONQUERED THE WORLD'}` : 'CIVILIZATION HAS FALLEN'); + root.add(scene.add.text(GAME_WIDTH / 2, 280, title, { + fontFamily: 'Righteous', fontSize: '52px', + color: humanWon ? COLORS.goldHex : COLORS.dangerHex, align: 'center', + wordWrap: { width: 1500 }, + }).setOrigin(0.5)); + + if (winner) { + const opData = opponentsData.find((o) => o.id === winner.leaderId) + ?? { id: winner.leaderId, name: winner.name, spriteIndex: 0 }; + try { + const p = createOpponentPortrait(scene, opData, GAME_WIDTH / 2, 480, 120, 95, { playIntro: false }); + p.playEmotion(humanWon ? 'happy' : 'happy'); // the winner celebrates either way + root.once('destroy', () => p.destroy()); + } catch (_) { /* optional */ } + } + + // Final scores. + const scores = state.civs + .map((c) => ({ name: c.name, score: Logic.civScore(rules, state, c.id), alive: c.alive })) + .sort((a, b) => b.score - a.score) + .map((s, i) => `${i + 1}. ${s.name}${s.alive ? '' : ' †'} — ${s.score}`); + root.add(scene.add.text(GAME_WIDTH / 2, 660, `FINAL SCORES\n${scores.join('\n')}`, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, align: 'center', lineSpacing: 8, + }).setOrigin(0.5, 0)); + + root.add(new Button(scene, GAME_WIDTH / 2 - 160, GAME_HEIGHT - 120, 'PLAY AGAIN', () => { + root.destroy(true); + onPlayAgain(); + }, { width: 280, height: 60, fontSize: 24 })); + root.add(new Button(scene, GAME_WIDTH / 2 + 160, GAME_HEIGHT - 120, 'MAIN MENU', () => { + scene.scene.start('GameMenu'); + }, { width: 280, height: 60, fontSize: 24, variant: 'ghost' })); +} diff --git a/src/games/civilization/CivilizationWorldGen.js b/src/games/civilization/CivilizationWorldGen.js new file mode 100644 index 0000000..b9051b5 --- /dev/null +++ b/src/games/civilization/CivilizationWorldGen.js @@ -0,0 +1,273 @@ +// Civilization — seeded world generation. Headless (no Phaser). +// +// Square grid (col,row), rendered isometrically by the view. Flat world (no +// east-west wrap). Terrain from layered value noise: elevation picks land/sea +// (exact land fraction via percentile threshold), a ridged channel places +// hills/mountains, latitude bands place tundra/glacier/desert, and a moisture +// channel places forest/jungle/swamp. Specials sit on a Civ II-style diagonal +// lattice; huts scatter on land away from starting positions. + +export function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Smooth value noise in [0,1]: random lattice at `freq` cells, cosine-blended. +function makeNoise(rng, cols, rows, octaves = 3, baseFreq = 5) { + const layers = []; + for (let o = 0; o < octaves; o += 1) { + const freq = baseFreq * (1 << o); + const gw = freq + 2; + const gh = Math.max(2, Math.round(freq * (rows / cols))) + 2; + const grid = []; + for (let i = 0; i < gw * gh; i += 1) grid.push(rng()); + layers.push({ grid, gw, gh, amp: 1 / (1 << o) }); + } + const smooth = (t) => (1 - Math.cos(t * Math.PI)) / 2; + return (x, y) => { + let sum = 0; + let ampSum = 0; + for (const { grid, gw, gh, amp } of layers) { + const fx = (x / cols) * (gw - 2); + const fy = (y / rows) * (gh - 2); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = smooth(fx - x0); + const ty = smooth(fy - y0); + const v00 = grid[y0 * gw + x0]; + const v10 = grid[y0 * gw + x0 + 1]; + const v01 = grid[(y0 + 1) * gw + x0]; + const v11 = grid[(y0 + 1) * gw + x0 + 1]; + sum += amp * ((v00 * (1 - tx) + v10 * tx) * (1 - ty) + (v01 * (1 - tx) + v11 * tx) * ty); + ampSum += amp; + } + return sum / ampSum; + }; +} + +// Civ II-ish deterministic lattices (independent of RNG so they look "placed"). +export function shieldGrassAt(x, y) { return ((x * 2 + y) % 4) < 2; } +function specialSlotAt(x, y, offset) { + return ((x * 3 + y * 5 + offset) % 16) === 0; +} +function specialPick(x, y) { return ((x >> 1) + (y >> 1)) % 2; } + +// Improvement bit flags stored per tile. +export const IMP = { ROAD: 1, RAILROAD: 2, IRRIGATION: 4, FARMLAND: 8, MINE: 16, FORTRESS: 32 }; + +export function generateWorld(rules, { sizeId = 'medium', seed = 1, numCivs = 4 } = {}) { + const size = rules.worldSizes[sizeId]; + if (!size) throw new Error(`unknown world size ${sizeId}`); + const { cols, rows } = size; + const terrainIndex = {}; + rules.terrainList.forEach((t, i) => { terrainIndex[t.id] = i; }); + const T = terrainIndex; + + // Retry with derived seeds until the continent constraint holds. + for (let attempt = 0; attempt < 12; attempt += 1) { + const rng = mulberry32((seed * 7919 + attempt * 104729) >>> 0); + const world = tryGenerate(rules, cols, rows, T, rng, numCivs, seed, attempt); + if (world) { + world.sizeId = sizeId; + world.seed = seed; + return world; + } + } + throw new Error(`worldgen failed after 12 attempts (seed ${seed}, ${sizeId}, ${numCivs} civs)`); +} + +function tryGenerate(rules, cols, rows, T, rng, numCivs, seed, attempt) { + const n = cols * rows; + const elevNoise = makeNoise(rng, cols, rows, 3, 4); + const moistNoise = makeNoise(rng, cols, rows, 3, 5); + const ridgeNoise = makeNoise(rng, cols, rows, 2, 6); + const splitNoise = makeNoise(rng, cols, rows, 2, 7); + + // Elevation with edge falloff so continents pull away from the map border. + const elev = new Float64Array(n); + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + const ex = Math.min(x, cols - 1 - x) / (cols / 2); + const ey = Math.min(y, rows - 1 - y) / (rows / 2); + const falloff = Math.min(1, Math.min(ex, ey) * 3.2); + elev[y * cols + x] = elevNoise(x, y) * (0.35 + 0.65 * falloff); + } + } + + // Land threshold at the exact percentile for the target land fraction. + const landTarget = 0.30 + (rng() - 0.5) * 0.05; // 27.5%..32.5% + const sorted = Array.from(elev).sort((a, b) => a - b); + const threshold = sorted[Math.floor(n * (1 - landTarget))]; + + const terrain = new Array(n); + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + const i = y * cols + x; + if (elev[i] < threshold) { terrain[i] = T.ocean; continue; } + const lat = Math.abs(y - (rows - 1) / 2) / ((rows - 1) / 2); + const moist = moistNoise(x, y); + const ridge = 1 - Math.abs(2 * ridgeNoise(x, y) - 1); + if (lat > 0.92) { terrain[i] = T.glacier; continue; } + if (ridge > 0.86) { terrain[i] = T.mountains; continue; } + if (ridge > 0.74) { terrain[i] = T.hills; continue; } + if (lat > 0.78) { terrain[i] = moist > 0.6 ? T.tundra : (rng() < 0.4 ? T.glacier : T.tundra); continue; } + if (lat < 0.35 && moist < 0.32) { terrain[i] = T.desert; continue; } + if (lat < 0.42 && moist > 0.74) { terrain[i] = T.jungle; continue; } + if (moist > 0.82) { terrain[i] = T.swamp; continue; } + if (moist > 0.56) { terrain[i] = T.forest; continue; } + terrain[i] = splitNoise(x, y) > 0.48 ? T.grassland : T.plains; + } + } + + // Continent labels (4-neighbour flood fill over land). + const continent = new Int16Array(n).fill(-1); + const contSizes = []; + for (let i = 0; i < n; i += 1) { + if (terrain[i] === T.ocean || continent[i] !== -1) continue; + const label = contSizes.length; + let count = 0; + const stack = [i]; + continent[i] = label; + while (stack.length) { + const cur = stack.pop(); + count += 1; + const cx = cur % cols; + const cy = (cur / cols) | 0; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; + const ni = ny * cols + nx; + if (terrain[ni] !== T.ocean && continent[ni] === -1) { + continent[ni] = label; + stack.push(ni); + } + } + } + contSizes.push(count); + } + const landCount = terrain.reduce((acc, t) => acc + (t === T.ocean ? 0 : 1), 0); + const largest = Math.max(0, ...contSizes); + if (landCount === 0 || largest / landCount < 0.15) return null; // reroll + + // Specials on the diagonal lattice (grassland gets shield variant instead). + const latticeOffset = Math.floor(rng() * 16); + const special = new Array(n).fill(-1); + const specialIndexById = {}; + rules.specialList.forEach((s, i) => { specialIndexById[s.id] = i; }); + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + const i = y * cols + x; + const terr = rules.terrainList[terrain[i]]; + if (terr.id === 'grassland') continue; + if (!specialSlotAt(x, y, latticeOffset)) continue; + const opts = rules.specialsByTerrain[terr.id]; + if (!opts || !opts.length) continue; + const pick = opts[specialPick(x, y) % opts.length]; + special[i] = specialIndexById[pick.id]; + } + } + + // Starting positions: quality-scored land tiles, greedy farthest-point. + const starts = pickStarts(rules, cols, rows, T, terrain, special, continent, contSizes, numCivs, rng); + if (!starts) return null; + + // Goody huts: ~1 per 40 land tiles, away from starts, not on glacier. + const huts = new Uint8Array(n); + const hutTarget = Math.floor(landCount / 40); + let placed = 0; + for (let tries = 0; tries < hutTarget * 30 && placed < hutTarget; tries += 1) { + const x = Math.floor(rng() * cols); + const y = Math.floor(rng() * rows); + const i = y * cols + x; + if (terrain[i] === T.ocean || terrain[i] === T.glacier || huts[i]) continue; + if (starts.some(([sx, sy]) => Math.max(Math.abs(sx - x), Math.abs(sy - y)) <= 2)) continue; + huts[i] = 1; + placed += 1; + } + + return { + cols, + rows, + terrain, + special, + improvements: new Array(n).fill(0), + huts: Array.from(huts), + continent: Array.from(continent), + starts, + landFraction: landCount / n, + largestContinentFrac: largest / landCount, + attempt, + }; +} + +// Quality of a would-be city site: yields of the centre + 8 neighbours. +export function siteQuality(rules, world, x, y) { + const { cols, rows } = world; + let q = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; + const i = ny * cols + nx; + const terr = rules.terrainList[world.terrain[i]]; + const spec = world.special[i] >= 0 ? rules.specialList[world.special[i]] : null; + const food = spec ? spec.food : terr.food; + const shield = spec ? spec.shield : terr.shield; + const trade = spec ? spec.trade : terr.trade; + q += food * 3 + shield * 2 + trade; + } + } + return q; +} + +function pickStarts(rules, cols, rows, T, terrain, special, continent, contSizes, numCivs, rng) { + const world = { cols, rows, terrain, special }; + const candidates = []; + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < cols - 1; x += 1) { + const i = y * cols + x; + const terr = rules.terrainList[terrain[i]]; + if (terr.water || terr.id === 'glacier' || terr.id === 'mountains') continue; + if (contSizes[continent[i]] < 8) continue; // no dinghy islands + const q = siteQuality(rules, world, x, y); + candidates.push({ x, y, q }); + } + } + if (candidates.length < numCivs * 4) return null; + candidates.sort((a, b) => b.q - a.q); + const qualityFloor = candidates[Math.floor(candidates.length * 0.5)].q; // top half only + const pool = candidates.filter((c) => c.q >= qualityFloor); + + const minDist = Math.max(6, Math.floor(Math.min(cols, rows) / (numCivs))); + for (let relax = 0; relax < 4; relax += 1) { + const dist = Math.max(4, minDist - relax * 2); + const chosen = []; + // Seed with a random high-quality site, then greedy farthest-point. + const first = pool[Math.floor(rng() * Math.min(pool.length, 20))]; + chosen.push(first); + while (chosen.length < numCivs) { + let best = null; + let bestScore = -1; + for (const c of pool) { + let d = Infinity; + for (const s of chosen) { + d = Math.min(d, Math.max(Math.abs(s.x - c.x), Math.abs(s.y - c.y))); + } + if (d < dist) continue; + const score = d * 10 + c.q * 0.1; + if (score > bestScore) { bestScore = score; best = c; } + } + if (!best) break; + chosen.push(best); + } + if (chosen.length === numCivs) return chosen.map((c) => [c.x, c.y]); + } + return null; +} diff --git a/src/games/civilization/sprites.md b/src/games/civilization/sprites.md new file mode 100644 index 0000000..2e1bc48 --- /dev/null +++ b/src/games/civilization/sprites.md @@ -0,0 +1,280 @@ +# Civilization — Sprite / Art Spec + +Everything in Civilization renders procedurally out of the box, so the game is +fully playable with **no** art: terrain is flat-shaded isometric diamonds with +glyphs, units are colored roundels with two-letter labels, cities are little +box clusters. This document lists the optional sprite sheets you can drop in +to replace those placeholders, with exact dimensions, layouts, and +frame-by-frame maps. + +All art is wired through **`public/data/civilization-artwork.json`**. The scene +checks whether each sheet's texture is loaded; if it is, it uses your frames, +otherwise it falls back to procedural drawing. You never need to touch code — +just add a PNG under `public/assets/images/civilization/` and set its `path` +in that JSON. + +Frame numbering everywhere is **row-major, 0-based**: frame 0 is top-left, +count left-to-right then down to the next row. + +**The isometric diamond**: map tiles occupy a 128 × 64 px diamond. Terrain and +city frames are 128 × 96 px — the bottom 64 px is the diamond footprint, the +top 32 px is headroom for tall features (peaks, trees, towers). The diamond's +four corners touch the frame edges at (64, 32), (128, 64+32), (64, 96), and +(0, 64+32) — i.e. left/right corners at frame-y 64, top corner at frame-y 32, +bottom corner at frame-y 96. Fill the whole diamond opaquely; leave the +headroom transparent except where the feature rises into it. Tiles butt +against each other with no blending in v1 (hard Civ I-style edges). + +> **Future work (not in v1):** terrain blend transitions between neighboring +> tile types, river overlays, and coastline foam. Roads, railroads and +> irrigation are currently vector-drawn; the improvements sheet below covers +> the badge-style overlays only. + +--- + +## 1. Terrain base sheet — `civilization-terrain.png` + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-terrain.png` | +| **Sheet size** | **768 × 192 px** | +| **Frame size** | **128 × 96 px** | +| **Layout** | 6 columns × 2 rows = 12 frames | +| **Status** | ❌ not created — procedural diamonds render meanwhile | +| **JSON** | `terrainSheet` | + +### Frame map + +| Frame | id | Name | Notes | +|---:|---|---|---| +| 0 | `grassland` | Grassland | plain green | +| 1 | — | Grassland (shield) | grassland variant with the little shield mark | +| 2 | `plains` | Plains | dry golden grass | +| 3 | `forest` | Forest | trees rise into the 32px headroom | +| 4 | `hills` | Hills | rolling mounds | +| 5 | `mountains` | Mountains | peaks use full headroom, snow caps | +| 6 | `desert` | Desert | sand, maybe a dune | +| 7 | `tundra` | Tundra | patchy frozen grass | +| 8 | `glacier` | Glacier | ice sheet | +| 9 | `swamp` | Swamp | murky pools, reeds | +| 10 | `jungle` | Jungle | dense canopy, brighter green than forest | +| 11 | `ocean` | Ocean | deep blue, light wave marks | + +Frame indexes are pinned in `data/civilization-rules.json` (`terrains[].frame`, +`grasslandShieldFrame`) — append-only, never renumber. + +--- + +## 2. Special resources sheet — `civilization-resources.png` + +Drawn centered on top of the base terrain tile (badge layer, 64 × 64 centered +on the diamond). + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-resources.png` | +| **Sheet size** | **320 × 256 px** | +| **Frame size** | **64 × 64 px** | +| **Layout** | 5 columns × 4 rows = 20 frames | +| **Status** | ❌ not created — colored dot badges render meanwhile | +| **JSON** | `resourceSheet` | + +### Frame map + +| Frame | id | Name | On terrain | +|---:|---|---|---| +| 0 | `buffalo` | Buffalo | Plains | +| 1 | `wheat` | Wheat | Plains | +| 2 | `pheasant` | Pheasant | Forest | +| 3 | `silk` | Silk | Forest | +| 4 | `coal` | Coal | Hills | +| 5 | `wine` | Wine | Hills | +| 6 | `gold` | Gold | Mountains | +| 7 | `iron` | Iron | Mountains | +| 8 | `oasis` | Oasis | Desert | +| 9 | `oil` | Oil | Desert | +| 10 | `game` | Game | Tundra | +| 11 | `furs` | Furs | Tundra | +| 12 | `ivory` | Ivory | Glacier | +| 13 | `glacieroil` | Oil (arctic) | Glacier | +| 14 | `peat` | Peat | Swamp | +| 15 | `spice` | Spice | Swamp | +| 16 | `gems` | Gems | Jungle | +| 17 | `fruit` | Fruit | Jungle | +| 18 | `fish` | Fish | Ocean | +| 19 | `whales` | Whales | Ocean | + +--- + +## 3. Tile improvements sheet — `civilization-improvements.png` + +Badge overlays drawn centered on the tile. Roads and railroads stay +vector-drawn (they connect dynamically between tiles), so they are NOT here. + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-improvements.png` | +| **Sheet size** | **384 × 64 px** | +| **Frame size** | **64 × 64 px** | +| **Layout** | 6 columns × 1 row = 6 frames | +| **Status** | ❌ not created — vector glyphs render meanwhile | +| **JSON** | `improvementSheet` | + +### Frame map + +| Frame | id | Name | Notes | +|---:|---|---|---| +| 0 | `irrigation` | Irrigation | furrow lines | +| 1 | `farmland` | Farmland | denser irrigation (Refrigeration upgrade) | +| 2 | `mine` | Mine | pit head / spoil heap | +| 3 | `fortress` | Fortress | square rampart outline | +| 4 | `hut` | Goody hut | little thatched hut | +| 5 | `fortify` | Fortify shield | reserved for a unit "fortified" badge | + +--- + +## 4. Units sheet — `civilization-units.png` + +Unit art is **neutral** (no civ color baked in): the game draws a player-color +ring/roundel underneath your frame. Center the unit in the frame with its feet +around y = 52 so it sits on the tile. + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-units.png` | +| **Sheet size** | **512 × 448 px** | +| **Frame size** | **64 × 64 px** | +| **Layout** | 8 columns × 7 rows = 56 frames (51 used, 52–55 reserved) | +| **Status** | ❌ not created — roundel + 2-letter label renders meanwhile | +| **JSON** | `unitSheet` | + +### Frame map + +| Frame | id | Name | | Frame | id | Name | +|---:|---|---|---|---:|---|---| +| 0 | `settlers` | Settlers | | 26 | `cannon` | Cannon | +| 1 | `engineers` | Engineers | | 27 | `artillery` | Artillery | +| 2 | `explorer` | Explorer | | 28 | `howitzer` | Howitzer | +| 3 | `caravan` | Caravan | | 29 | `fighter` | Fighter | +| 4 | `freight` | Freight | | 30 | `bomber` | Bomber | +| 5 | `warriors` | Warriors | | 31 | `helicopter` | Helicopter | +| 6 | `phalanx` | Phalanx | | 32 | `stealthfighter` | Stealth Ftr. | +| 7 | `archers` | Archers | | 33 | `stealthbomber` | Stealth Bmb. | +| 8 | `legion` | Legion | | 34 | `cruisemsl` | Cruise Msl. | +| 9 | `pikemen` | Pikemen | | 35 | `nuclearmsl` | Nuclear Msl. | +| 10 | `musketeers` | Musketeers | | 36 | `trireme` | Trireme | +| 11 | `riflemen` | Riflemen | | 37 | `caravel` | Caravel | +| 12 | `alpinetroops` | Alpine Troops | | 38 | `galleon` | Galleon | +| 13 | `partisans` | Partisans | | 39 | `frigate` | Frigate | +| 14 | `marines` | Marines | | 40 | `ironclad` | Ironclad | +| 15 | `paratroopers` | Paratroopers | | 41 | `destroyer` | Destroyer | +| 16 | `mechinf` | Mech. Inf. | | 42 | `cruiser` | Cruiser | +| 17 | `horsemen` | Horsemen | | 43 | `aegiscruiser` | AEGIS Cruiser | +| 18 | `chariot` | Chariot | | 44 | `battleship` | Battleship | +| 19 | `elephant` | Elephant | | 45 | `submarine` | Submarine | +| 20 | `knights` | Knights | | 46 | `carrier` | Carrier | +| 21 | `crusaders` | Crusaders | | 47 | `transport` | Transport | +| 22 | `dragoons` | Dragoons | | 48 | `ssstructural` | SS Structural | +| 23 | `cavalry` | Cavalry | | 49 | `sscomponent` | SS Component | +| 24 | `armor` | Armor | | 50 | `ssmodule` | SS Module | +| 25 | `catapult` | Catapult | | 51–55 | — | reserved | + +Frame indexes are pinned in `data/civilization-rules.json` (`units[].frame`). + +--- + +## 5. City sheet (classic theme) — `civilization-cities-classic.png` + +Cities scale with population and show walls when City Walls are built. This is +the **classic/western** theme; more themes (e.g. `asian`) drop in later by +adding an entry to `citySheets` in the artwork JSON — the format already +supports it and no code changes are needed. + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-cities-classic.png` | +| **Sheet size** | **512 × 192 px** | +| **Frame size** | **128 × 96 px** (same diamond+headroom as terrain) | +| **Status** | ❌ not created — box-cluster fallback renders meanwhile | +| **Layout** | 4 columns × 2 rows = 8 frames | +| **JSON** | `citySheets.classic` | + +### Frame map + +| Frame | Size tier | Walls | Notes | +|---:|---|---|---| +| 0 | 1–3 (village) | no | a few small buildings | +| 1 | 4–7 (town) | no | denser, a landmark roof | +| 2 | 8–12 (city) | no | multi-story buildings | +| 3 | 13+ (metropolis) | no | grand skyline into the headroom | +| 4 | 1–3 | **yes** | tier 0 ringed by walls | +| 5 | 4–7 | **yes** | | +| 6 | 8–12 | **yes** | | +| 7 | 13+ | **yes** | | + +The player-color banner and name plate are drawn by the game below the tile — +keep the bottom ~6 px of the diamond clear of critical detail. + +--- + +## 6. HUD icons sheet — `civilization-icons.png` + +Small interface icons (yields, governments, spaceship parts, moods). + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-icons.png` | +| **Sheet size** | **480 × 96 px** | +| **Frame size** | **48 × 48 px** | +| **Layout** | 10 columns × 2 rows = 20 frames | +| **Status** | ❌ not created — text labels render meanwhile | +| **JSON** | `iconSheet` | + +### Frame map + +| Frame | id | Notes | | Frame | id | Notes | +|---:|---|---|---|---:|---|---| +| 0 | `food` | wheat sheaf | | 10 | `gov-despotism` | | +| 1 | `shield` | production shield | | 11 | `gov-anarchy` | | +| 2 | `trade` | trade arrow | | 12 | `gov-monarchy` | | +| 3 | `gold` | coin | | 13 | `gov-communism` | | +| 4 | `beaker` | science flask | | 14 | `gov-republic` | | +| 5 | `pop` | citizen head | | 15 | `gov-democracy` | | +| 6 | `ss-structural` | | | 16 | `mood-happy` | diplomacy list dot | +| 7 | `ss-component` | | | 17 | `mood-idle` | | +| 8 | `ss-module` | | | 18 | `mood-upset` | | +| 9 | `vet` | veteran chevron | | 19 | — | reserved | + +Government frames match `governments[].frame` in the rules JSON (10–15). + +--- + +## 7. Menu icon — `game-icons.png` frame 83 + +| | | +|---|---| +| **Path** | `public/assets/images/game-icons.png` (shared sheet) | +| **Sheet size** | 660 × 660 px (15 × 15 grid of 44 × 44 frames) | +| **Frame** | **83** (row 5, col 8) | +| **Status** | ❌ needs painting | + +Suggested motif: a tiny isometric globe/hex of terrain, or a laurel-wreathed +city. The menu shows a generic fallback until painted. + +--- + +## Quick checklist + +| # | File | Size | Priority | +|---|---|---|---| +| 1 | `civilization-terrain.png` | 768×192 | **High** — biggest visual upgrade | +| 2 | `civilization-cities-classic.png` | 512×192 | **High** | +| 3 | `civilization-units.png` | 512×448 | **High** (51 frames — biggest lift) | +| 4 | `civilization-resources.png` | 320×256 | Medium | +| 5 | `civilization-improvements.png` | 384×64 | Medium | +| 6 | `civilization-icons.png` | 480×96 | Low | +| 7 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) | + +After painting a sheet, set its `path` in `public/data/civilization-artwork.json`, +e.g. `"path": "assets/images/civilization/civilization-terrain.png"`, and +reload — no code changes. diff --git a/src/main.js b/src/main.js index cfa7cec..4a9efb6 100644 --- a/src/main.js +++ b/src/main.js @@ -91,6 +91,7 @@ import PeggleGame from './games/peggle/PeggleGame.js'; import PeggleEditor from './games/peggle/PeggleEditor.js'; import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js'; import StarControlGame from './games/starcontrol/StarControlGame.js'; +import CivilizationGame from './games/civilization/CivilizationGame.js'; const config = { type: Phaser.AUTO, @@ -195,6 +196,7 @@ const config = { PeggleEditor, ColoradoDefenseGame, StarControlGame, + CivilizationGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 2a49292..e57bb3a 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 43341de..e6cc171 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -59,6 +59,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('swdbg-artwork', 'data/swdbg-artwork.json'); this.load.json('jumble', 'data/jumble.json'); this.load.json('balatro-artwork', 'data/balatro-artwork.json'); + this.load.json('civilization-artwork', 'data/civilization-artwork.json'); this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json'); this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json'); this.load.json('star-control-ships', 'data/star-control-ships.json'); diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js new file mode 100644 index 0000000..3c1980d --- /dev/null +++ b/tools/verifyCivilization.js @@ -0,0 +1,1149 @@ +// Headless verification for Civilization (Civ II-lite). +// node tools/verifyCivilization.js [--quick] +// Exits non-zero on any failure. +// +// 1. Rules integrity: ids unique, prereqs resolve, tech DAG acyclic & fully +// reachable, every tech gates something or feeds a later tech, frames sane. +// 2. Worldgen: determinism, land fraction, continents, start quality/spacing. +// 3. City fixtures: growth, despotism penalty, corruption, auto-assign, buy math. +// 4. Combat: deterministic fixtures + Monte Carlo vs analytic model. +// 5. Research pacing per difficulty. +// 6. Trade + diplomacy state machine (incl. fuzz). +// 7. Spaceship: launch gating, countdown, capital-capture loss. +// 8. Serialization round-trip. +// 9. AI self-play soak: full games end in victory, invariants hold, perf budget. + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { compileRules, techCost, turnToYear } from '../src/games/civilization/CivilizationRules.js'; +import { generateWorld, siteQuality, shieldGrassAt } from '../src/games/civilization/CivilizationWorldGen.js'; +import * as Logic from '../src/games/civilization/CivilizationLogic.js'; +import * as AI from '../src/games/civilization/CivilizationAI.js'; + +const QUICK = process.argv.includes('--quick'); +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const rulesJson = JSON.parse(readFileSync(join(root, 'data/civilization-rules.json'), 'utf8')); + +let failures = 0; +let passes = 0; +function check(name, cond, detail = '') { + if (cond) { passes += 1; return; } + failures += 1; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} +function section(name) { console.log(`\n== ${name}`); } + +// --------------------------------------------------------------------------- +section('1. rules integrity'); + +let RULES = null; +try { + RULES = compileRules(rulesJson); +} catch (err) { + check('rules compile', false, err.message); +} + +if (RULES) { + check('rules compile', true); + + const techIds = Object.keys(RULES.techs); + check('tech count sane (80+ incl future tech)', techIds.length >= 80, `${techIds.length}`); + check('future tech present & repeatable', RULES.techs.futuretech?.repeatable === true); + + // Cut techs must NOT be present (wonders/happiness/espionage systems removed). + for (const cut of ['theology', 'espionage', 'fundamentalism', 'environmentalism', + 'geneticengineering', 'recycling']) { + check(`cut tech absent: ${cut}`, !RULES.techs[cut]); + } + + // Every tech is reachable (compileRules ranks all) and matters: it gates a + // unit/building/government/improvement or is a prereq of another tech. + for (const t of RULES.techList) { + check(`tech ranked: ${t.id}`, RULES.techRank[t.id] !== undefined); + const g = RULES.techGates[t.id]; + const matters = g.units.length + g.buildings.length + g.governments.length + + g.improvements.length + g.prereqOf.length > 0 || t.repeatable; + check(`tech matters: ${t.id}`, matters); + } + // Rank must respect prereqs. + for (const t of RULES.techList) { + for (const p of t.prereqs) { + check(`rank order ${p} < ${t.id}`, RULES.techRank[p] < RULES.techRank[t.id]); + } + } + + // Roots: the classic 8 starting techs. + const roots = RULES.techList.filter((t) => t.prereqs.length === 0).map((t) => t.id).sort(); + check('8 root techs', roots.length === 8, roots.join(',')); + + // Units. + check('unit count 51', RULES.unitList.length === 51, `${RULES.unitList.length}`); + const unitFrames = new Set(); + for (const u of RULES.unitList) { + check(`unit stats sane: ${u.id}`, u.attack >= 0 && u.attack <= 99 && u.defense >= 0 + && u.move >= 0 && u.hp >= 1 && u.fp >= 1 && u.cost >= 10 && u.cost <= 320); + check(`unit frame unique: ${u.id}`, !unitFrames.has(u.frame), `${u.frame}`); + unitFrames.add(u.frame); + check(`unit frame in sheet: ${u.id}`, u.frame >= 0 && u.frame < 56); + check(`unit abbr: ${u.id}`, typeof u.abbr === 'string' && u.abbr.length === 2); + if (u.domain === 'project') check(`ss unit flagged: ${u.id}`, u.flags.includes('spaceship')); + } + const ssUnits = RULES.unitList.filter((u) => u.domain === 'project'); + check('3 spaceship parts', ssUnits.length === 3); + check('spaceship config', RULES.spaceship.structuralNeeded === 8 + && RULES.spaceship.componentsNeeded === 4 && RULES.spaceship.modulesNeeded === 3 + && RULES.spaceship.travelTurns > 0); + + // Terrain + specials. + check('11 terrains', RULES.terrainList.length === 11); + check('exactly one water terrain', RULES.terrainList.filter((t) => t.water).length === 1); + const terrFrames = new Set(); + for (const t of RULES.terrainList) { + check(`terrain frame unique: ${t.id}`, !terrFrames.has(t.frame)); + terrFrames.add(t.frame); + check(`terrain frame in sheet: ${t.id}`, t.frame >= 0 && t.frame < 12); + check(`terrain yields sane: ${t.id}`, t.food >= 0 && t.shield >= 0 && t.trade >= 0 + && t.move >= 1 && t.defense >= 1); + check(`terrain color: ${t.id}`, /^#[0-9a-f]{6}$/i.test(t.color)); + } + check('grassland shield frame distinct', !terrFrames.has(RULES.grasslandShieldFrame) + && RULES.grasslandShieldFrame >= 0 && RULES.grasslandShieldFrame < 12); + check('20 specials', RULES.specialList.length === 20); + const specFrames = new Set(); + for (const s of RULES.specialList) { + check(`special frame unique: ${s.id}`, !specFrames.has(s.frame)); + specFrames.add(s.frame); + check(`special frame in sheet: ${s.id}`, s.frame >= 0 && s.frame < 20); + } + const specTerrains = Object.keys(RULES.specialsByTerrain); + check('specials cover 10 terrains (all but grassland)', specTerrains.length === 10 + && !specTerrains.includes('grassland')); + for (const terr of specTerrains) { + check(`2 specials on ${terr}`, RULES.specialsByTerrain[terr].length === 2); + } + + // Buildings. + check('26 buildings', RULES.buildingList.length === 26, `${RULES.buildingList.length}`); + for (const cut of ['temple', 'colosseum', 'cathedral', 'policestation', 'masstransit', + 'recyclingcenter', 'solarplant']) { + check(`cut building absent: ${cut}`, !RULES.buildings[cut]); + } + const powerPlants = RULES.buildingList.filter((b) => b.effect === 'power'); + check('3 mutually exclusive power plants', powerPlants.length === 3); + + // Governments & difficulties. + check('6 governments', RULES.governmentList.length === 6); + check('despotism/anarchy need no tech', !RULES.governments.despotism.prereq + && !RULES.governments.anarchy.prereq); + check('5 difficulties', RULES.difficultyList.length === 5); + check('difficulty ordering', RULES.difficultyList[0].aiProdBonus + < RULES.difficultyList[4].aiProdBonus); + + // World sizes / colors / names. + check('3 world sizes', RULES.worldSizeList.length === 3); + for (const w of RULES.worldSizeList) check(`world ${w.id} dims`, w.cols >= 32 && w.rows >= 24); + check('8 player colors', RULES.playerColors.length === 8 + && RULES.playerColors.every((c) => /^#[0-9a-f]{6}$/i.test(c))); + check('city name pool 64', RULES.cityNames.length === 64 + && new Set(RULES.cityNames).size === 64); + + // Tech cost + year curve helpers. + check('tech cost grows', techCost(0) < techCost(10) && techCost(10) < techCost(50)); + check('tech cost difficulty factor', techCost(10, 1.2) > techCost(10, 1.0)); + check('year starts 4000 BC', turnToYear(0, RULES.yearCurve) === -4000); + const y60 = turnToYear(60, RULES.yearCurve); + check('year curve reaches ~1000 BC by turn 60', y60 === -1000, `${y60}`); + let prev = -4000; + let monotonic = true; + for (let i = 1; i <= 500; i += 1) { + const y = turnToYear(i, RULES.yearCurve); + if (y <= prev) { monotonic = false; break; } + prev = y; + } + check('year curve strictly increasing over 500 turns', monotonic); + + // Artwork JSON contract <-> assetManifest fields (checked once artwork file exists). + try { + const art = JSON.parse(readFileSync(join(root, 'data/civilization-artwork.json'), 'utf8')); + for (const field of ['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet']) { + check(`artwork field ${field}`, !!art[field] && 'path' in art[field] + && art[field].frameWidth > 0 && art[field].frameHeight > 0); + } + check('artwork citySheets map', !!art.citySheets && !!art.citySheets.classic + && 'path' in art.citySheets.classic); + } catch { + console.log(' (data/civilization-artwork.json not present yet — skipping contract checks)'); + } +} + +// --------------------------------------------------------------------------- +section('2. world generation'); + +if (RULES) { + const hashWorld = (w) => JSON.stringify([w.terrain, w.special, w.huts, w.starts]); + for (const sizeId of ['small', 'medium', 'large']) { + const size = RULES.worldSizes[sizeId]; + let landOk = 0; + let contOk = 0; + let startsOk = 0; + let detOk = 0; + let qualityOk = 0; + const seeds = QUICK ? 6 : 20; + for (let s = 1; s <= seeds; s += 1) { + const numCivs = 3 + (s % 5); // 3..7 + const w = generateWorld(RULES, { sizeId, seed: s * 31, numCivs }); + const w2 = generateWorld(RULES, { sizeId, seed: s * 31, numCivs }); + if (hashWorld(w) === hashWorld(w2)) detOk += 1; + if (w.landFraction >= 0.25 && w.landFraction <= 0.38) landOk += 1; + if (w.largestContinentFrac >= 0.15) contOk += 1; + const T = {}; + RULES.terrainList.forEach((t, i) => { T[t.id] = i; }); + let good = w.starts.length === numCivs; + for (let a = 0; a < w.starts.length && good; a += 1) { + const [x, y] = w.starts[a]; + const terr = RULES.terrainList[w.terrain[y * size.cols + x]]; + if (terr.water || terr.id === 'glacier' || terr.id === 'mountains') good = false; + for (let b = a + 1; b < w.starts.length; b += 1) { + const [x2, y2] = w.starts[b]; + if (Math.max(Math.abs(x - x2), Math.abs(y - y2)) < 4) good = false; + } + } + if (good) startsOk += 1; + const minQ = Math.min(...w.starts.map(([x, y]) => siteQuality(RULES, w, x, y))); + if (minQ >= 12) qualityOk += 1; + } + check(`${sizeId}: deterministic (same seed => same world)`, detOk === seeds, `${detOk}/${seeds}`); + check(`${sizeId}: land fraction 25-38%`, landOk === seeds, `${landOk}/${seeds}`); + check(`${sizeId}: largest continent >= 15% of land`, contOk === seeds, `${contOk}/${seeds}`); + check(`${sizeId}: starts valid & spaced`, startsOk === seeds, `${startsOk}/${seeds}`); + check(`${sizeId}: start quality floor`, qualityOk === seeds, `${qualityOk}/${seeds}`); + } + + // Density checks on one representative map. + const w = generateWorld(RULES, { sizeId: 'medium', seed: 42, numCivs: 5 }); + const land = w.terrain.filter((t) => !RULES.terrainList[t].water).length; + const hutCount = w.huts.reduce((a, b) => a + b, 0); + check('hut density ~1/40 land', hutCount >= Math.floor(land / 40) * 0.6 + && hutCount <= Math.ceil(land / 40), `${hutCount} huts, ${land} land`); + const specCount = w.special.filter((s) => s >= 0).length; + check('specials density 1/64..1/8 of tiles', specCount >= w.terrain.length / 64 + && specCount <= w.terrain.length / 8, `${specCount}`); + let specMatch = true; + for (let i = 0; i < w.terrain.length; i += 1) { + if (w.special[i] < 0) continue; + const spec = RULES.specialList[w.special[i]]; + if (spec.terrain !== RULES.terrainList[w.terrain[i]].id) { specMatch = false; break; } + } + check('every special sits on its terrain', specMatch); + let hutsOnLand = true; + for (let i = 0; i < w.terrain.length; i += 1) { + if (w.huts[i] && (RULES.terrainList[w.terrain[i]].water + || RULES.terrainList[w.terrain[i]].id === 'glacier')) hutsOnLand = false; + } + check('huts on land (not glacier)', hutsOnLand); + check('shield-grass lattice ~50%', (() => { + let c = 0; + for (let y = 0; y < 20; y += 1) for (let x = 0; x < 20; x += 1) c += shieldGrassAt(x, y) ? 1 : 0; + return c === 200; + })()); +} + +// --------------------------------------------------------------------------- +// Fixture helpers: hand-built flat worlds for deterministic engine tests. + +function T(id) { return RULES.terrainList.findIndex((t) => t.id === id); } + +function mkCiv(i, total, human = false) { + const relations = {}; + const attitude = {}; + for (let j = 0; j < total; j += 1) if (j !== i) { relations[j] = 'contact'; attitude[j] = 0; } + return { + id: i, leaderId: `test${i}`, name: `Civ${i}`, color: '#ffffff', human, + alive: true, government: 'despotism', revolutionTurns: 0, pendingGovernment: null, + gold: 100, beakers: 0, researching: null, known: {}, futureCount: 0, + relations, attitude, reputation: 0, + spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }, + nameCursor: i, nameOrder: Array.from({ length: RULES.cityNames.length }, (_, k) => k), + score: 0, + }; +} + +function makeFlatState({ cols = 16, rows = 16, civs = 2, terrain = 'grassland' } = {}) { + const n = cols * rows; + const world = { + cols, rows, + terrain: new Array(n).fill(T(terrain)), + special: new Array(n).fill(-1), + improvements: new Array(n).fill(0), + huts: new Array(n).fill(0), + continent: new Array(n).fill(0), + starts: [], landFraction: 1, largestContinentFrac: 1, sizeId: 'small', seed: 1, + }; + const state = { + version: 1, seed: 1, rngState: 987654321, sizeId: 'small', difficultyId: 'prince', + turn: 1, current: 0, humanIndex: 0, world, + civs: Array.from({ length: civs }, (_, i) => mkCiv(i, civs, i === 0)), + cities: [], units: [], nextUnitId: 1, nextCityId: 1, + explored: Array.from({ length: civs }, () => new Array(n).fill(1)), + over: null, events: [], + }; + return state; +} + +function setWar(state, a, b) { + state.civs[a].relations[b] = 'war'; + state.civs[b].relations[a] = 'war'; +} + +// --------------------------------------------------------------------------- +section('3. city fixtures'); + +if (RULES) { + // Founding: settler consumed, size 1, first city gets palace + leader name. + { + const st = makeFlatState(); + const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); + const city = Logic.foundCity(RULES, st, settler); + check('city founded', !!city && city.size === 1); + check('settler consumed', st.units.length === 0); + check('first city has palace', !!city.buildings.palace); + check('capital named for leader', city.name === 'Civ0 City'); + check('min distance blocks adjacent city', !Logic.canFoundCity(RULES, st, 6, 5)); + check('distance 2 allowed', Logic.canFoundCity(RULES, st, 7, 5)); + } + + // Growth on flat grassland: foodbox fills, granary keeps half. + { + const st = makeFlatState(); + const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); + const city = Logic.foundCity(RULES, st, settler); + city.build = { type: 'building', id: 'granary' }; + let grewAt = -1; + for (let t = 0; t < 30 && grewAt < 0; t += 1) { + Logic.beginCivTurn(RULES, st, 0); + if (city.size >= 2) grewAt = t; + } + check('city grows on grassland within 30 turns', grewAt >= 0); + check('foodbox reset after growth', city.foodBox < (city.size + 1) * Logic.FOODBOX_PER_SIZE); + // Force a granary growth and check the half-box carryover. + city.buildings.granary = true; + city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE; + const sizeBefore = city.size; + Logic.beginCivTurn(RULES, st, 0); + check('granary growth', city.size === sizeBefore + 1); + check('granary keeps half box', city.foodBox >= Math.floor(((sizeBefore + 1) * Logic.FOODBOX_PER_SIZE) / 2)); + } + + // Despotism penalty & government trade bonus on tile yields. + { + const st = makeFlatState({ terrain: 'plains' }); + const i = 5 * 16 + 5; + st.world.special[i] = RULES.specialList.findIndex((s) => s.id === 'wheat'); // 3/1/0 + let y = Logic.tileYield(RULES, st, 0, null, 5, 5); + check('despotism -1 on 3-food wheat', y.food === 2, `${y.food}`); + st.civs[0].government = 'monarchy'; + y = Logic.tileYield(RULES, st, 0, null, 5, 5); + check('monarchy full wheat', y.food === 3); + st.civs[0].government = 'republic'; + const j = 6 * 16 + 6; + st.world.improvements[j] = 1; // road on plains: +1 trade, republic: +1 more + y = Logic.tileYield(RULES, st, 0, null, 6, 6); + check('road + republic trade', y.trade === 2, `${y.trade}`); + st.civs[0].government = 'despotism'; + y = Logic.tileYield(RULES, st, 0, null, 6, 6); + check('road under despotism trade 1', y.trade === 1, `${y.trade}`); + } + + // Irrigation / mine / railroad effects. + { + const st = makeFlatState({ terrain: 'hills' }); + st.civs[0].government = 'monarchy'; + const i = 5 * 16 + 5; + st.world.improvements[i] = 16; // mine on hills: +3 shields + let y = Logic.tileYield(RULES, st, 0, null, 5, 5); + check('hills mine +3 shields', y.shield === 3, `${y.shield}`); + st.world.improvements[i] |= 2; // railroad: +1 when shields >= 1 + y = Logic.tileYield(RULES, st, 0, null, 5, 5); + check('railroad +1 shield', y.shield === 4); + } + + // Corruption: distance, courthouse, democracy, communism-flat. + { + const st = makeFlatState({ cols: 32, rows: 8 }); + st.civs[0].government = 'monarchy'; + const cap = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 2, 4, null)); + const near = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 6, 4, null)); + const far = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 28, 4, null)); + near.size = 6; far.size = 6; + // Give both cities identical trade via routes so corruption math is isolated. + near.routes = [{ cityId: cap.id, amount: 20 }]; + far.routes = [{ cityId: cap.id, amount: 20 }]; + const yNear = Logic.cityYields(RULES, st, near); + const yFar = Logic.cityYields(RULES, st, far); + check('corruption grows with distance', yFar.corruption > yNear.corruption, + `${yNear.corruption} vs ${yFar.corruption}`); + far.buildings.courthouse = true; + const yFarCourt = Logic.cityYields(RULES, st, far); + check('courthouse halves corruption', yFarCourt.corruption === Math.floor(yFar.corruption / 2)); + delete far.buildings.courthouse; + st.civs[0].government = 'democracy'; + check('democracy zero corruption', Logic.cityYields(RULES, st, far).corruption === 0); + st.civs[0].government = 'communism'; + const cNear = Logic.cityYields(RULES, st, near).corruption; + const cFar = Logic.cityYields(RULES, st, far).corruption; + check('communism flat corruption', cNear === cFar, `${cNear} vs ${cFar}`); + } + + // Auto-assign is optimal for the linear emphasis objective. + { + const st = makeFlatState(); + // Scatter mixed terrain around a city site. + const kinds = ['plains', 'forest', 'hills', 'mountains', 'desert', 'ocean', 'swamp']; + let k = 0; + for (let y = 3; y <= 7; y += 1) { + for (let x = 3; x <= 7; x += 1) { + st.world.terrain[y * 16 + x] = T(kinds[k % kinds.length]); + k += 3; + } + } + st.world.terrain[5 * 16 + 5] = T('grassland'); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + city.size = 5; + for (const emphasis of ['balanced', 'food', 'production', 'trade']) { + city.emphasis = emphasis; + Logic.autoAssignTiles(RULES, st, city); + const weights = { balanced: [3, 2, 1], food: [6, 1, 1], production: [1, 5, 1], trade: [1, 1, 5] }[emphasis]; + const wOf = (idx) => { + const yld = Logic.tileYield(RULES, st, 0, city, idx % 16, (idx / 16) | 0); + return yld.food * weights[0] + yld.shield * weights[1] + yld.trade * weights[2]; + }; + const chosen = city.worked.reduce((s, idx) => s + wOf(idx), 0); + // Brute force: weights of every candidate tile, top-5 sum. + const cand = []; + for (const [dx, dy] of Logic.CITY_RADIUS) { + if (dx === 0 && dy === 0) continue; + cand.push(wOf((5 + dy) * 16 + (5 + dx))); + } + cand.sort((a, b) => b - a); + const best = cand.slice(0, 5).reduce((a, b) => a + b, 0); + check(`auto-assign optimal (${emphasis})`, chosen === best, `${chosen} vs ${best}`); + } + } + + // Buy math + class-switch penalty + building multipliers. + { + const st = makeFlatState(); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + city.build = { type: 'unit', id: 'warriors' }; + check('warriors buy cost 25', Logic.buyCost(RULES, city) === 25); + Logic.setBuild(RULES, st, city, 'building', 'granary'); + check('granary buy cost 120', Logic.buyCost(RULES, city) === 120); + city.shieldBox = 40; + check('partial buy cost', Logic.buyCost(RULES, city) === 40); // (60-40)*2 + Logic.setBuild(RULES, st, city, 'unit', 'warriors'); + check('class switch halves shields', city.shieldBox === 20); + st.civs[0].gold = 200; + city.build = { type: 'building', id: 'granary' }; + city.shieldBox = 0; + check('buy succeeds', Logic.buyBuild(RULES, st, city) && st.civs[0].gold === 80); + check('no double buy same turn', !Logic.buyBuild(RULES, st, city)); + // Science multiplier: library +50%. + city.size = 4; + city.routes = [{ cityId: 99, amount: 12 }]; + st.civs[0].government = 'monarchy'; + const sBefore = Logic.cityYields(RULES, st, city).science; + city.buildings.library = true; + const sAfter = Logic.cityYields(RULES, st, city).science; + check('library +50% science', sAfter === Math.floor(sBefore * 1.5), `${sBefore}->${sAfter}`); + } + + // Size caps: 8 without aqueduct, 12 without sewer. + { + const st = makeFlatState(); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + city.size = 8; + city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE + 5; + Logic.beginCivTurn(RULES, st, 0); + check('size capped at 8 without aqueduct', city.size === 8); + city.buildings.aqueduct = true; + city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE; + Logic.beginCivTurn(RULES, st, 0); + check('aqueduct unlocks growth', city.size === 9); + } + + // Settler build waits for size 2 and costs a citizen. + { + const st = makeFlatState(); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + city.build = { type: 'unit', id: 'settlers' }; + city.shieldBox = 200; + Logic.beginCivTurn(RULES, st, 0); + check('settler waits at size 1', st.units.length === 0 && city.size === 1); + city.size = 3; + Logic.beginCivTurn(RULES, st, 0); + check('settler built at size>=2 costs pop', st.units.length === 1 && city.size === 2); + } + + // Research: cost, completion, prereq gating. + { + const st = makeFlatState(); + const civ = st.civs[0]; + check('cannot research gated tech', !Logic.setResearch(RULES, st, civ, 'monarchy')); + check('can research root tech', Logic.setResearch(RULES, st, civ, 'alphabet')); + civ.beakers = 1000; + Logic.beginCivTurn(RULES, st, 0); + check('tech completes', !!civ.known.alphabet); + civ.known.codeoflaws = true; + civ.known.ceremonialburial = true; + check('monarchy now available', Logic.setResearch(RULES, st, civ, 'monarchy')); + const cost3 = Logic.currentResearchCost(RULES, st, civ); + civ.known.pottery = true; + check('cost rises with known count', Logic.currentResearchCost(RULES, st, civ) > cost3); + } + + // Revolution: anarchy interlude then target government. + { + const st = makeFlatState(); + const civ = st.civs[0]; + civ.known.monarchy = true; + check('revolution starts', Logic.startRevolution(RULES, st, civ, 'monarchy')); + check('in anarchy', civ.government === 'anarchy'); + for (let i = 0; i < 6; i += 1) Logic.beginCivTurn(RULES, st, 0); + check('revolution completes', civ.government === 'monarchy'); + check('cannot switch to unknown gov', !Logic.startRevolution(RULES, st, civ, 'democracy')); + } +} + +// --------------------------------------------------------------------------- +section('4. combat'); + +if (RULES) { + // Deterministic strength modifiers. + { + const st = makeFlatState({ terrain: 'mountains' }); + setWar(st, 0, 1); + st.world.terrain[5 * 16 + 4] = T('grassland'); + const attacker = Logic.spawnUnit(RULES, st, 0, 'warriors', 4, 5, null); + const defender = Logic.spawnUnit(RULES, st, 1, 'phalanx', 5, 5, null); + let d = Logic.defenderStrength(RULES, st, defender, attacker); + check('mountain phalanx D=6', Math.abs(d - 6) < 1e-9, `${d}`); + defender.fortified = true; + d = Logic.defenderStrength(RULES, st, defender, attacker); + check('fortified adds x1.5', Math.abs(d - 9) < 1e-9, `${d}`); + } + { + // Walls x3 vs land; howitzer ignores them. + const st = makeFlatState(); + setWar(st, 0, 1); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 8, 8, null)); + city.buildings.citywalls = true; + const defender = Logic.spawnUnit(RULES, st, 1, 'musketeers', 8, 8, null); + const rifle = Logic.spawnUnit(RULES, st, 0, 'riflemen', 7, 8, null); + const how = Logic.spawnUnit(RULES, st, 0, 'howitzer', 7, 8, null); + const dWalls = Logic.defenderStrength(RULES, st, defender, rifle); + const dHow = Logic.defenderStrength(RULES, st, defender, how); + check('walls triple defense vs land', Math.abs(dWalls - 9) < 1e-9, `${dWalls}`); + check('howitzer ignores walls (city base applies)', Math.abs(dHow - 4.5) < 1e-9, `${dHow}`); + } + { + // Pikemen double vs mounted only. + const st = makeFlatState(); + setWar(st, 0, 1); + const pike = Logic.spawnUnit(RULES, st, 1, 'pikemen', 5, 5, null); + const knight = Logic.spawnUnit(RULES, st, 0, 'knights', 4, 5, null); + const legion = Logic.spawnUnit(RULES, st, 0, 'legion', 4, 5, null); + const dVsKnight = Logic.defenderStrength(RULES, st, pike, knight); + const dVsLegion = Logic.defenderStrength(RULES, st, pike, legion); + check('pikemen x2 vs mounted', Math.abs(dVsKnight - 2 * dVsLegion) < 1e-9); + } + { + // Veteran and hp scaling on attack. + const st = makeFlatState(); + const u = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null); + const a0 = Logic.attackerStrength(RULES, st, u); + u.vet = true; + check('vet x1.5 attack', Math.abs(Logic.attackerStrength(RULES, st, u) - a0 * 1.5) < 1e-9); + u.hp = 5; + check('hp halves attack', Math.abs(Logic.attackerStrength(RULES, st, u) - a0 * 1.5 * 0.5) < 1e-9); + } + + // Monte Carlo: duel win rates track the analytic single-round model. + { + const st = makeFlatState(); + const trials = QUICK ? 2000 : 10000; + // Equal units: expect ~50%. + let wins = 0; + for (let i = 0; i < trials; i += 1) { + if (Logic.simulateDuel(st, 4, 20, 1, 4, 20, 1, 20).attackerWon) wins += 1; + } + const even = wins / trials; + check('equal duel ~50%', even > 0.46 && even < 0.54, `${even.toFixed(3)}`); + // 2:1 attacker: p(round)=2/3; 10-round-to-kill race strongly favours attacker. + wins = 0; + for (let i = 0; i < trials; i += 1) { + if (Logic.simulateDuel(st, 8, 20, 1, 4, 20, 1, 20).attackerWon) wins += 1; + } + const strong = wins / trials; + check('2:1 duel > 85%', strong > 0.85, `${strong.toFixed(3)}`); + // Higher firepower shortens the race for the attacker. + wins = 0; + for (let i = 0; i < trials; i += 1) { + if (Logic.simulateDuel(st, 4, 20, 2, 4, 20, 1, 20).attackerWon) wins += 1; + } + const fp = wins / trials; + check('fp advantage wins > 65%', fp > 0.65, `${fp.toFixed(3)}`); + check('duel ordering sane', strong > fp && fp > even); + } + + // Stack death outside cities/fortresses, survival inside. + { + const st = makeFlatState(); + setWar(st, 0, 1); + Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); + Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); + Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); + const tank = Logic.spawnUnit(RULES, st, 0, 'armor', 4, 5, null); + tank.vet = true; + let out = { result: '' }; + for (let i = 0; i < 10 && Logic.unitsAt(st, 5, 5).length; i += 1) { + tank.mp = 9; tank.hp = 30; + out = Logic.resolveAttack(RULES, st, tank, 5, 5); + if (out.won) break; + } + check('stack dies on open ground', out.won === true && Logic.unitsAt(st, 5, 5).length === 0); + + const st2 = makeFlatState(); + setWar(st2, 0, 1); + const city = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 5, 5, null)); + Logic.spawnUnit(RULES, st2, 1, 'warriors', 5, 5, null); + Logic.spawnUnit(RULES, st2, 1, 'warriors', 5, 5, null); + const tank2 = Logic.spawnUnit(RULES, st2, 0, 'armor', 4, 5, null); + tank2.vet = true; + let won2 = false; + for (let i = 0; i < 10 && !won2; i += 1) { + tank2.mp = 9; tank2.hp = 30; + const o = Logic.resolveAttack(RULES, st2, tank2, 5, 5); + won2 = o.won === true; + } + check('city stack loses only defender', won2 && Logic.unitsAt(st2, 5, 5).length === 1, + `${Logic.unitsAt(st2, 5, 5).length} left, city ${!!city}`); + } + + // City capture: pop loss, loot, palace relocation, elimination. + { + const st = makeFlatState(); + setWar(st, 0, 1); + const cityA = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 5, 5, null)); + const cityB = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 10, 10, null)); + cityA.size = 4; + st.civs[1].gold = 200; + const tank = Logic.spawnUnit(RULES, st, 0, 'armor', 4, 5, null); + const out = Logic.tryMove(RULES, st, tank, 1, 0); + check('undefended city captured', out.result === 'captured'); + check('captured city loses a pop', cityA.size === 3); + check('capturer moves in', tank.x === 5 && tank.y === 5); + check('loot transferred', st.civs[0].gold > 100); + check('palace relocates', !!cityB.buildings.palace && !cityA.buildings.palace); + check('civ still alive with one city', st.civs[1].alive); + const tank2 = Logic.spawnUnit(RULES, st, 0, 'armor', 9, 10, null); + Logic.tryMove(RULES, st, tank2, 1, 0); + check('last city falls => civ eliminated', !st.civs[1].alive); + check('conquest victory declared', st.over?.type === 'conquest' && st.over.winner === 0); + } + + // Attacks require war; nukes and SDI. + { + const st = makeFlatState({ civs: 3 }); + const u0 = Logic.spawnUnit(RULES, st, 0, 'legion', 4, 5, null); + Logic.spawnUnit(RULES, st, 1, 'legion', 5, 5, null); + const blocked = Logic.tryMove(RULES, st, u0, 1, 0); + check('attack blocked without war', blocked.result === 'blocked' && blocked.needsWar === 1); + + setWar(st, 0, 1); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 10, 5, null)); + city.size = 8; + Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); + Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); + const nuke = Logic.spawnUnit(RULES, st, 0, 'nuclearmsl', 9, 5, null); + const out = Logic.resolveAttack(RULES, st, nuke, 10, 5); + check('nuke clears stack', out.result === 'nuked' && Logic.unitsAt(st, 10, 5).length === 0); + check('nuke halves city pop', city.size === 4); + check('nuke consumed', !st.units.some((u) => u.type === 'nuclearmsl')); + + city.buildings.sdidefense = true; + Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); + const nuke2 = Logic.spawnUnit(RULES, st, 0, 'nuclearmsl', 9, 5, null); + const out2 = Logic.resolveAttack(RULES, st, nuke2, 10, 5); + check('SDI blocks nuke', out2.result === 'nukeBlocked' + && Logic.unitsAt(st, 10, 5).length === 1 && city.size === 4); + } + + // Missiles are consumed even on a won conventional attack. + { + const st = makeFlatState(); + setWar(st, 0, 1); + Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); + const cm = Logic.spawnUnit(RULES, st, 0, 'cruisemsl', 4, 5, null); + const out = Logic.resolveAttack(RULES, st, cm, 5, 5); + check('cruise missile consumed', !st.units.some((u) => u.type === 'cruisemsl'), out.result); + } + + // Movement: roads, rails, boarding, disembark, trireme coast rule. + { + const st = makeFlatState(); + const u = Logic.spawnUnit(RULES, st, 0, 'warriors', 5, 5, null); + check('grass step costs full move', (() => { Logic.tryMove(RULES, st, u, 1, 0); return u.mp === 0; })()); + const i1 = 5 * 16 + 6; + const i2 = 5 * 16 + 7; + st.world.improvements[i1] = 1; st.world.improvements[i2] = 1; + u.mp = 3; + Logic.tryMove(RULES, st, u, 1, 0); + check('road step costs 1/3', u.mp === 2, `${u.mp}`); + st.world.improvements[i2] |= 2; + const i3 = 5 * 16 + 8; + st.world.improvements[i3] = 3; + Logic.tryMove(RULES, st, u, 1, 0); + check('rail step free', u.mp === 2, `${u.mp}`); + + // Boarding & disembark. + const st2 = makeFlatState(); + for (let y = 0; y < 16; y += 1) st2.world.terrain[y * 16 + 8] = T('ocean'); + const boat = Logic.spawnUnit(RULES, st2, 0, 'transport', 8, 5, null); + const inf = Logic.spawnUnit(RULES, st2, 0, 'riflemen', 7, 5, null); + const bOut = Logic.tryMove(RULES, st2, inf, 1, 0); + check('boards transport', bOut.result === 'boarded' && inf.carriedBy === boat.id); + boat.mp = 15; + Logic.tryMove(RULES, st2, boat, 0, 1); + check('cargo rides along', inf.x === 8 && inf.y === 6); + inf.mp = 3; + const dOut = Logic.disembark(RULES, st2, inf, 1, 0); + check('disembarks ashore', dOut.result === 'moved' && inf.carriedBy === null && inf.x === 9); + + // Trireme must hug the coast. + const st3 = makeFlatState(); + for (let y = 0; y < 16; y += 1) { + for (let x = 6; x < 16; x += 1) st3.world.terrain[y * 16 + x] = T('ocean'); + } + const tri = Logic.spawnUnit(RULES, st3, 0, 'trireme', 6, 5, null); + check('trireme coast tile ok', Logic.canOccupy(RULES, st3, tri, 6, 8)); + check('trireme open sea blocked', !Logic.canOccupy(RULES, st3, tri, 12, 8)); + const dd = Logic.spawnUnit(RULES, st3, 0, 'destroyer', 8, 5, null); + check('destroyer open sea ok', Logic.canOccupy(RULES, st3, dd, 12, 8)); + check('land unit cannot walk on water', !Logic.canOccupy(RULES, st3, + Logic.spawnUnit(RULES, st3, 0, 'warriors', 3, 3, null), 8, 8)); + } + + // Pathfinding: prefers roads, avoids blocked tiles, respects domains. + { + const st = makeFlatState(); + for (let x = 3; x <= 12; x += 1) st.world.improvements[7 * 16 + x] |= 1; + const u = Logic.spawnUnit(RULES, st, 0, 'warriors', 3, 7, null); + const path = Logic.findPath(RULES, st, u, 12, 7); + check('path found', !!path && path.length === 9); + check('path follows road', path.every(([, y]) => y === 7)); + const sea = Logic.findPath(RULES, st, u, 12, 7); + check('path deterministic', JSON.stringify(sea) === JSON.stringify(path)); + } + + // Air crash rule at end of turn. + { + const st = makeFlatState(); + Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + const f1 = Logic.spawnUnit(RULES, st, 0, 'fighter', 5, 5, null); + const f2 = Logic.spawnUnit(RULES, st, 0, 'fighter', 10, 10, null); + Logic.endCivTurn(RULES, st, 0); + check('fighter in city survives', st.units.includes(f1)); + check('fighter in the field crashes', !st.units.includes(f2)); + } + + // Work orders: road, irrigation water rule, mine/irrigation exclusivity, transform. + { + const st = makeFlatState(); + const eng = Logic.spawnUnit(RULES, st, 0, 'engineers', 5, 5, null); + st.civs[0].known.explosives = true; + check('can start road', Logic.startWork(RULES, st, eng, 'road')); + Logic.beginCivTurn(RULES, st, 0); // 2 work points (engineer) = road done + const idx = 5 * 16 + 5; + check('road built', (st.world.improvements[idx] & 1) === 1); + check('irrigation needs water', !Logic.canWork(RULES, st, eng, 'irrigation')); + st.world.terrain[5 * 16 + 6] = T('ocean'); + check('irrigation ok next to ocean', Logic.canWork(RULES, st, eng, 'irrigation')); + Logic.startWork(RULES, st, eng, 'irrigation'); + Logic.beginCivTurn(RULES, st, 0); + Logic.beginCivTurn(RULES, st, 0); + check('irrigation built', (st.world.improvements[idx] & 4) === 4); + // Mining hills clears irrigation. + const st2 = makeFlatState({ terrain: 'hills' }); + const eng2 = Logic.spawnUnit(RULES, st2, 0, 'engineers', 5, 5, null); + st2.world.improvements[idx] = 4; + Logic.startWork(RULES, st2, eng2, 'mine'); + Logic.beginCivTurn(RULES, st2, 0); + Logic.beginCivTurn(RULES, st2, 0); + check('mine replaces irrigation', (st2.world.improvements[idx] & (16 | 4)) === 16); + // Transform swamp -> grassland (engineer only). + const st3 = makeFlatState({ terrain: 'swamp' }); + st3.civs[0].known.explosives = true; + const sett = Logic.spawnUnit(RULES, st3, 0, 'settlers', 4, 4, null); + check('settler cannot transform', !Logic.canWork(RULES, st3, sett, 'transform')); + const eng3 = Logic.spawnUnit(RULES, st3, 0, 'engineers', 5, 5, null); + Logic.startWork(RULES, st3, eng3, 'transform'); + for (let i = 0; i < 5; i += 1) Logic.beginCivTurn(RULES, st3, 0); + check('swamp transformed to grassland', st3.world.terrain[idx] === T('grassland')); + } + + // Huts: all outcomes reachable, ambush can kill. + { + const st = makeFlatState(); + const seen = new Set(); + for (let i = 0; i < 200; i += 1) { + st.world.huts[5 * 16 + 6] = 1; + const u = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null); + const out = Logic.tryMove(RULES, st, u, 1, 0); + if (out.hut) seen.add(out.hut.outcome); + for (const un of [...st.units]) Logic.removeUnit(st, un); + } + check('hut outcomes cover gold/tech/unit/ambush', seen.has('gold') && seen.has('tech') + && seen.has('unit') && (seen.has('ambushWon') || seen.has('ambushLost')), [...seen].join(',')); + } +} + +// --------------------------------------------------------------------------- +section('6. trade & diplomacy'); + +if (RULES) { + // Trade routes: distance gate, bonus math, max-3 replacement. + { + const st = makeFlatState({ cols: 32, rows: 8 }); + const home = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 2, 4, null)); + const near = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 6, 4, null)); + const far = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 20, 4, null)); + const cv1 = Logic.spawnUnit(RULES, st, 0, 'caravan', 6, 4, home.id); + check('route needs 8+ distance', Logic.canEstablishRoute(RULES, st, cv1) === null); + const cv2 = Logic.spawnUnit(RULES, st, 0, 'caravan', 20, 4, home.id); + const goldBefore = st.civs[0].gold; + const out = Logic.establishTradeRoute(RULES, st, cv2); + check('route established', !!out && out.bonus > 0); + check('caravan consumed', !st.units.some((u) => u.type === 'caravan' && u.x === 20)); + check('bonus paid in gold+beakers', st.civs[0].gold === goldBefore + out.bonus + && st.civs[0].beakers >= out.bonus); + check('both cities got the route', home.routes.length === 1 && far.routes.length === 1 + && home.routes[0].cityId === far.id); + check('route trade feeds yields', Logic.cityYields(RULES, st, far).routeTrade === out.amount); + // Max 3: pile on routes, weakest is dropped. + home.routes = [{ cityId: 90, amount: 2 }, { cityId: 91, amount: 3 }, { cityId: 92, amount: 4 }]; + const cv3 = Logic.spawnUnit(RULES, st, 0, 'caravan', 20, 4, home.id); + Logic.establishTradeRoute(RULES, st, cv3); + check('max 3 routes, worst replaced', home.routes.length === 3 + && !home.routes.some((r) => r.cityId === 90)); + // Foreign routes pay double. + const st2 = makeFlatState({ cols: 32, rows: 8, civs: 2 }); + const h2 = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 0, 'settlers', 2, 4, null)); + Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 20, 4, null)); + const cvF = Logic.spawnUnit(RULES, st2, 0, 'caravan', 20, 4, h2.id); + const outF = Logic.establishTradeRoute(RULES, st2, cvF); + check('foreign route pays roughly double', !!outF && outF.bonus >= out.bonus * 1.5, + `${out.bonus} vs ${outF?.bonus}`); + } + + // Diplomacy state machine: legal steps only. + { + const st = makeFlatState({ civs: 3 }); + check('contact->peace legal', Logic.canPropose(st, 0, 1, 'peace')); + check('contact->alliance illegal', !Logic.canPropose(st, 0, 1, 'alliance')); + check('contact->ceasefire illegal', !Logic.canPropose(st, 0, 1, 'ceasefire')); + check('apply peace', Logic.applyTreaty(st, 0, 1, 'peace') + && st.civs[1].relations[0] === 'peace'); + check('peace->alliance legal', Logic.applyTreaty(st, 0, 1, 'alliance')); + check('sneak attack scars reputation', (() => { + const rep = st.civs[0].reputation; + Logic.declareWar(RULES, st, 0, 1); + return st.civs[0].reputation < rep && st.civs[0].relations[1] === 'war'; + })()); + check('war->peace illegal (need ceasefire)', !Logic.canPropose(st, 0, 1, 'peace')); + check('war->ceasefire->peace', Logic.applyTreaty(st, 0, 1, 'ceasefire') + && Logic.applyTreaty(st, 0, 1, 'peace')); + check('third party attitude fell on sneak attack', st.civs[2].attitude[0] < 0); + check('cancel treaty back to contact', Logic.cancelTreaty(st, 0, 1) + && st.civs[0].relations[1] === 'contact'); + check('war on nocontact illegal', (() => { + st.civs[0].relations[2] = 'nocontact'; + return !Logic.declareWar(RULES, st, 0, 2); + })()); + } + + // Gifts, exchanges, tribute. + { + const st = makeFlatState({ civs: 2 }); + st.civs[0].gold = 100; + check('gift gold', Logic.giftGold(st, 0, 1, 50) && st.civs[1].gold === 150); + check('gift beyond means fails', !Logic.giftGold(st, 0, 1, 500)); + check('gift raises attitude', st.civs[1].attitude[0] > 0); + st.civs[0].known.alphabet = true; + st.civs[1].known.pottery = true; + check('tech exchange', Logic.exchangeTechs(RULES, st, 0, 1, 'alphabet', 'pottery') + && st.civs[0].known.pottery && st.civs[1].known.alphabet); + check('re-exchange fails', !Logic.exchangeTechs(RULES, st, 0, 1, 'alphabet', 'pottery')); + const paid = Logic.payTribute(st, 1, 0, 75); + check('tribute paid', paid === 75 && st.civs[1].attitude[0] < 0); + } + + // Fuzz: random diplomacy actions never reach an illegal state and + // attitudes stay bounded. + { + const st = makeFlatState({ civs: 4 }); + st.rngState = 424242; + const legalStates = new Set(['nocontact', 'contact', 'war', 'ceasefire', 'peace', 'alliance']); + let legal = true; + let bounded = true; + const actions = QUICK ? 300 : 1000; + for (let i = 0; i < actions; i += 1) { + const a = Logic.randInt(st, 4); + let b = Logic.randInt(st, 4); + if (a === b) b = (b + 1) % 4; + const roll = Logic.rand(st); + if (roll < 0.25) Logic.declareWar(RULES, st, a, b); + else if (roll < 0.5) { + const kinds = ['ceasefire', 'peace', 'alliance']; + Logic.applyTreaty(st, a, b, kinds[Logic.randInt(st, 3)]); + } else if (roll < 0.65) Logic.cancelTreaty(st, a, b); + else if (roll < 0.8) { st.civs[a].gold = 50; Logic.giftGold(st, a, b, 25); } + else Logic.updateAttitudes(RULES, st, a); + for (const civ of st.civs) { + for (const [other, rel] of Object.entries(civ.relations)) { + if (!legalStates.has(rel)) legal = false; + if (st.civs[other].relations[civ.id] !== rel) legal = false; // symmetry + } + for (const v of Object.values(civ.attitude)) { + if (v < -100 || v > 100 || Number.isNaN(v)) bounded = false; + } + } + if (!legal || !bounded) break; + } + check('fuzz: relations stay legal & symmetric', legal); + check('fuzz: attitudes bounded', bounded); + } + + // Attitude -> portrait mood mapping. + check('mood mapping', Logic.attitudeMood(-60) === 'upset' && Logic.attitudeMood(0) === 'idle' + && Logic.attitudeMood(60) === 'happy'); +} + +// --------------------------------------------------------------------------- +section('7. spaceship'); + +if (RULES) { + const st = makeFlatState({ civs: 2 }); + const civ = st.civs[0]; + check('launch blocked without parts', !Logic.launchSpaceship(RULES, st, civ)); + civ.spaceship.structural = 8; + civ.spaceship.component = 4; + civ.spaceship.module = 2; + check('launch blocked missing modules', !Logic.launchSpaceship(RULES, st, civ)); + civ.spaceship.module = 3; + check('launch succeeds with full parts', Logic.launchSpaceship(RULES, st, civ)); + check('arrival scheduled', civ.spaceship.arrivalTurn === st.turn + RULES.spaceship.travelTurns); + check('double launch blocked', !Logic.launchSpaceship(RULES, st, civ)); + + // Countdown to victory via endCivTurn wrapping. + for (let i = 0; i < RULES.spaceship.travelTurns + 1 && !st.over; i += 1) { + Logic.endCivTurn(RULES, st, 0); + Logic.endCivTurn(RULES, st, 1); + } + check('spaceship arrival wins', st.over?.type === 'spaceship' && st.over.winner === 0); + + // Capital capture kills the ship. + { + const st2 = makeFlatState({ civs: 2 }); + setWar(st2, 0, 1); + const cap = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 5, 5, null)); + Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 10, 10, null)); + const civ1 = st2.civs[1]; + civ1.spaceship = { structural: 8, component: 4, module: 3, launched: false, arrivalTurn: 0 }; + Logic.launchSpaceship(RULES, st2, civ1); + const tank = Logic.spawnUnit(RULES, st2, 0, 'armor', 4, 5, null); + Logic.tryMove(RULES, st2, tank, 1, 0); + check('capital captured', cap.civ === 0); + check('spaceship lost with capital', !civ1.spaceship.launched + && civ1.spaceship.structural === 0); + check('game continues (civ lives on)', civ1.alive && !st2.over); + } + + // Spaceship parts respect their caps in the build list. + { + const st3 = makeFlatState(); + const civ0 = st3.civs[0]; + civ0.known.spaceflight = true; + const city = Logic.foundCity(RULES, st3, Logic.spawnUnit(RULES, st3, 0, 'settlers', 5, 5, null)); + let avail = Logic.availableUnits(RULES, st3, civ0, city).map((u) => u.id); + check('structural buildable with tech', avail.includes('ssstructural')); + check('component gated on plastics', !avail.includes('sscomponent')); + civ0.spaceship.structural = 8; + avail = Logic.availableUnits(RULES, st3, civ0, city).map((u) => u.id); + check('structural capped at 8', !avail.includes('ssstructural')); + } +} + +// --------------------------------------------------------------------------- +section('8. serialization'); + +if (RULES) { + const leaders = [{ id: 'steve', name: 'Steve' }, { id: 'gerome', name: 'Gerome' }, + { id: 'jerry', name: 'Jerry' }]; + const st = Logic.createGame(RULES, { sizeId: 'small', seed: 11, difficultyId: 'prince', leaders }); + // Play a few scripted turns. + for (let t = 0; t < 5; t += 1) { + for (let c = 0; c < st.civs.length; c += 1) { + Logic.beginCivTurn(RULES, st, c); + for (const u of Logic.civUnits(st, c)) { + if (u.type === 'settlers' && Logic.canFoundCity(RULES, st, u.x, u.y)) { + Logic.foundCity(RULES, st, u); + } else if (u.mp > 0) { + Logic.tryMove(RULES, st, u, (t + c) % 3 - 1, (t * c) % 3 - 1); + } + } + Logic.endCivTurn(RULES, st, c); + } + } + const json = Logic.serialize(st); + const st2 = Logic.deserialize(json); + check('round trip parses', !!st2); + check('round trip identical', Logic.serialize(st2) === json); + check('hash stable', Logic.hashState(st) === Logic.hashState(st2)); + check('version mismatch rejected', Logic.deserialize(JSON.stringify({ version: 99 })) === null); + + // Determinism: same seed + same script => same hash. + const stA = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'king', leaders }); + const stB = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'king', leaders }); + check('createGame deterministic', Logic.hashState(stA) === Logic.hashState(stB)); +} + +// --------------------------------------------------------------------------- +section('5+9. AI self-play soak (+ research pacing)'); + +if (RULES) { + const LEADER_POOL = ['steve', 'gerome', 'jerry', 'aiko', 'natasha', 'brad', 'cybro'] + .map((id) => ({ id, name: id[0].toUpperCase() + id.slice(1) })); + + function checkInvariants(st, label) { + for (const civ of st.civs) { + if (civ.gold < 0 || Number.isNaN(civ.gold)) return `${label}: negative/NaN gold civ ${civ.id}`; + if (civ.beakers < 0) return `${label}: negative beakers`; + } + for (const c of st.cities) { + if (c.size < 1) return `${label}: city size ${c.size}`; + if (!st.civs[c.civ].alive) return `${label}: city owned by dead civ`; + const seen = new Set(); + for (const t of c.worked) { + if (seen.has(t)) return `${label}: duplicate worked tile`; + seen.add(t); + } + } + for (const u of st.units) { + if (!Logic.inBounds(st.world, u.x, u.y)) return `${label}: unit off map`; + if (u.hp <= 0) return `${label}: dead unit alive`; + const def = RULES.units[u.type]; + const terr = Logic.terrainAt(RULES, st.world, u.x, u.y); + if (def.domain === 'land' && terr.water && !u.carriedBy) return `${label}: land unit swimming`; + if (def.domain === 'sea' && !terr.water && !Logic.cityAt(st, u.x, u.y)) return `${label}: ship aground`; + if (!st.civs[u.civ].alive) return `${label}: unit of dead civ`; + } + // Worked tiles disjoint across cities. + const workedAll = new Set(); + for (const c of st.cities) { + for (const t of c.worked) { + if (workedAll.has(t)) return `${label}: worked tile shared between cities`; + workedAll.add(t); + } + } + return null; + } + + function runGame(gameIdx, { sizeId, numCivs, difficultyId, seed, turnCap = 600 }) { + const leaders = LEADER_POOL.slice(0, numCivs); + const st = Logic.createGame(RULES, { sizeId, seed, difficultyId, leaders, humanIndex: -1 }); + let invariantErr = null; + let aiTime = 0; + let aiTurns = 0; + let firstSpaceflight = null; + while (!st.over && st.turn < turnCap) { + const c = st.current; + Logic.beginCivTurn(RULES, st, c); + const t0 = performance.now(); + AI.runAITurn(RULES, st, c); + aiTime += performance.now() - t0; + aiTurns += 1; + Logic.endCivTurn(RULES, st, c); + if (!firstSpaceflight && st.civs.some((cv) => cv.known.spaceflight)) { + firstSpaceflight = st.turn; + } + if (st.turn % 50 === 0 && !invariantErr) { + invariantErr = checkInvariants(st, `game ${gameIdx} turn ${st.turn}`); + } + } + if (!invariantErr) invariantErr = checkInvariants(st, `game ${gameIdx} final`); + return { st, invariantErr, avgAiMs: aiTime / Math.max(1, aiTurns), firstSpaceflight }; + } + + const games = []; + const N = QUICK ? 6 : 28; + const sizes = ['small', 'medium', 'small', 'medium']; + const diffs = ['chieftain', 'warlord', 'prince', 'king', 'emperor']; + const configs = []; + for (let g = 0; g < N; g += 1) { + configs.push({ + sizeId: sizes[g % sizes.length], + numCivs: 3 + (g % 3), + difficultyId: diffs[g % diffs.length], + seed: 1000 + g * 17, + }); + } + // Pinned seeds known to end in conquest (deterministic engine), so the + // victory-mix coverage below cannot flake on an all-peaceful draw. + configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'emperor', seed: 7077 }); + configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'king', seed: 8088 }); + configs.forEach((cfg, g) => { + const out = runGame(g, cfg); + games.push({ cfg, ...out }); + check(`game ${g} (${cfg.sizeId}/${cfg.numCivs}civ/${cfg.difficultyId}) no invariant breaks`, + out.invariantErr === null, out.invariantErr ?? ''); + }); + + const finished = games.filter((g) => g.st.over); + const conquest = finished.filter((g) => g.st.over.type === 'conquest'); + const space = finished.filter((g) => g.st.over.type === 'spaceship'); + console.log(` ${finished.length}/${games.length} games decided ` + + `(${conquest.length} conquest, ${space.length} spaceship); ` + + `avg AI turn ${(games.reduce((a, g) => a + g.avgAiMs, 0) / games.length).toFixed(2)}ms`); + check('a healthy share of games reach a victory (>=30%)', + finished.length >= Math.ceil(games.length * 0.3), `${finished.length}/${games.length}`); + check('conquest victories occur', conquest.length > 0); + if (QUICK) { + console.log(' (--quick: spaceship coverage check runs in the full suite only)'); + } else { + check('spaceship victories occur', space.length > 0, 'no spaceship win in suite'); + } + check('AI turn time budget (<=50ms avg)', games.every((g) => g.avgAiMs <= 50), + `worst ${Math.max(...games.map((g) => g.avgAiMs)).toFixed(1)}ms`); + + // Research pacing: someone should reach Space Flight in a sensible window. + const sfTurns = games.map((g) => g.firstSpaceflight).filter((t) => t !== null); + check('space flight reached in some games', sfTurns.length > 0); + if (sfTurns.length) { + const median = sfTurns.sort((a, b) => a - b)[Math.floor(sfTurns.length / 2)]; + check('space flight window turn 150-600', median >= 150 && median <= 600, `median ${median}`); + } + + // Determinism: replaying the same seed produces the same final hash. + { + const cfg = { sizeId: 'small', numCivs: 3, difficultyId: 'prince', seed: 4242, turnCap: 120 }; + const a = runGame(-1, cfg); + const b = runGame(-2, cfg); + check('soak replay deterministic', Logic.hashState(a.st) === Logic.hashState(b.st)); + } +} + +// --------------------------------------------------------------------------- +console.log(`\n${passes} passed, ${failures} failed`); +if (failures > 0) process.exit(1);