From 6a4ce1ca5fa63166be4c9520537fff2eb683364b Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Thu, 17 Sep 2026 22:05:53 +0200 Subject: [PATCH] Split shared data, game state and UI into modules --- client/assets/flags/flag_aotearoa.png | Bin 0 -> 2981 bytes client/assets/flags/flag_australia.png | Bin 0 -> 7594 bytes client/assets/flags/flag_florida.png | Bin 0 -> 14730 bytes client/assets/flags/flag_new_hampshire.png | Bin 0 -> 21433 bytes client/assets/flags/flag_uruguay.png | Bin 0 -> 5892 bytes client/assets/flags/flag_usa.png | Bin 0 -> 4155 bytes client/assets/flags/flag_vietnam.png | Bin 0 -> 18253 bytes client/assets/icon_battle.svg | 1 + client/assets/icon_missile.svg | 1 + client/js/feed.js | 59 ++ client/js/game_screen/panels.js | 536 ++++++++++++++ client/js/map_view/constants.js | 43 ++ client/js/map_view/entities.js | 405 +++++++++++ client/js/map_view/input.js | 293 ++++++++ client/js/map_view/motion.js | 404 +++++++++++ client/js/map_view/terrain.js | 486 +++++++++++++ client/js/map_view/utils.js | 151 ++++ client/js/modals/buildings.js | 92 +++ client/js/modals/confirm.js | 34 + client/js/modals/format.js | 51 ++ client/js/modals/nation.js | 388 +++++++++++ client/js/modals/news.js | 41 ++ client/js/modals/train.js | 126 ++++ shared/data/buildings.js | 95 +++ shared/data/civilisations.js | 149 ++++ shared/data/combat.js | 41 ++ shared/data/economy.js | 35 + shared/data/effects.js | 19 + shared/data/governments.js | 35 + shared/data/index.js | 16 + shared/data/map_config.js | 50 ++ shared/data/relations.js | 12 + shared/data/statuses.js | 40 ++ shared/data/technologies.js | 51 ++ shared/data/terrain.js | 19 + shared/data/units.js | 62 ++ shared/game_state/combat.js | 305 ++++++++ shared/game_state/constants.js | 19 + shared/game_state/diplomacy.js | 85 +++ shared/game_state/economy.js | 388 +++++++++++ shared/game_state/helpers.js | 41 ++ shared/game_state/movement.js | 362 ++++++++++ shared/game_state/orders.js | 164 +++++ shared/game_state/serialization.js | 244 +++++++ shared/game_state/statuses.js | 165 +++++ shared/game_state/territory.js | 147 ++++ shared/game_state/visibility.js | 94 +++ shared/game_state/world.js | 192 ++++++ tests/combat_test.js | 767 +++++++++++++++++++++ tests/conflicts_test.js | 142 ++++ tests/diplomacy_test.js | 248 +++++++ tests/feed_test.js | 63 ++ tests/orders_test.js | 140 ++++ tests/regression_test.js | 224 ++++++ tests/siege_test.js | 269 ++++++++ tests/stacking_test.js | 141 ++++ 56 files changed, 7935 insertions(+) create mode 100644 client/assets/flags/flag_aotearoa.png create mode 100644 client/assets/flags/flag_australia.png create mode 100644 client/assets/flags/flag_florida.png create mode 100644 client/assets/flags/flag_new_hampshire.png create mode 100644 client/assets/flags/flag_uruguay.png create mode 100644 client/assets/flags/flag_usa.png create mode 100644 client/assets/flags/flag_vietnam.png create mode 100644 client/assets/icon_battle.svg create mode 100644 client/assets/icon_missile.svg create mode 100644 client/js/feed.js create mode 100644 client/js/game_screen/panels.js create mode 100644 client/js/map_view/constants.js create mode 100644 client/js/map_view/entities.js create mode 100644 client/js/map_view/input.js create mode 100644 client/js/map_view/motion.js create mode 100644 client/js/map_view/terrain.js create mode 100644 client/js/map_view/utils.js create mode 100644 client/js/modals/buildings.js create mode 100644 client/js/modals/confirm.js create mode 100644 client/js/modals/format.js create mode 100644 client/js/modals/nation.js create mode 100644 client/js/modals/news.js create mode 100644 client/js/modals/train.js create mode 100644 shared/data/buildings.js create mode 100644 shared/data/civilisations.js create mode 100644 shared/data/combat.js create mode 100644 shared/data/economy.js create mode 100644 shared/data/effects.js create mode 100644 shared/data/governments.js create mode 100644 shared/data/index.js create mode 100644 shared/data/map_config.js create mode 100644 shared/data/relations.js create mode 100644 shared/data/statuses.js create mode 100644 shared/data/technologies.js create mode 100644 shared/data/terrain.js create mode 100644 shared/data/units.js create mode 100644 shared/game_state/combat.js create mode 100644 shared/game_state/constants.js create mode 100644 shared/game_state/diplomacy.js create mode 100644 shared/game_state/economy.js create mode 100644 shared/game_state/helpers.js create mode 100644 shared/game_state/movement.js create mode 100644 shared/game_state/orders.js create mode 100644 shared/game_state/serialization.js create mode 100644 shared/game_state/statuses.js create mode 100644 shared/game_state/territory.js create mode 100644 shared/game_state/visibility.js create mode 100644 shared/game_state/world.js create mode 100644 tests/combat_test.js create mode 100644 tests/conflicts_test.js create mode 100644 tests/diplomacy_test.js create mode 100644 tests/feed_test.js create mode 100644 tests/orders_test.js create mode 100644 tests/regression_test.js create mode 100644 tests/siege_test.js create mode 100644 tests/stacking_test.js diff --git a/client/assets/flags/flag_aotearoa.png b/client/assets/flags/flag_aotearoa.png new file mode 100644 index 0000000000000000000000000000000000000000..7b541f6ba175ac31584399023871cea48280ed94 GIT binary patch literal 2981 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 literal 0 HcmV?d00001 diff --git a/client/assets/flags/flag_australia.png b/client/assets/flags/flag_australia.png new file mode 100644 index 0000000000000000000000000000000000000000..11521536fcf21027e337057a8e5b7dcf10da38f4 GIT binary patch literal 7594 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 literal 0 HcmV?d00001 diff --git a/client/assets/flags/flag_florida.png b/client/assets/flags/flag_florida.png new file mode 100644 index 0000000000000000000000000000000000000000..475dcba2472490eb9a591eb016d902fc495d70b0 GIT binary patch literal 14730 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 Dl7tk44ofvPP)Tw7+U|>mi z^mSxl*x1kgCy|wbfk7eJBgmJ5p-PQ`p`nF=;TK5r3kHT#0|tgy2@DKYGZ+}e3+C(! zv|(Ui;4JWnEM{QPQwCwiilz2t3=9eko-U3d6>)EBS!+VWzkTi6yJPom0cAzb76!+r z-N8$?OyOF(E_m)PU(r`vmbN|$(0I2_(WP_u?$_OWYhK@R@3}Pd=lPaxpSPyh<$K-R z{eEBddCT(od#}e;%ZgU)zuUV1*8FYkA^*3O|GVDnZTsOm%a6(aE&J6D#0x+0zsFqv z>GuEAOn*!n|2?t&puYd_ejV~8&fjK!r^b8X%8Ngr%MSd1|1zO8lr<%*D_-`&@wVrW z7e2VNU+utuep`d{Tlro;k3BHGxL>Q2Pb+rEpAYA6)EvJVzjtrKa-}mL_5WQxUmsJz zsV;wm-*le$7V+b;2bTv68{FOa@PE7B%aQxT-_aTZJ|=^KWV|vii$UkFYn9C z#S}iC7E+*hT;_Vwoa#Ko8{2r)tj^AnIki2fC-}m(m5m1M<}dRN&*;9*>d@Xe;orWn zgf@Y%ZY8Cf`xjhH>rZ`LcVv^ngSF1Z9}lYXzpPc>s&}RAp2f@m5$&rE+B~}Ye`9vr z_DWqnyPu-pV&z-92bP3C=Uj>|6gRW}=dc&ln3qi6PR=iX~(zBWQp z9pTIV_55s}ykO-$M%hU1X>3Qm^_6*zWjsopWYrrpCbll~(AaRW`EanMrq6_^Rqcgw zmIhaIj}%Pz*&*@bQPx7mMe}0%Pv1PJk$2%|{A;g2=Y1Fq51j8k(Y&gPIjKumA>XuR zdZBn&pOuI3+5=bTB%HZ#A$fU^*Tak}4eyigT)41M=ES>emsYkhXNIjE zS3mw7$w)Cj@r!rP#9d6@d$J7m4LFb8%588G)^?wFbmNVHCs$`4I=el2;p)@LW)}C^(Y2XZj7{D>aIe-q3)v6ua@8+y{VQ;|ur_ApBP-b@Q`r*k zZ7(*su}nz2-F;E#va0$Rjvy{}#Unxd(MpFue3&Ncp&4kEwrFL+%?lH!I!61lKQDe$ zb?pALUkx7nC=+3Sau>i3#?VR=n~e!c}}r(a3dd`$G5t9#FOgGm3I z8Qr_8)ML(mb4q%b@NJRIYWLX2=0aJyJ0{YWQ{P>|NW4E8Lf)KIkOeQCqA~3 z-LQ}OXMhJ^jBnJ-8qvp2(^x}NLnFkBM5TJZx@Nx*InMt){lQn`Jc+#c&cwdZjEvCZ z3sa&~uDtwaJ-stbdR3Ik@7}(~H0fElu3lXEqE_wD+f@dax6e4Yh{-dx>W0Mgj^oa! zHdOubzcAI*`~LczONaYrIGhMcm3SA^8GLfyipi^!a;Gq_U->=8QLOYr8=Kyg{~KkF z+TRf}xU-Q@xz<1N(yr8?2sIt21)j$9=9Y+r_PYJ}Fo(ac>uc*A`Lys^kA8BBTRQmG z+zK_k^;?QbIJeN|cu>{dJ1#cs7vEyt%ExPwq4h)dsbH+l^f}97b#9)uS@!wZt=hiH z0ef~ux8y3nlP+e7dX*bM6Va$T-FtAsJFw8P#~>)xCpt znXdv}miGM3X8NPO*y6}zDJK~&!%MpupL>5<=*V8sQf&FGb|2IARa+C5UG0lKy(sYR zidO6L--+&P#M@?UU$k>>VSVQN$$E9}JU>{rEA^D?ANF|8_9EN#;^+JpDc^0gvSx2_ z(NJfQb!_d?*VPcI%9!x_k<&Iizm%wl5zBo0jmtNA@V|S%?tsp5wHFyB53824JzBl> zQ=SVN(^>cmp^I(vQ(V0bpi|=l_cG&guD-;aumbz1~q1O~o5a_n@XOpWhJrH~V*7dq2kyfE=R zKK1i^Wmc(6`;Rv0ONI2kycfE`&^vj?w6nz-3l)jiiYg`FP3mQJ6o09+?ehNu*TALhd@3`O+*h&Ioq8$%jMeh# zMXo}pD;jh6Jw7V6!N~mByy|a-Wh8xvM=-m2a%Ie~?;p;B$lH!VL=bQB}EG1lfLe$Fk zSy9y?;n541tQOi_>m~RsgpYY{%?Zh%yuCJp=|Qp<7yj3Lc*k|($ZX?t7ZlgLDgUcl z`mG}8Ig8BsP^G^8>A#(Q7%nnS+vJzC_csfZY*O{F&8 z&*hWVW8Jq)qHA;Kz8NAD8m!w=lvjqdF<<{X(Ya;Al1S01xmqihhGsslsx9{1GNZ!C zYKq$&hqO(45ypE;%1#{Q`Oi`jy)7}jhr93S@5CN|AvuY{R`1DLkGSW4e6Wr4MAn60 zSF>ZD#CEKomh&f`2FV1?I~)E&tA{u=3UP=dlJmoTQ=!bvZa9{g*%DH~#Tx zSDm;_LaBpf*S~*)OlPF3)%NkTUyax;`C9*sXGc?Yo4fP5r%7M$3A{AF zb-U!p>`adnoCjvRs~_~)&(|)0r^9seCdG9d^EWTnFH*{s`E##cpn9FJdP@vnI&&(A z<}HQ8Vn^&g>b~RG_!--C;C&L~qpq(7ipM!uwq)+UU2;E0=l+HXiQ?j!+kSuhrF7<& zT=SXL6M_=E1gG)-cyvy8!=>z_w=N5=Q2qDs$&bwyS>G*Wtn38;{c>UH{Mx-u_pn6U zri3G_t}Wi+8SM3L$L#BmUs|YVeD6Fv{hiFE^_O@5u=ycbQlNB7GIYfuhsDgNogaO? zvdz^ZbVC7=*>q;axOmOd?_DptuL$?*yl)QuGtKy+`DxaO3m0d}&*=Lh zWGrHyePBX@kBV{bUEN8X(`A(EYNHvQ_pTDlwXw62mDjiM*mIw+|81Xw&!{NLp>xE06yVFEbu2)miB0e~X@$T=i?VRWHbJd|f|G8{?3w!l){r3D*2>71A z$|OqZgf`>Tr|)je%4)N&T@`RFJ2%_Ry=bc1lUlX@AAV(P`v7c6Z*?aCJ9F7I3JQnFEURrJAtP_vO z@2W^GvYUH+Q%=D=vDNP%oW5eUF|A09FLhQCLuld73lfp6Wfe&|LP6d-GtIUg{GzNxn>Yv10O|LOI)_YP4V z_ft3}(r-6(IEF8Mz3pOlBkSd@l0L6$tZc=M+2%JZ8ZsFEEiwN!$!BhxaD8Wa_Gn+=bi@C9H=a&eTu+b^)Y=%ww)EARcx zj?b0*TCjOtOLMGVTh*+C_YR5r#x8pi66idAmXQ4fL+ur-UNoeIGp~BJY2u9V)*C9) zPr_$BbXlkNi@lKLZ^hcFxw?Ok9rRhxTWsF?c(JDE2D4Siig$}Y_&T%w<6&9fikxr> zBk8F#k1eY5y<_{TdXiVjM7Q+sHCY_jVhbC)Gi}%0+Lf>}zImtfxktOb_w5L&`hEG- z{*x>-UR-giFaEqz^l|UZ-y5IH^FLU5*iIrhyxE^KMfY!-=C$=VdfsYl3v5YiEU8IZspzTB zIOC-_xBY9SA5r4MhYOCs^2e(Ao!_=5{{3kfVf)Qy-v3OiSsk}}i#6Syb}D8` zobKLwweR>gQ;7u|TxC^DzD;9&@@VqS@Q-&^aq!IiAfVH>k0*Hg$AF6$9v@h(Xlu?a zE~IBLaWS`Z?cqBAg1SOGzvFWjO0VEEPUe1TEtPh7p?!ua+t2=kwU2Kb-cDoR_2S9# z=~CX_Eg9FgWc0VIKde~BnpPQlI4W4Q`sJ6UPj)?;deeQw&mxPOH=jNp*ibUZqr_EL z_R=9Gc9z{!4#lhwv+ACi$Gb`EY{Kf5%*KbKq8hFK<@Ptf+%B)ND_7Xz$j|t*N*#y3 z-aE8`tvRP?ewP0%CB@rx~u(ryGOJBwLhiZdWTM}5U`JtXx?~l zWyGdRrArdJd#rV(d}U>4ykB>qwMO=2r`7ayMc#kS&DtN&9ZI>gH$!cY+J2te-H92` zgcdARJp6V0z2+F--q-b3aW)^6POUn%A>)hEijRBu`35iR?EhBZlDqMCg7)TCFF(Ui zb$6IH3npefSs;0X?eMXDy<@)_f@S<1mbN_8H8V25w7Vi+u>*OfTJ5pFAY_vn>kHAyr^jKp*AKgy^S06fOKk!?0x^oEw zPqC+~*mU-4U0xmepq9g5Os8aSY|P)D%hcf)Vb)?;rapPWlNURqrzkCrYI_^rJae0D z+vb&dH)bbv-<_!z)ZTgfc8R;mA6bhDuf>x^=l`+Z?&Gp`j-0i9P0^Ro8L!J0`31JE z6XRZTJ?r31!|&C`=aaw6nthn}Rr2P{Jdcy-4&CFf-uUi_l@IUCt}ACuL`*^-?)`B- zBh)H!d1m{iPeB!yRUdavv2!T)Ha3&J*)r>w)RZmLALN{i+))y_I5=3^?BBkKzelq| zri42*CECil6zhBQHn0=vbpQEBB|J4&~HndIL z#>F)E(?{nm+5Cp33zi?qoz0*$?ViYMFaEu)ar>m4?bSj*)y@>qJpTFe_r!-6v`u=K z-jK5Pc-52czBIw{gXf+dwpwi1T~@j`<7vt@iEM!~1;J;){AY=FySSl50}J zI3z_5E$KIsnX>(4q1HDMz3}*VFYmV;%SpL@HEF}^goqbW4{p!MFSPh-@XhUQtCsWO z1%dpBZ`Z4&MXz%W+h@2DWwpz+ge`hjZh=BDyS|L9lB?xhAN%`#Ww(nM zqo>IoFjqf*=-eWqzN%LeZ_DMD2eCb#+4X;$l94cd*{0~N>Wd2W7)->kE-)~P9ER1b6SDKMyXX%Vjdp;2}Ms0OFmW< zn-;fNw;jH7K_+l#pu{5!mUCg}0#wWNXYpN+tUn*ezAk_HvE@e>FAaTpS9?bE3C**P zK5;xwbDRRgqT4?Id>(i+l2bJ9@N#au*7dvfMLrdY#Q&6$E}!=Js4?G+2O*u+hbI+S zoD}Wk;eVX9MRj@Mnp)nlUau#WJcjx|jKtPPH*tUYB+svPRJ*utLS3-MX8HMZ8adue z#Q4cM_a#|h;$E|%=uPCEy~&r}O8mcln5*n!{z3aRS%vK7vem+Of4Jly4z5;zxU_R( zQ2LZ>MY(@6h0N5W4~9%aGO*<`%VNS<>l~r=@o}cGs2zY!t-8ivgf#CUf@{Zd+a~k#57}y#xb~i8j5L_Z)$n$iP>4dl2 zS{jpMU)=qaDZ29uQ`)I59xJ6D&gu7@ujj%nyQ*qY%v|$Jh5kL~mR-NycD(%eqltny zgXY}7Yo;&~4V{SxJ;|E*cG_DFwvy-QN^qAxqU zW^pOWC@Y8MXJi#LS>`VZ#F!e#D8FWoRm7jV1=B>K6`r*%rt!MbIExvbfoAT|% zCC62r{Kc0q@>kZh=d{~q7jQk`__rx<|KzdH**a%q+07`Ot_YUmeY*j&eu(mwQv3NOfBQZYsc_-&V3!6#+E5YN_Uh6rVF*tIw!RxY-v&l?{tyi z^q1zRS7|-4JR$$p_p3$GOO7UYd)EBREE1+74^q|{o%vHyWM#7T)W(zo-U}H{Tr+j! z)N^yPLliRKEiX{H$UXImI#=4`OAR%gRsR%kiLYxjvUst5@s490kJ(i(_e(h4cgtUJ zZ0~cM?Ws&DS2LB@9$;=`QwjbtO>u3PVtBin9G|~ihquta9ZOhj_QgxM3a>o7h>!h= zKh4vvOYJG>SJXK?@7T{7-9J8Enm*%+F{k~!KZ1dl zfgG8p&9_fxANY_^F|%so2}|McKD*e(mi0WJ`8PW|`?dJDSc?LIFyrNiD=Rdlez2U5 ziB-^E@8a$$Y4k{VhTA&_PW7&J>&$e{t~Iz;u4BV%^Kj97C10E4zU>!MQ@R!}ov5Lq z-LSpc{GRnUxi?$hJ`ghy_uEytW8RGp88`NFwlr_yU7NjCW|VkjU!S@n{j09khlE*G zEy2v&j%ZHb^VR#?gllo$?g7)i1LhvdIkoovk&Ukpe_&Q#cKc%9Y+(((uAC~BAy+3|iKD%_^!l+ZCil?n^i-f$-xz;YR>$j_zBQ8#P-?iP{Z6#-) zZK+UMu=#_X^7c+O&Ngi``I`6dwRe;b_2Hl2cdLu*%5jwg>F;u0mRng{2gZlCa!YSZ zc>7Oh&$n|)Jp9sq-PUDF<=^XmNX%Z4da9!3?5vCH{q=Khu*}_M=JF?ibN4wWnOpx& z-pEe5S9^cjkIQlrY^(=P%vqX}9y$4hsFs<%SorGoN(U4e-rxBt!sRJH|Hnm^DU)UE zTKLw@Yh&EE_f2E|V%vq@>877fu8pWpgid_h&A)irO#kLPeDgP-_*B&@$YDJ_lwt0MJ37qn%v5!%Tae{Eln&sGVMsx<*B|Ixvvgf zu{`bZBcv?m%D31H7Ikd7MXdXJw2p7?Kijr$o7pD+xvmHHmLJYkoF5b~f70qp?zTF+ z7Y638geA~tEczL0n1(q|kS-!-+Z;S8?%^f9?d{nc%gUlUVg zOiBaqUhA9G)@c6s?sww^VTU7I?taMleo{O0*R99{fnI5T_th+F`1@>KXOzg)$Qpfk zH#eu*ci ziUqUey4;`J-V(m-E^phmZHLQ?^RpR`dnRv42xE|X{i^WcZr%@b4EXf^)dfsHS!ej; z(XS&cMn;B*=Clf4xRl-Azy7}N+I1G^&R^x1oGIC{wpW$uMkB**h7+?=SG-V2SaRXO zj|($51a~?oFO>QHY0Ax``*asa$@BN^Y1^#%Z-%Ty=Z+Hlw%WhHS8nigJ8}Dg`2D?4 zUH`V{^GWP7nENzrzTs~19Hr@csuxy>y1&28uKxI*OTc$iE~e!VK1jZ}ret>BwToj_ z^zIX!clx%g9dnv4^H*qDl6X&|YTga$L#*wKJC%<`_BqF@bL;#t5#J{2)cxe#@#pq@ zvTx&rZ>mjS+`6^$5vRF{(ImFxRJTQr{xBWZmPF?wktITYod_jP(rZb4pjFzkHruZ)|s8^4F40TaC7^K56d$T=~$tcl-DLnfIRm28+dn^oGKF(+fXbJYR7C!fTh8``k7- zbNPvBeEw*w);?G9#_ssZ2PB^aw3(mZoe+A%bjxDS=|P7-Udyy^(mk;0pry&;@&(N+ zZfs4DbD7Y~ulFqG$no#>JuQb=IBn)Vwcww?BTRkW?}MJV1>S8t z8Sx@kXKnNMz7q`_pQ~slM_i6*)-FspJXp@OZS8-aUsAh%q?}WaFs%&+58i7BV{;Y)dzj7RpSU;4gt(zBCNZmeQe znmc*!`nI%RWaZmnyyoz~YV$+qR z{7qbYi&lJF-li$_`C>tp;wmk#ix+PlTly@%`u?GbJRi6Y96mf;qQ0aT zyWe|U*W1w=r~IPgn_`odF;kVr4vFrVSvT3w%@C3}Z?|#!p?&Ytj+}Wh>*Puv(bG$| zvq)T77?6A{pd>EXya%+uLMg4v5xCN>`tx}yc?;2lj&$q54+eE7$o;ekH zG3VqYQCI113HkSDFC5>};L@`>tI=|GW^4N$kM56;Sf0JfJsfa7qJ3-iHLw3)w|qD< z#nAXcVUqrp+_m)|U3zC&GQVOe`?c-Ll$0r=qCq*A19mFXK6Y|hWt z-rdLdw;<=%2jw8)^^c$5wZHr6WNvP*Yjt(~xAnW`n%eaJ?>U*g?BL-$%v*W4Mr=;+ zEqVLvoASp)E#8-Q+RYPdOe_q_dM)k{E zZ!(>}w)V&s)9(rIu7CD?erdYbc9)&k_N92MOglNj|I+g5sjO;zJ6k^Y=N@0ZnCqd- zwf8$5SuZTRT>j>j;goID9=-k>pZspa&L0nN@JH+_6Mmn}2U`n}>7Vp`{@skaIy)m{ zqYH<&##}AZ5b5;{*rH+iE9kOGlHR`ktR8*08f?A_R=oJAp78WRL#A>1-B0S1*2f>e zd3pKo+GTEsPEOkDz2V^vwlmUZ{IB`eOkVf*hiMwy**OA^nUd~z!}gTraJ>6b#?=#f zYnze@-_Dj(lTV$LO^7>k*(mxGbIXMzt9*?O-*0B$@T;Buf`_Td$AUd|6;gKA;(pJ~ znEUzmf8F${Ub3d}O~VtWZ7MrHggjcB(_Ped=;Ru{=cieiu1Wv%Db$~m^>xivpTY^E z)=9PhEsI39rAa(Gs?2rnmEM`+#D`07*LL`IrgxP}OS=3zG4c6>h&zTZVeUyQ-v?El z+kbBA>eb4#)qWb~#4-s=nKiM_d|R-z^IB+*WA(|*6|>tNF23Yl-<*X^lxb=JScx%=kXrB$Clc5TaA zm5MDBzRWt#zECt;XuHAzQ-)t*+=iEy@vh|6jgXquu&pZoNQ$Or+VA!JZIU`mguZjG zJ8CiO3YT%aN_JF6^nu&Aq)%S;Vq~vx zk6vEX>Ur+8^0W!F*}iYHy`Uf0o<05Cp@tRlai3gQ%w}D%cZ$F6{0-aoOb&P`6cDcO zo)ly9bpMx!O5Tl{bI<8jstRTPi@qH_vEYa4mS-{!;p!`o)ZTjd_jh5z`+4y?%y+L( z3!2Q7ubn?5d-mey|GQ^ZiLP7Ne|YY9vDmB@_3!PEFD8Gpn6P+F-z4Y@3kLfR^;4P6O3l zCz_Ht!$1DC`_I33o0BE6Y=GjVd|%&Bf+A0E3DZZlF53pnUGkxAFTt@UK~0^=vTDzP`1ti|g~R?U8N%QTf^ z=3L)h6-i2K)~rd$&F%ddyv+0c^(oU7-#sv_`Tv%0jzs}m)S6O(>slV_Y|Gd_T>9+P zQQdLGL+;tqGfjCrjYJmD>5g;|YTf?a#k2Nd;xz}0n=EcT^LMvu&ga!W>b_a;q06+| z{#zY-mSXAi?{xfT=KlZK@%`_kj58V7FX`Ycv%=QF&lUB@nXUTv6^KY3!{)W-95zj)NNjzySO zhWft<4+v;jvP6YP+Kk7{Y`>2D^y%}Q1ItWenx8MUsuj1s$#(Jh{U*8VuMQ*%FI;-G zz-t++>0d`4!QcylWlXP*IbWRhRrZsY=moQFuPYy2zH}*LQSZa|@6W&AT(V~mk8b79 z6Zf{rmP&6^`W$5RZ`(hufB(*A+E{>Dqy$6+}bM&U#DigGh^i_^*hL1%U0c1ttnL0ELh&G z^#A_3CdQczhi30jUvF*t=Se|=;j>GQq7}*C#HIzDUbt{wOVie$-cBeVuWoCoJ^ggOv;3T-|;#Dn>E!kH3DMl)rx^*K50`8vcqK}`vwj8=Ka0a<#(7Kg*Gm`-TY#OMZ+;}%a+dtMwJ@%l zE$uA(#KK>ii>9)){<;6&@?Ou1`1E4qA`i7tA1U_3KaB&EGP#P=qhGwxnyyiJc9)Be zsMxPP;!Euq+NYaNlr2cNJ^boIgpG_?^X_IL)$|<`o5H@9q_kEneW86(DtgK<7p5PP zW;(+A(o}A_Y0gcs)njG8$dRD~}}}koxhi%*NG{vBhpZ*Vk`W z9#@5yUR%^^IXywmd*8LQyZR8fy2Acvq8$ROoKNk@EXll(pW*6N)%E0wZvBTu z&X?VNySl{ZRSB_we>?fw^TX|Yix%9I;F`XF;jLWbNn7QgNWEdG7uVhrveaRna8uhP zk2JQ|7eZ#Yq}d&;jrTR}%*e?|xNDz~p3f~jUGu|?4`O^v*dA5wRE`p{($UX%V(!0L z@y%kLMZ}^037Z#g6+L|>B;e?T{FAoYOOq#Eo*Wsj(Z1_~NXggf?_4YWxx|_#{dU*5 zbAFqI@SaO241%m`ogS}fHg=pk$s%RG=i4B+{MrSbj)qrCvLX}~d&Q;LEt0!$A@JDV zz`{Y?J=gOF;~URi(uz|mgO!u6o}7KZPM2wRP*v!*B{%Op-*kNruiB-nnu2zhUtLK! zTl&E#h<%^sQO8w3C3mSMUx-4KV!ggd?CpYewSDh2gIpUrym?`75_uWi;>Zq?0wsVs3>+4lIRPd{{id!F03 z*41+=r<_drg#AVaVhIin3noRm|G0TH`R}LPAB|J(xI4b(?vd(SKjUU~@g>ncB_>m{ z=LS4}6y+Yg;A~Lg#dRXH4hL-x7RnYXTDR4txk~5Sh0UMTe|$9GBJ3LysJhanl>NW8 zNS2C6N7^%6xzy`rj&UVTuazQRPv;hoFWv;{WF)Jx^nqKwabl*re)sl z5(yDjUh#CVMpV|tn<1RC-1#qy3;hnZg_Yz!o@%ye={K#FukD?We_@=~Bk#iNy7r6Y zv6(NgCrzv86%0D|clt8VRmo0!oGvcC<~*aeq(W)lZE=^=7yMKVyRIGNw!gELmu*S6 zs(AJPLY)JN*B*3pCajC@mr7FeWIbH?Y?nEo-KL4J;;&ZxykRiSiF<>)nxJ8;!sHjf z^V8H0A5dUUvx==q|L<~T(;4sht<1WnI#Qw8iO-Ka^sZ8j^|@PCCZPQyO1E^wi;T|= zktg;CHNScz@{2R{h0fU{I~?qv{kIl0s^W=>i7~LLF^T(koP9_7znC@kSA^y3KPX9M zMPG2LOLA&uJM1Lny|qMA>C}&s*B9IuY@SggVYHiJ%aqijqsz)BR!hu{=FP}@?Yu1A z@3XJ!<_-Zj!6=o5r&N?yakx(w4&LFjr8p_mptaVqsN?FE%DcLp0oSt{_o%gJBv`u? zzVSSD^Y5RVf95fY)Gj>!^nAnQ_=9#?yBSy7h!!?gxo}4OeVp?wh~==Kr^@v1 zW;>`IX!ybKKEB8)X5Wc(nh`Rd%k)|&h46p98u=bQ=J!I$7H>bnABCPeroOV z!$&+szfDuyuw`50o{e(p-`*blMphq_ zR}-UGci-eY`y(YtjHS-#&ayC}Pc>k=lp!nR%!L6#P{N*1podStLDyC^ZCb8MKr)ohx_2xJ+s1Wbe*9 zvn_?v{=CT&eC1`gV7|=iZLSKNcXj`)b93a1{Wf*Q?0|zy*C^@~_PcZ>YjFqcJ(0a; z@w-N=YjZh|Cr#_GpZ@9oYjz$EZHt#P^9${41zzh_JU(k#9S|vdq$FMBPp+52x5-yM z_9#v+db-ef&FkwA?w`F;P^EKe)|Cj4l?kfrxw;}{b#8rAwGN0})wYMtQmK?@$3COv z<=H;Pxd$3|u*a(P@cbyhueSbF^V{Fb0n0L!UAskH!mL(YHE3+xD7a^@jiHK(NaI0g z)n|T5PC7yz$}#<&Zf;pdwXOdw{?~tsc-d1YwJkVeP0``yD^6B-|MNDrWUZNGx;ioA zvrP}rT*IAHwl>aI^RhR*X3DSKzU+Y0X%YW-Nxcl*?tjm0*|D!Y=}GwFrO9o9;x8{8 zj#t=zOrq0rabkL@=zj|k1j_=;vGUNY~T}R8=QkXdQ+DWdRv%6OA^yV#w zXLqyo#xDKw?u|s}zAK7pCI|bzFLw!UG-hWPswvbxJJTp<|CLvj#Xe$hw)h#bg-yM7 zNG9udxL{(OW{t?-;mP2^qRM+~4AD!hNtlf}s zqNiqIx}aafO5UN3_$YE{oRT`g}LLV6990(?W+0)p}`cM;_<2Nt-WyA-aCn z+o;~9%KpxqFHaOZ$TRhf$+73R4VN$NS}pqbYx}>+o(2lu4TdLW9?v-T*Wi?F_Oc}h z`;1=SPGSp=Jk2n%|4rJet4xpg$+k|uaxd27=ha!WK2G9LUU9g*__SJVl}m=Sb-3`k zn^V#`7ZojEC-z7tYJ!J^nNw@?m8&i*#ZMg&Hq2C8p`t$R-V={^35&UUR_zK%*KzMm zx0aiEyVZGOtVP|#TYvqxCM@@OXv|}M$APcujh0YfL*I0*606h+;;Rl`+bo*p{x#4| zeu?%H!J8AK=H$d06y~Zjd2%1UlCzlcpiY;^sU7wX(+fOie!G@)!i`z5zAd~NdAMv?VOZXXYxcbxG)u(#(_qo%O*1kMRwzdrrb zd~Hy_L-3^h>5qb~lfSFndN}h#MBcRni~MI9Tx|KUsV$1#OLKo!e|(e5A&pxW$BRr% zUn_2MVqeZ>`f*dQmJrYG3AtM8ZTimblNm2{?73<+OUZC6>%TcaN`0SP+5GK7g9>+i zkRT6Bwe}kBV9pO)1T%#ELevh|<}cUlGS({SO_8px^ADdLqxws1-nm#0?RQOPrtD$o zGLDzCce)A`OKji7Yxd_)6`yLlt<{6f^Um~&*D9rq=m8$Up!UH@AJJlsoCxGjZ=1fIl=eKeO<)X za*4DbN0XC9Saolht8bc`vwV%0bC~f~0ZpG{$1=?G+=H8+^Ya{>Cu+^P;OU|F^8EkR z`TyweGgc5@(AZpJqRC>+9)F}uajDMrO^03w>phsuEIi@B$%}VejAz6>>zUbn>Ot;B zHO+!9n!Yt((hZqkDEY2C*A7o1J?oW5}R)|E_cyL4CNDZJgYOgOpOqSM1k zbJ5~`El0goOO8CzQJW|{)!6jap%n~W9>QBX7u##IxGtM09u%~2fsw>opC+kS9<86Z zgsW}J`k|7(LyLX=Buj?nf4KDgwRXH)(0X#2uEoi_zMo#6KM|q3)k=q3|DR1`2ZuKLFAF}2zTRLB+n6>Z;&z+ay%@bps{FndczrhyD944{o zq-aPb8kJ3}H!LX?lA9IwLi|#V?%oQiEA! zy;ju5p51zQ(gF#S51HLX+u9O+jbF`p6q@nD_2v|Y7n@#tn5oo$RAXhO4tx5FX=i8sb@SZk z$M(-8oF|s0D(k@IjgucudDX)caI2mpdxCuV0Pabr$6$UoC4yzY)>=nzF*^`WVr3(>hGHub4mE- zn=0Ph=j*YqKlRp+h(9i`{x99IV%_&Pc4PH_XMV8g+_jqYakFvHRc;BVQpMvpWoET* z-JreBZQo|rRlX$`Ljn%(`K_D4qm)@YdlA2PWCVxdj=O6g9^3EgI#bJDXIaT^D_MIf zSw(g9?stqA=&yFhj%O3lChJA}fQt&KAzq#ZM1djsI8BF z@pixKjq;xJr8^?8c$c!=6uec6tltBEv{8>B(H>u1Z-N`_Jd8(HtEQ#EpDtj zmBHm^P0Q@gI_jo3{?V8db!^cQlfKiD+<$w@GNx=-xDhNJ9~*XPo4u;VgWE^1aL=06 zZoO~*=|Am!syW$$2hXlOBImLD#OfV8MUAAYs{C2Bj)<9?=N5l{fB)usc}s=F2kYm? zS$OjQ2z|V|<=?`Npr-Cbj! zRJ?Z{LZ%hvRnrmXBe!J+AO*C;w+6#9}<2X{Vz2o*>9t!jF)twT7c-yx1k>lKP~%z zVYf_%WS9EN`gE>dhWiH;Kk#!b^7Ci^S$^nea@I6YiCWpxKNTC9#kKcbSavGFk#qb1 z%h#4Ba8{kP%PW}LAvs;T;O;55ST=evk##g zR4lk1*ZqtrzuL`mUB)G?%Bz}xPU8GKcc%63>6)=BVAjgR9o; z&U2Fu@8%j9o$^`1x@N+>Ws_BJrR>_3FsWW7iL>0VRQHf$&_dVhmPMX*e1&I%*&n@r zoyJk}xxIh4wd%tgfiW%IGiz^Otr1j=Yl)IxJYm(;wqujrS|V7DFaLPGT{&4qaJoZb zSA_KzNzSeBqqo!>8!JT|GG&c7IK6#(m^=GxxFbuKndjOGM=r$| z`a8GY-Rt}~Fz`_u^A+BIma!`XP0sF}p%k+2P4nsw?ShHFR9a?r>Bb~^?Rb5opxrCa zNz{KMcW{io>6iaT%ln=AHu4AppNtv_7etpL4X>X2wJZ_e7dR1w` zM!m`NIQHy2NFVtnybsyu(n8yFmWmM*qlOnb~$63+)VlBwmzUR%^XO z(Usk9UO7|b*R@ytb*7Zb3hJG;2=kS%`%>*&|E7Lzx#8R+N1J#4pBQL*nMuFk^3gkWvA53oPKvv?nIr!AS0M}jyS0s*3Z_);ce$p2d7=7> z`I`$0ME#y;C~vwEaOtdI-204U-`4AT99sG)!NedhJp7_Vpts;E>(!i&rvntURjgKY zoxC+!VsWtohoJe&8H=4R&sLk&CB25>RZ{Iihk}*X)=ySw&podFcwKUrq{+{%KC1kS zj6Kqdm&@$Bqw=cmwC<0T&UTtOLz4LdSzb%taat)BndTJzg;8?KX zS&_?TJuhCap6-^q`AFXP-rU^k-5X}@IKJ=2$BJh&b}yN45|DeKfT7SaNo;=F%pd(1 z<4Sr;9v&9SDp!1bvLxT8UT4MqQun75?;5?52|A^-l5Jk!XGy<^`5tQ*M9FrF7#{3g zb$gBQonyL@y*rNamvtRlw~J$mCg0*!jVENRH#Qb-T2rhS6?nvG^~n{R;(PX#IbV4d z_}2Nj7?-4R$CbZ-^V#+<-YT}1_h-oGgk|eLXldT!oRDeTTxNOt*6h}47TuoKbx&9} z9CQ|&zd6sL(bsU<6{Eb?>7lywx_2B?3Xpzt{@K1`X}UY=OGL^QuPV+o5iHM}b%ZBT zKcZ~jr|-$Jr)Nlph;?ygS1KlpOv|0qwN^)7nD2$dHpY0SBS%e(w;fOAQ1^Mm?ZTvy z{JHR*B!^kBQ~9Bwj%jhuvI_4Of4}fz^N-&X1#ArMmOcL#6XCPV#)NbEXwzU zCR~v<{%5^0`Ta&NySJu4e+PuS9WyvDDYBVe|NRf?)4S$qcz@;-SyOnyWL@hnxo$55 z+10V@ADuEa-cxXfZ(Ya_tA8KVe7@{#GQAV=iP`Ho*Mip@IWJax^)M(Gx9q(;aq`4d zb}w$bMVSSASo7=Upt{0zeKM6!i`>ZgX*D#S$^l$vOY^pvYF^V&V{zTgx$<9mnh`X5^`@B35V9lw6NPTx5B=j7w= z>V`+&9Qob;>~Y8{{l85tdC}RvQ!o9qYG+Pm&AZJqHbEr}%=oM>jr{I~now@R|ToYX$~kE>9Q7kR+8L*1vxK zzZ|~Tw14rcedAZM{Ky<8e;F=U8R6ul=aaWE`LkFoeH@UL|M9H@@4l2?_D?Cb=fB>0 zSTINL-PCr$6L+_zWS`u!((u}K1*RVle$+nUxMcmjcb%ndU#sHrLW_%mw{i~7IdDDM z{f^4B3F6(FN6bF1eRb5}>HYQd%MY+yM}6!$c$p(@YHy*V$-J9inYMh5Z|nQV-jdI~ zU{x(sSD9qgYIWy}CGt=1Jd4eFuf(-z_71I{9uX_uD30uPB}4mEdagz5Rk&h2VLEquDVYnbyIfHpxGP?EZW9P4~ay)O~U}TS#)f zjdS?z8XKu)cUVj3_%dJ9v-0?x;C|-$wNLW%By?Z0bf;YX`?%jo(ydFTQS0E$A4+Ya zp0#uK)NX0ZpC@wlU)K9g)QM{UKct?lMK$y3ku${w{S2yC5pXJb$H^eAnvjEkRURL1Onz%lQRq3nG9d%h3SEmV`I z9+ll}+g*6SqxjN(*U}5+6Za~Gc{5*%5ObfnT6&S>_Vge5i&XgR4hqi7I z;hxDN6zEtKxZaXGMYge^??}R({n`!lD?M(ndDG8*sGPf#bAn$Ei*}xx=ZB88O?&(0 zr+e@CwsG&n8-+gdlcnAry|ha;MQyVB@@ejpH}6i6eBWku@$kBDmIkW>4r}cenvqpL zsquCb_shMzYv=h)`nWf3M!|!n$yzUVe6p5O$?Pg#@iK6JfbRTR$EtR$e(L9wUCR;8 zpY`CN?!gn8|5SfWiOX#G6{Rk7!t6m>tm-k)mQgT z>YlqqZ}usUgr;gfyIGs(Geo_7EA@TeXFd(S582_j_j<2T?o_(WF1l#hITm@{TWr3) zYDIURZEEvxTgca2@uFe7O6#`dr;K(_?|Ajjb~`U8)N1Z2X@7LeTl?%s<}Fg!i}KC} zt+8!?8FXsvC!ITdU2|odE?iUbF}i)a>1vhckx+$~x0=^7C0_m`Tv)@O)A>GsS$=73@`_)7AN3^PIDatS>W|QR zCvNWjv)gQ*G2D}$-@7A8_eMcxb>gYKOzB_8x9cU}tkE@}WOTsL>&1;1=7!Jz-w)pR z?<&tPU$p~g#T8#MmZs!Zq+ZJO`SmHTW@7<=jPL3VeN4Xf)+@L0h0FhxblbHw;^X0< znyn@La(4}xm;cVu`g%U^%*@*tY|r~HK4uVQp&Q?5@wIwsQ%|}{+9fBGq}qpGN2DeP zUY={eV6A-6U(K)^>)j>Im;6~#EZ?(Jk3aN&Phi&T0<~t7H_ZowyuRPhT=cH~?IPwK z`O2xeVL6FOCq)isvsSQJb^feK{3T{o=kxK!x=4#1S+@TQtCoZad^-5@LVQQoAM5?! z@5>ZN7c<@!SDQ1pxm@t;<=0Q@DtG;O)uqj}Q@85^&pO5V{yh6K82y^O*3DtyzV3hY zIk#To=To{re(%$a(VlFu{2)ia?3ceskDY&1!5%feN^5f8w)My3Ch5JqZ|ZpIiKJ&} zkooKV#s2f0uH24tihkJ0k!|U;+iv0EW2p`Odl|Q{z5eu@v2T}yo1m{m>^`BTKc87d z=C$pAu0L^x&7!jftB&tvs^tovRD5FXtGugr&$bns9Shx(lf2>SK3$XF^D1htf0*Zd zoG<8_*^XJEfoyIaiK~m|v#jb+-~Fhvtzn^`PT9jvo07!U59e>Fe3GBW_^hgE_ElH= ziSw==XuN)4=aq{-3s))aKPAvCySanqn1SPy&W-V!-Wb*kDOmh&T~S{5-_FOsPOO=oJ-O?c#a)-( z%xy~+b%q_Ev?c48@XtG9>zZaecB)yls{PsXJ3sL74?h<%H{M-M&L5pWo_hJOem?El0})S#uidzU>ny%W?JlV`cRyYSb( z676@d7q>bji#3)^fBNZxFSq`qzmnhqBDeU)OeY$O->20z3`nQ{M%hMkP=NSYZEim|WQnw=Oyu};; z&4=67+Y_qSNb{U{W$Ewkx25#@orlSedZqKH#isGRpRoGE!c%P-O!H4XtEsGcl=|x1 z#gNIt8ndPb3I=bM`VxBUqR-ny!B;*8zm{pr*W-2a+qtqO-KRwN;H^sajo(5n{5GCT zeBO0eO7%=4Pusi$B)6zHhx= z%VYJn#=@`6`*nTx^X+)qe?N!w#qZsR)GHJ;X5T5R{V`GJ$$?2L9yaSK8{KD_KaZ<< zW}xmmk%)guYqu}+tjh|q6Kj)mOl{WPxA&2sXpnD3LG{fUG3I+5-^Ma7D`D15>^mxv zy0q)NQZ?%|vFgx*%g2~(N>^$@=)YCq8_x9g7EqtTuxFwVOtcs^I_&+i}P2loY zp4PU%J$>Tl3C%Lker(qKz0F3YogXY|YJZddcK(cz{iVBe9WL1K7K}d}wkC4R^fNKScC|Y11bE_G6;-dl zjUhady1VJT)3Tc_#wVK>ENG~tx()}NJ6~(*Ke-w+N_Ocm->vh?3LVckgs*cs!eWIU%PhQeyNk4CHY!;=ARn( zOV{6jTYK=+LeGj47p||rQ@1dch3M~A%VnG$`mAs1nOC`0QRkntR^3%08;l z;XRcg3wPqhwW~wG;dewrtSM* zN^RvA{$tR`a_)D>*VU7csm`!zIy3QX6dQMz+?4wPb~`JXEMAu+{C9p`Feyvu2q5bk~3#aBT{?GRIZ8InjY(RrBv(DVw(F^m+&9&XqmARVUDM+NYBBL2c=x zSF9F1kx`!bcB1gk#;vw~8`iDgU2F7NgjM1AxBDLtub8-B`LO@#expC8iJY^=E0WFZJ|!bb0dQ4O5Q_o|5%=F{jnY z<%`~dHt#x}HxnN2b)8*krYrFM?ZR8feSK@(J=XbuUzhe%EHmWFJ<(6Aof?BX47gr}Zr+&5UP zJ@3Aoz2@b70g$NZEGzpQIY4@Ja+2hnWj^` zJ6iKJ?Ecojjji^Xnkw;ss_&z^{hTT<7$2`%Ez*6;;=wT<*VB2MeT^csvvZfOy}D`V zKMnmWIU|GVb>Z@crKtLx5xyukVK>;JPm)b29mrTk>O&M2~Xo_~HLi{P0vog9z0 zT@wFTHgislkjA5od73OGYv!Gtb5V)Mso?#x33aBOJad{_0zZ84O?==Uqnx3-(Vd^q z()0QSf%$d2x%o`17ff*xS54db^KpjYy7>vO^U}{GsF-d%S-}^1tY*vC3d09i5BDtQ zzf>HysI1BVtNEqpH?FIvKi=7zfAd7qnzvIwMohk>=DTmgi49uQCj1l@IwobK)V?o% z@8sTTo=5J;9r*Q|$7Iw01_8suri#cICl==A3W*{%HE%xsQ#bP5(bROv&7invf^pAx z(OQWazcgy?jULUKX`3>C|N6^;7iF{zD_MS>40(U>h%tv|qAXjr-by(Vmtn`GOENy*)3;0{7qm^M!9R% z6j9&UCjvXZv2@Ga3H|r>NU@7Gv&mf>fv(l(yw7g*SO`o1gkyzsxWz>}!~uPS29P(L%Lz?KXecJurP{Se~#6i@x^D z7}g#wj@2hN{B(L0*Onq1;a~79Z!$ZxyI@9=?*9&s?;CEv^JQq0k*=7kaAnPt`AhfL zubJsSXA!G0b4p2nM&917DGRsky-~sTdJ?Z4p>nCP0voq`E<`~a+;8~^@y% zA8R2lwn69Ea({W{t+DD{tDh&w9uyMU_Gho~trO4hxNp+_-1__D-OQN}gIgx$KG6=_ zyy6zay5Kp7Wlhcaw|v|2@ZbTtkbOTbm+|f1$ksS<+XC;JTOR)o-1=`N$CmzUyAgi@ zf05RWq-h^E$o+aB$0H+Z%Ch;YwRT_i-3tf*b9Sa&P@l6l+b@#&#Qp4L+&6loPRBfx zkvn#&B20nxdeP~b-OA4l@|>GHzP(jdHJX<1GnU%NM>d@0_l?~#@IIK1A>VN7~J8t0JqwIX%qhw3(+Korp zCp}nsUN&u8mUrLEL(?W5_b@YL&F)bzTy)aGY=Ye+r#Y&zF)E!WUkMyi|8;$usot! z`{>o_QMiSJf5GET=5fCh1&ik2Y(6a3s#85@2?*7&DFO+vrDVRO;9G~p_ zH-1Okz9`9k;&`XBaOs^B|85--+PQhDN$=i<$5UR#gnnQ7OQ2%&ETM*=%~ua(oyZLgy{Gms8e7D7^lt{9}Oz=hU_3%e35i-45^p5H)wpZOe5hPdnc`7x-fS(g@~Vj2dBc&GN4BI!J$BQ6WM*Zao86`_#P>_huer QU|?YIboFyt=akR{0D#y+TmS$7 literal 0 HcmV?d00001 diff --git a/client/assets/flags/flag_uruguay.png b/client/assets/flags/flag_uruguay.png new file mode 100644 index 0000000000000000000000000000000000000000..2b317c6c290991107e9d42e849b077cb89092839 GIT binary patch literal 5892 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 literal 0 HcmV?d00001 diff --git a/client/assets/flags/flag_usa.png b/client/assets/flags/flag_usa.png new file mode 100644 index 0000000000000000000000000000000000000000..61b385366512c9ffe048a1cd500444297f6c8e08 GIT binary patch literal 4155 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 \ No newline at end of file diff --git a/client/assets/icon_missile.svg b/client/assets/icon_missile.svg new file mode 100644 index 0000000..2baae3e --- /dev/null +++ b/client/assets/icon_missile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/js/feed.js b/client/js/feed.js new file mode 100644 index 0000000..44ff7f3 --- /dev/null +++ b/client/js/feed.js @@ -0,0 +1,59 @@ +// The game feed: a chat-like column of short-lived messages pinned to the +// lower-left corner of the map. Newest lines sit at the bottom and each line +// retires on its own, so the map is never permanently obstructed. + +export const FEED_TTL_MS = 4500; +export const FEED_ALERT_TTL_MS = 8000; +const FEED_FADE_MS = 400; + +export class GameFeed { + constructor($container, options = {}) { + this.$container = $container; + this.ttl = options.ttl !== undefined ? options.ttl : FEED_TTL_MS; + this.alertTtl = options.alertTtl !== undefined ? options.alertTtl : FEED_ALERT_TTL_MS; + this.fade = options.fade !== undefined ? options.fade : FEED_FADE_MS; + this._timers = new Set(); + } + + // Appends one line at the bottom of the feed. `alert` marks a message the + // player must not miss (being declared war on) and lets it linger longer. + // A negative `ttl` keeps the line until `clear` or a later retirement. + push(text, { alert = false, ttl } = {}) { + if (!this.$container || !this.$container.length) return null; + const life = ttl !== undefined ? ttl : alert ? this.alertTtl : this.ttl; + const $line = $("
").text(text); + if (alert) $line.addClass("feed-alert"); + this.$container.append($line); + if (life >= 0) { + const timer = setTimeout(() => { + this._timers.delete(timer); + this._retire($line); + }, life); + this._timers.add(timer); + } + return $line; + } + + lineCount() { + return this.$container ? this.$container.children(".feed-line").length : 0; + } + + _retire($line) { + $line.addClass("leaving"); + if (this.fade <= 0) { + $line.remove(); + return; + } + const timer = setTimeout(() => { + this._timers.delete(timer); + $line.remove(); + }, this.fade); + this._timers.add(timer); + } + + clear() { + for (const timer of this._timers) clearTimeout(timer); + this._timers.clear(); + if (this.$container) this.$container.empty(); + } +} diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js new file mode 100644 index 0000000..27a7c94 --- /dev/null +++ b/client/js/game_screen/panels.js @@ -0,0 +1,536 @@ +// The game screen's HUD: the top and bottom panels, selection and stack menus, +// hover tooltips, the pillage forecast and the modal wiring. These methods run +// on the GameScreen instance (composed in ../game_screen.js) and only read the +// snapshot and forward orders. + +import { describeNews } from "../modals.js"; +import { compact, groupDigits, improvements as fmtImprovements } from "../../../shared/text_format.js"; +import { + CIVILISATIONS, + TRAINABLE_UNIT_IDS, + BUILDINGS, + GOVERNMENTS, + TECHNOLOGIES, + NEWS_WAR, + pillageSteps, +} from "../../../shared/data.js"; +import { defenseFactors, unitDefense } from "../../../shared/rules.js"; +import { dateString } from "../../../shared/game_clock.js"; +import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js"; + +export const panelMethods = { + _bindHud() { + $("#city-train").on("click", () => this._openTrainModal()); + $("#city-build").on("click", () => this._openBuildingsModal()); + $("#civ-flag").on("click", () => this._openNationModal()); + $("#news-button").on("click", () => this._openNewsModal()); + + this.trainModal.onTrain = (cityId, protoIndex) => + this.sendOrder({ type: "train", city: cityId, proto: protoIndex }); + this.trainModal.onCancel = (cityId, index) => + this.sendOrder({ type: "cancel_train", city: cityId, index }); + this.buildingsModal.onBuild = (cityId, index) => + this.sendOrder({ type: "build", city: cityId, building: index }); + this.buildingsModal.onDemolish = (cityId, index) => + this.sendOrder({ type: "demolish", city: cityId, building: index }); + this.nationModal.onGovernment = (index) => + this.sendOrder({ type: "government", government: index }); + this.nationModal.onTechnology = (index) => + this.sendOrder({ type: "technology", technology: index }); + this.nationModal.onDeclareWar = (target) => + this.sendOrder({ type: "declare_war", target }); + + $("#unit-attack") + .on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget))) + .on("mouseleave", () => this._hideStatTooltip()); + $("#unit-defense") + .on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget))) + .on("mouseleave", () => this._hideStatTooltip()); + $("#unit-pillage") + .on("click", () => this._pillageSelected()) + .on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget))) + .on("mouseleave", () => this._hideStatTooltip()); + + // A click anywhere outside the stack menu dismisses it. + $(document).on("mousedown.stackmenu", (event) => { + if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) { + this._hideStackMenu(); + } + }); + + $(window).on("keydown.game", (event) => this._onKey(event)); + }, + + // A small hover tooltip anchored above a stat, fed by its data-tooltip text. + _showStatTooltip($stat) { + const text = $stat.attr("data-tooltip"); + if (!text) return; + this._hoveredStat = $stat; + this.$statTooltip.text(text).removeClass("hidden"); + const offset = $stat.offset() || { left: 0, top: 0 }; + const width = this.$statTooltip.outerWidth() || 0; + const height = this.$statTooltip.outerHeight() || 0; + this.$statTooltip.css({ + left: `${Math.max(8, offset.left + $stat.outerWidth() / 2 - width / 2)}px`, + top: `${Math.max(8, offset.top - height - 8)}px`, + }); + }, + + _hideStatTooltip() { + this._hoveredStat = null; + this.$statTooltip.addClass("hidden"); + }, + + // ------------------------------------------------------- selection ------ + + // Selects the given unit ids (already filtered to our own), refreshing the + // map highlight and the bottom panel. A single id is a normal selection; an + // empty list clears; several ids form a stack order. + _selectUnits(ids) { + const units = []; + for (const id of ids) { + const unit = this._unit(id); + if (unit && unit.civ === this.localCiv) units.push(unit); + } + this.selectedUnitIds = units.map((unit) => unit.id); + this.selectedCityId = 0; + this.map.setSelectedUnits(this.selectedUnitIds); + this._hideStackMenu(); + this._showUnits(units); + }, + + _selectedUnits() { + return this.selectedUnitIds.map((id) => this._unit(id)).filter(Boolean); + }, + + _openStackMenu(coords, ids, clientX, clientY) { + const units = []; + for (const id of ids) { + const unit = this._unit(id); + if (unit && unit.civ === this.localCiv) units.push(unit); + } + if (units.length === 0) return; + const $menu = this.$stackMenu.empty(); + const selection = this._selectedUnits(); + if (selection.length > 0) { + const $move = $("").text("Move here"); + $move.on("click", () => { + this._hideStackMenu(); + this._orderMove(selection, coords); + }); + $menu.append($move); + } + for (const unit of units) { + const proto = this.protoUnits[unit.proto]; + const $item = $("") + .text(`${proto ? proto.name : "Unit"} — ${Math.round(unit.hp)}/${unit.maxHp} HP`); + $item.on("click", () => this._selectUnits([unit.id])); + $menu.append($item); + } + const $all = $("").text( + `Select all (${units.length})` + ); + $all.on("click", () => this._selectUnits(units.map((unit) => unit.id))); + $menu.append($all); + $menu.removeClass("hidden").css({ left: `${clientX}px`, top: `${clientY}px` }); + }, + + _hideStackMenu() { + if (this.$stackMenu && this.$stackMenu.length) this.$stackMenu.addClass("hidden"); + }, + + // The slowest member of a group sets the pace. The fallback matches the + // server's DEFAULT_UNIT_SPEED so prediction cannot disagree with the + // authoritative movement cost. + _groupSpeed(units) { + let speed = Infinity; + for (const unit of units) { + const proto = this.protoUnits[unit.proto]; + const value = proto && proto.speed ? proto.speed : DEFAULT_UNIT_SPEED; + speed = Math.min(speed, value); + } + return Number.isFinite(speed) ? speed : null; + }, + + async _groupMovePlan(units, coords) { + const speed = units.length > 1 ? this._groupSpeed(units) : null; + const targets = []; + const routes = new Map(); + for (const unit of units) { + const route = await this.map.planMove(unit, coords, speed); + if (!route || route.length < 2) continue; + routes.set(unit.id, route); + if (!this._isMilitaryUnit(unit)) continue; + for (const point of route) { + const owner = this.map.territory.get(`${point.x},${point.y}`); + if (owner === undefined || owner < 0 || owner === unit.civ) continue; + if (this._isAtWar(unit.civ, owner)) continue; + if (!targets.includes(owner)) targets.push(owner); + } + } + return { targets, speed, routes }; + }, + + // ------------------------------------------------------- pillage ------- + + // What the selected units would remove, gain and heal by pillaging the tile + // they stand on, or null when they cannot pillage. + _pillageForecast(units) { + if (units.length === 0) return null; + const coords = units[0].coords; + const sameTile = units.every((unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1]); + if (!sameTile) return null; + const owner = this._territoryOwner.has(`${coords[0]},${coords[1]}`) + ? this._territoryOwner.get(`${coords[0]},${coords[1]}`) + : -1; + if (owner < 0 || owner === this.localCiv) return null; + // A unit in combat cannot pillage. + if (this._battles.has(`${coords[0]},${coords[1]}`)) return null; + const population = this._population.get(`${coords[0]},${coords[1]}`) || 0; + const { steps, gdpMultiplier } = pillageSteps(population, units.length); + const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp); + const healed = ordered.map((unit, index) => + Math.min(unit.maxHp, unit.hp + steps[index].heal) - unit.hp + ); + return { population, steps, gdpMultiplier, healed }; + }, + + _updatePillageButton(units) { + const $button = $("#unit-pillage"); + const forecast = this._pillageForecast(units); + if (!forecast) { + $button.addClass("hidden").removeAttr("data-tooltip"); + return; + } + const removed = forecast.population - forecast.steps[forecast.steps.length - 1].remaining; + const share = forecast.population > 0 ? (removed / forecast.population) * 100 : 0; + const heals = forecast.healed.map((hp) => `+${Math.round(hp)}`).join(", "); + $button + .removeClass("hidden") + .attr( + "data-tooltip", + `Removes ${groupDigits(Math.round(removed))} people (${share.toFixed(0)}%) · ` + + `GDP/capita ×${forecast.gdpMultiplier.toFixed(2)} · heals ${heals} HP` + ); + }, + + _isMilitaryUnit(unit) { + const proto = this.protoUnits[unit.proto]; + return !!(proto && proto.military); + }, + + _isAtWar(a, b) { + if (a < 0 || b < 0 || a === b) return false; + return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`); + }, + + _civName(index) { + const civ = this.civilisations[index]; + return civ ? civ.name : `Nation ${index + 1}`; + }, + + _civDemonym(index) { + const civ = this.civilisations[index]; + return civ && civ.demonym ? civ.demonym : this._civName(index); + }, + + // ------------------------------------------------------- top panel ----- + + _updateTopPanel(state) { + const civ = this.civilisations[this.localCiv]; + if (civ) { + $("#civ-name").text(civ.name); + const flag = civ.flag || (CIVILISATIONS.find((c) => c.id === civ.id) || {}).flag; + if (flag) $("#civ-flag-img").attr("src", `assets/flags/${flag}`); + } + $("#player-name").text(this.config.playerName || (this.network.players[this.network.localPlayerId] || {}).name || ""); + $("#admin-label").toggle(this.network.isAdmin); + $("#stat-date").text(dateString(state.totalHours)); + const stats = state.viewerStats || {}; + $("#stat-pop").text(compact(stats.population || 0)); + $("#stat-gdp").text(`€${compact(stats.gdp || 0)}`); + $("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`); + $("#stat-budget").text(`€${compact(stats.budget || 0)}`); + }, + + _updateBottomPanel() { + if (this.selectedUnitIds.length > 0) { + this._showUnits(this._selectedUnits()); + } else if (this.selectedCityId) { + const city = this._city(this.selectedCityId); + if (city) this._showCity(city); + } + }, + + // Shows the bottom panel for one or many selected units. A stack shows its + // size and combined health, plus the pillage action when it can use it. + _showUnits(units) { + if (units.length === 0) { + $("#unit-info").addClass("hidden"); + this._hideStatTooltip(); + this._refreshBottomVisibility(); + return; + } + if (units.length === 1) { + this._showUnit(units[0]); + return; + } + const hp = units.reduce((sum, unit) => sum + unit.hp, 0); + const maxHp = units.reduce((sum, unit) => sum + unit.maxHp, 0); + $("#unit-name").text(`${units.length} units selected`); + $("#unit-attack, #unit-defense").addClass("hidden"); + $("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`); + $("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`); + this._updatePillageButton(units); + $("#tile-info, #city-info").addClass("hidden"); + $("#unit-info").removeClass("hidden"); + this._refreshBottomVisibility(); + }, + + _showUnit(unit) { + if (!unit) { + $("#unit-info").addClass("hidden"); + this._hideStatTooltip(); + this._refreshBottomVisibility(); + return; + } + const proto = this.protoUnits[unit.proto]; + $("#unit-name").text(proto ? proto.name : "Unit"); + $("#unit-attack, #unit-defense").removeClass("hidden"); + this._showUnitStats(unit, proto); + const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0; + $("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`); + $("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`); + this._updatePillageButton([unit]); + $("#tile-info, #city-info").addClass("hidden"); + $("#unit-info").removeClass("hidden"); + this._refreshBottomVisibility(); + }, + + // Attack and defense of the unit where it stands, with the hovering tooltips + // listing each factor that shaped the number. + _showUnitStats(unit, proto) { + const coords = { x: unit.coords[0], y: unit.coords[1] }; + const tile = this.map.getTile(coords); + const hasCity = ((this.snapshot && this.snapshot.cities) || []).some( + (city) => city.coords[0] === coords.x && city.coords[1] === coords.y + ); + const baseAttack = proto && typeof proto.attack === "number" ? proto.attack : 0; + const baseDefense = proto && typeof proto.defense === "number" ? proto.defense : 0; + $("#unit-attack") + .text(`Attack ${Math.round(baseAttack)}`) + .attr("data-tooltip", `Base ${Math.round(baseAttack)}`); + + const factors = defenseFactors(tile, hasCity); + const defense = unitDefense(baseDefense, tile, hasCity); + const parts = [`Base ${Math.round(baseDefense)}`]; + for (const factor of factors) { + parts.push(`${factor.label} +${Math.round(baseDefense * factor.bonus)}`); + } + $("#unit-defense") + .text(`Defense ${Math.round(defense)}`) + .attr("data-tooltip", parts.join(", ")); + // Keep an open tooltip in step with the unit it describes across snapshots. + if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) { + const text = this._hoveredStat.attr("data-tooltip"); + if (text) this.$statTooltip.text(text); + } else { + this._hideStatTooltip(); + } + }, + + _showCity(city) { + if (!city) { + $("#city-info").addClass("hidden"); + this._refreshBottomVisibility(); + return; + } + $("#city-name").text(city.name); + $("#city-population").text(`Population: ${groupDigits(city.population)}`); + $("#city-improvements").text(`Improvements: ${fmtImprovements(city.improvements)}`); + $("#tile-info, #unit-info").addClass("hidden"); + $("#city-info").removeClass("hidden"); + this._refreshBottomVisibility(); + }, + + _showTile(coords) { + const k = `${coords.x},${coords.y}`; + const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1; + const civ = this.civilisations[owner]; + const tile = this.map.getTile(coords); + const city = (this.snapshot.cities || []).find( + (c) => c.coords[0] === coords.x && c.coords[1] === coords.y + ); + $("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`); + $("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`); + $("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`); + $("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`); + $("#tile-improvements").text(`Improvements: ${fmtImprovements(city ? city.improvements : [])}`); + $("#city-info, #unit-info").addClass("hidden"); + $("#tile-info").removeClass("hidden"); + this._refreshBottomVisibility(); + }, + + _refreshBottomVisibility() { + const visible = + !$("#tile-info").hasClass("hidden") || + !$("#city-info").hasClass("hidden") || + !$("#unit-info").hasClass("hidden"); + $("#bottom-panel").toggleClass("hidden", !visible); + }, + + _unit(id) { + return (this.snapshot.units || []).find((u) => u.id === id) || null; + }, + + _city(id) { + return (this.snapshot.cities || []).find((c) => c.id === id) || null; + }, + + // -------------------------------------------------------------- modals --- + + _openTrainModal() { + const city = this._city(this.selectedCityId); + if (!city) return; + const trainable = TRAINABLE_UNIT_IDS.map((id) => { + const index = this.protoUnits.findIndex((p) => p.id === id); + return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null; + }).filter(Boolean); + this.buildingsModal.close(); + this.trainModal.show( + { id: city.id, name: city.name }, + (this.snapshot.viewerStats || {}).gdp || 0, + trainable, + this._trainingQueue(city.id) + ); + this._updateTrainModal(); + }, + + _trainingQueue(cityId) { + return (this.snapshot.training || {})[cityId] || []; + }, + + _updateTrainModal() { + if (!this.snapshot || !this.selectedCityId) return; + if (!this.trainModal.isShowing(this.selectedCityId)) { + this._trainingCityId = 0; + return; + } + const queue = this._trainingQueue(this.selectedCityId); + this.trainModal.renderQueue(queue); + const entry = queue[0]; + if (!entry) { + if (this._trainingCityId === this.selectedCityId) { + this.trainModal.endTraining(); + this._trainingCityId = 0; + } + return; + } + const proto = this.protoUnits[entry.protoIndex]; + const name = proto ? proto.name : "unit"; + if (this._trainingCityId !== this.selectedCityId) { + this._trainingCityId = this.selectedCityId; + this.trainModal.beginTraining(name, entry.totalHours, entry.elapsedHours); + } else { + this.trainModal.setTrainingProgress(name, entry.elapsedHours, entry.totalHours); + } + }, + + _openBuildingsModal() { + const city = this._city(this.selectedCityId); + if (!city) return; + this.trainModal.close(); + this.buildingsModal.show( + { id: city.id, name: city.name }, + (this.snapshot.viewerStats || {}).gdp || 0, + (this.snapshot.viewerStats || {}).budget || 0, + BUILDINGS, + this._cityBuildings.get(city.id) || {} + ); + }, + + _updateBuildingsModal() { + if (!this.snapshot || !this.buildingsModal.isShowing(this.selectedCityId)) return; + const stats = this.snapshot.viewerStats || {}; + this.buildingsModal.refresh( + stats.gdp || 0, + stats.budget || 0, + this._cityBuildings.get(this.selectedCityId) || {} + ); + }, + + _openNationModal() { + const stats = this.snapshot.viewerStats || {}; + this.nationModal.show( + GOVERNMENTS, + stats.government || 0, + TECHNOLOGIES, + this._researched, + stats.gdp || 0, + stats.budget || 0, + stats.research || 0, + this._finances(stats), + this._diplomacyInfo(), + this._conflicts + ); + }, + + _updateNationModal() { + if (!this.snapshot || !this.nationModal.isShowing()) return; + const stats = this.snapshot.viewerStats || {}; + this.nationModal.refresh( + stats.government || 0, + this._researched, + stats.gdp || 0, + stats.budget || 0, + stats.research || 0, + this._finances(stats), + this._diplomacyInfo(), + this._conflicts + ); + }, + + _diplomacyInfo() { + return { + civs: this.civilisations, + localCiv: this.localCiv, + relations: (this.snapshot && this.snapshot.diplomacy) || [], + stats: (this.snapshot && this.snapshot.civStats) || [], + }; + }, + + _openNewsModal() { + this.newsModal.show(this._news, this.civilisations); + }, + + _updateNewsModal() { + if (!this.snapshot || !this.newsModal.isShowing()) return; + this.newsModal.refresh(this._news, this.civilisations); + }, + + // Pops newly arrived news into the corner feed. The first snapshot only + // records what is already known, so history never floods the screen. + _updateFeed(news) { + if (this._seenNews === null) { + this._seenNews = new Set(news.map((entry) => entry.id)); + return; + } + for (const entry of news) { + if (this._seenNews.has(entry.id)) continue; + this._seenNews.add(entry.id); + this._pushNews(entry); + } + }, + + _pushNews(entry) { + if (entry.type === NEWS_WAR && entry.target === this.localCiv) { + this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true }); + return; + } + this.feed.push(describeNews(entry, this.civilisations)); + }, + + _finances(stats) { + return stats.breakdown || null; + }, +}; diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js new file mode 100644 index 0000000..00bd002 --- /dev/null +++ b/client/js/map_view/constants.js @@ -0,0 +1,43 @@ +// Tuning constants for the world map view. + +export const RESYNC_HOURS = 0.75; +export const PREDICTION_GRACE_HOURS = 2.0; +export const DRAG_THRESHOLD = 5; +export const ZOOM_MIN = 0.2; +export const ZOOM_MAX = 2.0; + +// A battle spreads the two stacks to either side of the tile centre, with the +// battle icon between them. Side offset is in pixels; step is the spacing +// between the (half-size) units of one stack. +export const BATTLE_SIDE_OFFSET = 15; +export const BATTLE_STACK_STEP = 9; + +// The zoomed-out overview groups this many tiles per side into one block, so a +// full world is a few hundred flat elements instead of tens of thousands of +// textured ones. +export const OVERVIEW_BLOCK = 4; + +// The detailed view is built from square blocks of tiles. Only the blocks that +// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are +// materialised, so panning costs the same no matter how big the map is. Tiles +// are pooled and recycled as blocks leave the view. +export const CHUNK_SIZE = 8; +export const CHUNK_MARGIN = 1; +// Built chunks are kept detached in an LRU cache so panning back over ground +// already visited reattaches them instead of rebuilding hundreds of tiles. The +// cap bounds how many detached tile nodes can be held at once. +export const CHUNK_CACHE_LIMIT = 160; + +// Flat colours used by the simplified overview, keyed by terrain type. The +// full-resolution view keeps the textured hexes; this is only the LOD stand-in. +export const OVERVIEW_COLOURS = { + Forest: "#2f6b3a", + Hills: "#7a6a44", + Mountain: "#7d7469", + Desert: "#c2b280", + Ice: "#dcecf5", + Land: "#4f8a4f", + Sea: "#1b3f6b", +}; +export const OVERVIEW_SEA = "#1b3f6b"; +export const OVERVIEW_FOG = "#04040a"; diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js new file mode 100644 index 0000000..40b04f1 --- /dev/null +++ b/client/js/map_view/entities.js @@ -0,0 +1,405 @@ +// Turning a server snapshot into DOM: territory borders, fog, cities and units, +// plus the stack and battle layout of unit markers. + +import { key, mapToLocal, parseKey } from "../../../shared/hex.js"; +import { statusById } from "../../../shared/data.js"; +import { BATTLE_SIDE_OFFSET, BATTLE_STACK_STEP } from "./constants.js"; +import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js"; + +export const entityMethods = { + setCatalogue(civilisations, protoUnits) { + this.civilisations = civilisations; + this.protoUnits = protoUnits; + }, + + applySnapshot(state) { + this.ensureTerrain(state.seed); + this.localCiv = state.viewer; + this._applyTerritory(state.territory); + this._applyKnowledge(state.explored, state.visible); + this._applyBattles(state.battles); + this._syncCities(state.cities); + this._syncUnits(state.units); + if (!this._centered) this._centerOnCapital(state.cities); + this.animate(0); + }, + + // Mirrors the battlefields in the snapshot: which tile is being fought over, + // and which side defends it. Also keeps one battle marker element per tile. + _applyBattles(entries) { + this._battles = new Map(); + const seen = new Set(); + for (const entry of entries || []) { + const k = key(entry[0], entry[1]); + seen.add(k); + this._battles.set(k, { defender: entry[2], attacker: entry[3] }); + let marker = this._battleViews.get(k); + if (!marker) { + marker = document.createElement("div"); + marker.className = "battle-marker"; + const icon = document.createElement("img"); + icon.src = "assets/icon_battle.svg"; + icon.alt = "Battle"; + marker.appendChild(icon); + this.$entities[0].appendChild(marker); + this._battleViews.set(k, marker); + } + const local = mapToLocal(entry[0], entry[1]); + marker._localX = local.x; + marker._localY = local.y; + } + for (const [k, marker] of Array.from(this._battleViews)) { + if (seen.has(k)) continue; + marker.remove(); + this._battleViews.delete(k); + } + }, + + _applyTerritory(entries) { + const signature = hashEntries(entries); + if (signature === this._territorySignature) return; + this._territorySignature = signature; + + this.territory = new Map(); + for (const entry of entries) { + this.territory.set(key(entry[0], entry[1]), entry[2]); + } + + this._renderBorders(); + this._drawOverview(); + }, + + // Draws a thick tinted segment along every edge shared with a different owner + // (or the open sea). Each segment is + // nudged towards its owner's tile by half its width so two neighbours' borders + // sit side by side instead of painting over each other on the shared edge. + // Only the chunks currently on screen are rebuilt. + _renderBorders() { + if (!this._chunks) return; + for (const rec of this._chunks.values()) this._buildBorders(rec); + if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.borderDirty = true; + }, + + _buildBorders(rec) { + rec.borderDirty = false; + this._recycleLayer(rec.borderEl, this._borderPool); + const fragment = document.createDocumentFragment(); + for (const k of rec.tileKeys) { + const owner = this.territory.get(k); + if (owner === undefined) continue; + const civ = this.civilisations[owner]; + if (!civ) continue; + const coords = parseKey(k); + const center = mapToLocal(coords.x, coords.y); + const localCenter = { x: center.x - rec.x, y: center.y - rec.y }; + for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { + const nk = key(neighbour.x, neighbour.y); + if (this.territory.has(nk) && this.territory.get(nk) === owner) continue; + const delta = this.topology.pixelDelta(coords, neighbour); + const target = { x: localCenter.x + delta.x, y: localCenter.y + delta.y }; + const [a, b] = nearestCorners(localCenter, target); + const width = 6; + const inward = inwardOffset(a, b, localCenter, width / 2); + const el = this._acquireHex(this._borderPool); + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy); + const angle = Math.atan2(dy, dx); + el.className = "border-seg"; + el.style.left = `${a.x}px`; + el.style.top = `${a.y - width / 2}px`; + el.style.width = `${length}px`; + el.style.height = `${width}px`; + el.style.background = civ.primaryColour; + el.style.transform = inward === 0 + ? `rotate(${angle}rad)` + : `rotate(${angle}rad) translateY(${inward}px)`; + el.style.borderRadius = `${width / 2}px`; + fragment.appendChild(el); + } + } + rec.borderEl.appendChild(fragment); + }, + + _applyKnowledge(exploredEntries, visibleEntries) { + // `explored` is append-only, so a length change is a cheap exact test for it. + // Visibility changes membership without changing size, so it is hashed. Both + // matter to fog, so either change rebuilds the sets and the on-screen fog. + const visibleSignature = hashEntries(visibleEntries); + const changed = + exploredEntries.length !== this._exploredCount || + visibleSignature !== this._visibleSignature; + if (!changed) return; + this._exploredCount = exploredEntries.length; + this._visibleSignature = visibleSignature; + this.explored = new Set(exploredEntries.map((e) => key(e[0], e[1]))); + this.visible = new Set(visibleEntries.map((e) => key(e[0], e[1]))); + + this._refreshFog(); + this._drawOverview(); + }, + + _syncCities(cities) { + const seen = new Set(); + this._cityCiv = new Map(); + for (const data of cities) { + const id = data.id; + seen.add(id); + let view = this._cityViews.get(id); + if (!view) { + view = this._createCityView(data); + this._cityViews.set(id, view); + } + const coords = { x: data.coords[0], y: data.coords[1] }; + this._cityCiv.set(key(coords.x, coords.y), data.civ); + const local = mapToLocal(coords.x, coords.y); + view._localX = local.x; + view._localY = local.y; + const wrappedX = local.x + this._wrapShift(local.x); + view.style.left = `${wrappedX}px`; + view.style.top = `${local.y}px`; + const owned = data.civ === this.localCiv; + const label = view._label; + label.textContent = data.name; + label.classList.toggle("capital", !!data.isCapital); + label.style.left = `${wrappedX}px`; + label.style.top = `${local.y}px`; + const marker = view.querySelector(".marker"); + marker.style.background = this.civilisations[data.civ] + ? this.civilisations[data.civ].primaryColour + : "#444"; + marker.style.cursor = owned ? "pointer" : "default"; + view.dataset.owned = owned ? "1" : "0"; + this._syncStatusIcons(view.querySelector(".city-statuses"), data.statuses || []); + // Foreign cities are only shown when their tile has been explored. + const k = key(coords.x, coords.y); + const shown = owned || this.explored.has(k); + view.style.display = shown ? "" : "none"; + label.style.display = shown ? "" : "none"; + } + for (const [id, view] of Array.from(this._cityViews)) { + if (seen.has(id)) continue; + view.remove(); + if (view._label) view._label.remove(); + this._cityViews.delete(id); + } + }, + + _createCityView(data) { + const view = document.createElement("div"); + view.className = "entity city"; + const marker = document.createElement("div"); + marker.className = "marker"; + marker.style.backgroundImage = "url(assets/icon_modern_city.svg)"; + const statuses = document.createElement("div"); + statuses.className = "city-statuses"; + view.appendChild(marker); + view.appendChild(statuses); + marker.addEventListener("click", (event) => { + event.stopPropagation(); + if (view.dataset.owned === "1") this.onCitySelected(data.id); + }); + this.$entities[0].appendChild(view); + const label = document.createElement("div"); + label.className = "city-label"; + this.$labels[0].appendChild(label); + view._label = label; + return view; + }, + + _syncUnits(units) { + const seen = new Set(); + // Store every unit's authoritative data first, so motion prediction can + // see where all of them are before any single one is advanced. + for (const data of units) { + seen.add(data.id); + this._unitData.set(data.id, data); + } + for (const data of units) { + const id = data.id; + let view = this._unitViews.get(id); + if (!view) { + view = this._createUnitView(data); + this._unitViews.set(id, view); + } + const proto = this.protoUnits[data.proto]; + const civ = this.civilisations[data.civ]; + view.querySelector(".body").style.background = civ ? civ.primaryColour : "#666"; + view.querySelector(".body img").src = proto ? `assets/${proto.icon}` : "assets/person.png"; + const flag = view.querySelector(".unit-flag"); + if (flag) { + if (civ && civ.flag) { + flag.src = `assets/flags/${civ.flag}`; + flag.style.display = ""; + } else { + flag.style.display = "none"; + } + } + view.querySelector(".health .fill").style.width = + `${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`; + view.classList.toggle("selected", this._selectedUnitIds.has(id)); + this._syncUnitStatuses(view, data); + this._updateUnitMotion(data); + // Foreign units are only shown inside currently visible tiles. + const k = key(data.coords[0], data.coords[1]); + view.style.display = data.civ === this.localCiv || this.visible.has(k) ? "" : "none"; + } + for (const [id, view] of Array.from(this._unitViews)) { + if (seen.has(id)) continue; + view.remove(); + this._unitViews.delete(id); + this._unitMotion.delete(id); + this._unitData.delete(id); + } + this._assignStackOffsets(); + }, + + // Spreads units sharing a tile around a small circle so every member of a + // stack stays visible and clickable. On a battlefield the two stacks are + // drawn half-size, pushed to opposite sides of the tile with the battle icon + // between them. + // Stacks are drawn as a single icon: the first unit's view stays in place + // and carries a badge with the stack size, while the rest are hidden but kept + // for hit-testing. On a battlefield the two stacks are drawn half-size, + // pushed to opposite sides of the tile with the battle icon between them. + _assignStackOffsets() { + for (const view of this._unitViews.values()) { + view.style.visibility = ""; + this._setStackCount(view, 0); + } + const byTile = new Map(); + for (const [id, motion] of this._unitMotion) { + const tile = this._currentTile(motion); + const k = key(tile.x, tile.y); + if (!byTile.has(k)) byTile.set(k, []); + byTile.get(k).push(id); + } + for (const [k, ids] of byTile) { + const battle = this._battles.get(k); + if (battle) { + this._assignBattleOffsets(ids, battle); + continue; + } + ids.forEach((id, index) => { + const motion = this._unitMotion.get(id); + if (motion) motion.offset = { x: 0, y: 0 }; + const view = this._unitViews.get(id); + if (!view) return; + view.classList.toggle("in-battle", false); + const leader = index === 0; + this._setStackCount(view, leader ? ids.length : 0); + view.style.visibility = leader ? "" : "hidden"; + }); + } + }, + + // Writes the stack size into the badge on a unit icon, hiding it for 0 or 1. + _setStackCount(view, count) { + if (!view) return; + const badge = view.querySelector(".unit-count"); + if (!badge) return; + if (count > 1) { + badge.textContent = String(count); + badge.style.display = "block"; + } else { + badge.textContent = ""; + badge.style.display = "none"; + } + }, + + _assignBattleOffsets(ids, battle) { + const sides = new Map(); + for (const id of ids) { + const data = this._unitData.get(id); + const side = data && data.civ === battle.attacker ? "attacker" : "defender"; + if (!sides.has(side)) sides.set(side, []); + sides.get(side).push(id); + } + for (const [side, members] of sides) { + const sign = side === "attacker" ? 1 : -1; + members.forEach((id, index) => { + const motion = this._unitMotion.get(id); + if (!motion) return; + motion.offset = { + x: sign * BATTLE_SIDE_OFFSET, + y: (index - (members.length - 1) / 2) * BATTLE_STACK_STEP, + }; + const view = this._unitViews.get(id); + if (view) { + view.classList.toggle("in-battle", true); + view.dataset.battleSide = side; + } + }); + } + }, + + _currentTile(motion) { + if (motion.path.length >= 2) { + const index = pathProgress(motion).index; + return motion.path[Math.min(index, motion.path.length - 1)]; + } + return motion.coords; + }, + + _createUnitView(data) { + const view = document.createElement("div"); + view.className = "entity unit"; + const body = document.createElement("div"); + body.className = "body"; + body.style.background = this.civilisations[data.civ] + ? this.civilisations[data.civ].primaryColour + : "#666"; + const img = document.createElement("img"); + img.alt = ""; + body.appendChild(img); + const health = document.createElement("div"); + health.className = "health"; + const fill = document.createElement("div"); + fill.className = "fill"; + health.appendChild(fill); + const count = document.createElement("div"); + count.className = "unit-count"; + count.style.display = "none"; + const statuses = document.createElement("div"); + statuses.className = "unit-statuses"; + const flag = document.createElement("img"); + flag.className = "unit-flag"; + flag.alt = ""; + view.appendChild(flag); + view.appendChild(body); + view.appendChild(health); + view.appendChild(count); + view.appendChild(statuses); + view.addEventListener("mousedown", (event) => { + event.stopPropagation(); + if (event.button === 0) this.onUnitSelected(data.id); + }); + this.$entities[0].appendChild(view); + return view; + }, + + // Draws one small icon per active status over the unit. The layout is driven + // by the snapshot's status ids and the shared catalogue, so a new status + // needs only a catalogue entry and an icon file. + _syncUnitStatuses(view, data) { + this._syncStatusIcons(view.querySelector(".unit-statuses"), data.statuses || []); + }, + + _syncStatusIcons(container, ids) { + if (!container) return; + const signature = ids.join(","); + if (container._statusSignature === signature) return; + container._statusSignature = signature; + container.textContent = ""; + for (const id of ids) { + const def = statusById(id); + if (!def) continue; + const icon = document.createElement("img"); + icon.className = "status-icon"; + icon.src = `assets/${def.icon}`; + icon.alt = def.label; + icon.title = def.description || def.label; + container.appendChild(icon); + } + }, +}; diff --git a/client/js/map_view/input.js b/client/js/map_view/input.js new file mode 100644 index 0000000..13068ff --- /dev/null +++ b/client/js/map_view/input.js @@ -0,0 +1,293 @@ +// Camera, zoom/LOD switching, pointer input, selection and screen-to-tile +// hit-testing. + +import { mapToLocal, key, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js"; +import { DRAG_THRESHOLD, ZOOM_MIN, ZOOM_MAX } from "./constants.js"; +import { clamp, parity } from "./utils.js"; + +export const inputMethods = { + // ------------------------------------------------------------- camera -- + + centerOn(coords) { + const local = mapToLocal(coords.x, coords.y); + this.camera.x = local.x; + this.camera.y = local.y; + this._applyCamera(); + }, + + _centerOnCapital(cities) { + for (const city of cities) { + if (city.civ === this.localCiv && city.isCapital) { + this.centerOn({ x: city.coords[0], y: city.coords[1] }); + this._centered = true; + return; + } + } + }, + + // `applyWrap` is false while dragging: the per-element wrapped copies are + // refreshed once per animation frame in `animate`, so a high pointer rate + // cannot multiply the work. Panning and zooming from elsewhere apply them + // immediately so the view is always consistent when read. + _applyCamera(applyWrap = true) { + const width = this.$viewport.width(); + const height = this.$viewport.height(); + this._updateLod(width, height); + if (this.lod) { + // With the whole world on screen there is nothing to scroll to; keep the + // camera over the map so the overview never drifts into the void. + this.camera.x = clamp(this.camera.x, this._worldMinX, this._worldMinX + this._worldWidth); + this.camera.y = clamp(this.camera.y, this._worldMinY, this._worldMinY + this._worldHeight); + } + const { x, y, zoom } = this.camera; + const tx = width / 2 - x * zoom; + const ty = height / 2 - y * zoom; + this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`); + if (applyWrap) this._syncChunks(); + }, + + // Switches between the textured hex view and the simplified overview. The + // detailed view only makes sense while the visible world stays within one + // horizontal period (so wrapping is seamless); past that -- when the player + // is zoomed out to see the whole map -- the coarse overview takes over. + _updateLod(width, height) { + let detailedWidth = this._worldWidth; + if (this._period > 0) detailedWidth = Math.min(detailedWidth, this._period) - HEX_W; + const detailed = detailedWidth > 0 && + width / this.camera.zoom <= detailedWidth && + height / this.camera.zoom <= this._worldHeight + HEX_H; + const lod = !detailed; + if (lod === this.lod) return; + this.lod = lod; + this.$world.toggleClass("lod", lod); + if (lod) { + this._clearChunks(); + this._drawOverview(true); + } else { + this._chunkDirty = true; + } + }, + + _setupCameraInput() { + let dragging = false; + let dragged = false; + let pressX = 0; + let pressY = 0; + let lastX = 0; + let lastY = 0; + let pressButton = -1; + + this.$viewport.on("mousedown", (event) => { + if (event.button !== 0 && event.button !== 2) return; + dragging = true; + dragged = false; + pressButton = event.button; + pressX = event.clientX; + pressY = event.clientY; + lastX = event.clientX; + lastY = event.clientY; + if (event.button === 0) this.$viewport.addClass("dragging"); + }); + + $(window).on("mousemove", (event) => { + if (!dragging) return; + if (Math.abs(event.clientX - pressX) > DRAG_THRESHOLD || + Math.abs(event.clientY - pressY) > DRAG_THRESHOLD) { + dragged = true; + } + if (dragged) { + this.camera.x -= (event.clientX - lastX) / this.camera.zoom; + this.camera.y -= (event.clientY - lastY) / this.camera.zoom; + this._applyCamera(false); + } + lastX = event.clientX; + lastY = event.clientY; + }); + + $(window).on("mouseup", (event) => { + if (!dragging) return; + dragging = false; + this.$viewport.removeClass("dragging"); + if (dragged) return; + const coords = this.tileAtScreen(event.clientX, event.clientY); + if (!coords) return; + if (pressButton === 0) this._onLeftClick(coords); + else if (pressButton === 2) this._onRightClick(coords, event); + }); + + this.$viewport.on("contextmenu", (event) => event.preventDefault()); + this.$viewport.on("wheel", (event) => { + event.preventDefault(); + const factor = event.originalEvent.deltaY < 0 ? 1.1 : 1 / 1.1; + this.camera.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.camera.zoom * factor)); + this._applyCamera(); + }); + }, + + panBy(dx, dy) { + this.camera.x += dx; + this.camera.y += dy; + this._applyCamera(); + }, + + // ------------------------------------------------------------ clicks -- + + _onLeftClick(coords) { + const unitId = this.unitAt(coords); + if (unitId && !this._selectedUnitIds.has(unitId)) { + this.onUnitSelected(unitId); + return; + } + if (unitId) return; + const city = this.cityAt(coords); + if (city) { + this.onCitySelected(city.id); + return; + } + this.clearSelection(); + }, + + // With a selection, right-clicking orders it where it clicked: Ctrl queues a + // scheduled destination, a hostile stack or city is attacked, and anything + // else -- including a friendly stack, which we merge into -- is a move. With + // no selection, right-clicking a friendly stack opens its menu and any other + // tile is inspected. + _onRightClick(coords, event) { + const selected = Array.from(this._selectedUnitIds); + if (selected.length > 0) { + if (event && (event.ctrlKey || event.metaKey)) { + return this.onScheduleOrdered(selected, coords); + } + if (this._isEnemyTarget(coords)) { + return this.onAttackOrdered(selected, coords); + } + return this.onMoveOrdered(selected, coords); + } + const friendly = this.unitIdsAt(coords).filter( + (id) => (this._unitData.get(id) || {}).civ === this.localCiv + ); + if (friendly.length > 0) { + this.onStackMenu(coords, friendly, event ? event.clientX : 0, event ? event.clientY : 0); + return; + } + this.onTileRequested(coords); + }, + + // Ids of every unit of the same civilisation sharing the tile the given unit + // is drawn on, so clicking one icon selects the whole stack. + stackIds(unitId) { + const data = this._unitData.get(unitId); + if (!data) return []; + const motion = this._unitMotion.get(unitId); + const tile = motion + ? this._currentTile(motion) + : { x: data.coords[0], y: data.coords[1] }; + return this.unitIdsAt(tile).filter( + (id) => (this._unitData.get(id) || {}).civ === data.civ + ); + }, + + // Whether the tile holds a hostile unit stack or an enemy city, so a right + // click with our stack selected becomes an attack rather than a march. + _isEnemyTarget(coords) { + const enemy = this.unitIdsAt(coords).some((id) => { + const data = this._unitData.get(id); + return data && data.civ !== this.localCiv; + }); + if (enemy) return true; + const k = key(coords.x, coords.y); + if (!this.visible.has(k)) return false; + const cityCiv = this._cityCiv.get(k); + return cityCiv !== undefined && cityCiv !== this.localCiv; + }, + + // --------------------------------------------------------- selection -- + + setSelectedUnits(ids) { + this._selectedUnitIds = new Set((ids || []).filter((id) => id)); + for (const [unitId, view] of this._unitViews) { + view.classList.toggle("selected", this._selectedUnitIds.has(unitId)); + } + }, + + setSelectedUnit(id) { + this.setSelectedUnits(id ? [id] : []); + }, + + clearSelection() { + this.setSelectedUnits([]); + this.onUnitSelected(0); + }, + + // ------------------------------------------------------- hit-testing -- + + unitIdsAt(coords) { + const local = this._clickLocal(coords); + const result = []; + for (const [id, motion] of this._unitMotion) { + const view = this._unitViews.get(id); + if (view && view.style.display === "none") continue; + const position = motion.path.length >= 2 + ? this._positionAlongPath(motion) + : mapToLocal(motion.coords.x, motion.coords.y); + const offset = motion.offset || { x: 0, y: 0 }; + const wrapped = this._wrappedPosition(position); + if (Math.hypot(wrapped.x + offset.x - local.x, wrapped.y + offset.y - local.y) < HEX_W * 0.5) { + result.push(id); + } + } + return result; + }, + + unitAt(coords) { + return this.unitIdsAt(coords)[0] || 0; + }, + + cityAt(coords) { + for (const [id, view] of this._cityViews) { + if (view.style.display === "none") continue; + const local = this._clickLocal(coords); + const cx = parseFloat(view.style.left); + const cy = parseFloat(view.style.top); + if (Math.hypot(cx - local.x, cy - local.y) < HEX_W * 0.5) return { id }; + } + return null; + }, + + // World position of a clicked tile at the wrapped copy nearest the camera, so + // hit-testing works across the seam. + _clickLocal(coords) { + return this._wrappedPosition(mapToLocal(coords.x, coords.y)); + }, + + tileAtScreen(clientX, clientY) { + const rect = this.$viewport[0].getBoundingClientRect(); + const screenX = clientX - rect.left; + const screenY = clientY - rect.top; + const width = this.$viewport.width(); + const height = this.$viewport.height(); + const worldX = (screenX - (width / 2 - this.camera.x * this.camera.zoom)) / this.camera.zoom; + const worldY = (screenY - (height / 2 - this.camera.y * this.camera.zoom)) / this.camera.zoom; + return this._worldToHex(worldX, worldY); + }, + + _worldToHex(worldX, worldY) { + const column = Math.round(worldX / COL_STEP); + let best = null; + let bestDistance = Infinity; + for (let dx = -1; dx <= 1; dx++) { + const x = column + dx; + const baseY = Math.round(worldY / HEX_H - 0.5 * parity(x)); + for (let dy = -1; dy <= 1; dy++) { + const y = baseY + dy; + const local = mapToLocal(x, y); + const distance = Math.hypot(local.x - worldX, local.y - worldY); + if (distance < bestDistance) { + bestDistance = distance; + best = { x, y }; + } + } + } + return best ? this.topology.wrapCoords(best.x, best.y) : null; + }, +}; diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js new file mode 100644 index 0000000..70f4b68 --- /dev/null +++ b/client/js/map_view/motion.js @@ -0,0 +1,404 @@ +// Dead-reckoning unit motion between snapshots, path drawing and the local +// move prediction that mirrors GameState.find_path. + +import { mapToLocal, key } from "../../../shared/hex.js"; +import { HexPathfinder } from "../../../shared/hex_pathfinder.js"; +import { RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js"; +import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js"; + +// A pathfinding slice runs for this long before handing the thread back, so +// even a cross-map search never blocks a frame for long. +const PATH_SLICE_MS = 5; +const pathNow = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); +const pathDefer = (fn) => setTimeout(fn, 0); + +export const motionMethods = { + // A living hostile civilian standing on the path blocks dead reckoning: the + // animation must stop before it. An enemy military unit is the fight itself + // and combat is fought on a single tile, so the mover charges onto its tile + // and stops there. Friendly units stack and never block. + _truncateAtOccupant(path, segmentHours, data) { + const proto = this.protoUnits[data.proto]; + for (let i = 1; i < path.length; i++) { + const occupant = this._unitsAtCoords(path[i], data.id).find( + (other) => other.civ !== data.civ + ); + if (!occupant) continue; + if (proto && proto.military && this._isMilitaryData(occupant)) { + return { path: path.slice(0, i + 1), segmentHours: segmentHours.slice(0, i) }; + } + return { + path: path.slice(0, i), + segmentHours: segmentHours.slice(0, Math.max(0, i - 1)), + }; + } + return { path, segmentHours }; + }, + + _updateUnitMotion(data) { + const id = data.id; + let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] })); + let segmentHours = data.segmentHours || []; + const clipped = this._truncateAtOccupant(path, segmentHours, data); + path = clipped.path; + segmentHours = clipped.segmentHours; + const existing = this._unitMotion.get(id); + if (path.length < 2 || segmentHours.length === 0) { + if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) { + return; + } + this._unitMotion.set(id, { + path: [], + segmentHours: [], + pathHours: 0, + coords: { x: data.coords[0], y: data.coords[1] }, + predicting: false, + predictAge: 0, + }); + return; + } + const authoritative = hoursAlong(data, segmentHours); + let pathHours = authoritative; + if (existing && samePath(existing.path, path)) { + pathHours = Math.abs(authoritative - existing.pathHours) > RESYNC_HOURS + ? authoritative + : existing.pathHours; + } + this._unitMotion.set(id, { + path, + segmentHours, + pathHours, + coords: { x: data.coords[0], y: data.coords[1] }, + predicting: false, + predictAge: 0, + }); + }, + + animate(dtHours) { + for (const [, motion] of this._unitMotion) { + if (motion.path.length < 2) continue; + motion.pathHours += dtHours; + if (motion.predicting) motion.predictAge += dtHours; + } + this._syncChunks(); + this._applyEntityWrap(); + this._drawPaths(); + }, + + // Repositions every unit and city at the wrapped copy nearest the camera. + _applyEntityWrap() { + for (const [id, motion] of this._unitMotion) { + const view = this._unitViews.get(id); + if (!view) continue; + const position = motion.path.length >= 2 + ? this._positionAlongPath(motion) + : mapToLocal(motion.coords.x, motion.coords.y); + // Choose the wrapped copy from the tile centre, then apply the stack + // offset, so members of one stack never split across the wrap seam. + const wrapped = this._wrappedPosition(position); + const offset = motion.offset || { x: 0, y: 0 }; + view.style.left = `${wrapped.x + offset.x}px`; + view.style.top = `${wrapped.y + offset.y}px`; + } + for (const view of this._cityViews.values()) { + if (view._localX === undefined) continue; + const left = view._localX + this._wrapShift(view._localX); + view.style.left = `${left}px`; + view.style.top = `${view._localY}px`; + if (view._label) { + view._label.style.left = `${left}px`; + view._label.style.top = `${view._localY}px`; + } + } + for (const marker of this._battleViews.values()) { + if (marker._localX === undefined) continue; + marker.style.left = `${marker._localX + this._wrapShift(marker._localX)}px`; + marker.style.top = `${marker._localY}px`; + } + }, + + _positionAlongPath(motion) { + const path = motion.path; + const segmentHours = motion.segmentHours; + let remaining = motion.pathHours; + let index = 0; + while (index < segmentHours.length) { + const step = segmentHours[index]; + if (step > 0 && remaining < step) break; + remaining -= step; + index += 1; + } + if (index >= segmentHours.length) { + index = segmentHours.length - 1; + if (index < 0) { + const local = mapToLocal(path[path.length - 1].x, path[path.length - 1].y); + return local; + } + remaining = segmentHours[index]; + } + const from = path[index]; + const to = path[index + 1] || from; + const a = mapToLocal(from.x, from.y); + const delta = this.topology.pixelDelta(from, to); + const step = segmentHours[index] || 1; + const t = Math.max(0, Math.min(1, remaining / step)); + return { x: a.x + delta.x * t, y: a.y + delta.y * t }; + }, + + _drawPaths() { + const fragment = document.createDocumentFragment(); + for (const [id, motion] of this._unitMotion) { + if (!this._selectedUnitIds.has(id)) continue; + if (motion.path.length < 2) continue; + const index = pathProgress(motion).index; + const base = mapToLocal(motion.path[index].x, motion.path[index].y); + const current = this._positionAlongPath(motion); + let previous = base; + for (let i = index + 1; i < motion.path.length; i++) { + const from = motion.path[i - 1]; + const to = motion.path[i]; + const delta = this.topology.pixelDelta(from, to); + const next = { x: previous.x + delta.x, y: previous.y + delta.y }; + const start = i === index + 1 ? current : previous; + const a = this._wrappedPosition(start); + const b = this._wrappedPosition(next); + fragment.appendChild(makeSegment(a, b, "#ffffff", 4)); + previous = next; + } + } + this.$paths.empty()[0].appendChild(fragment); + }, + + // -------------------------------------------------------- move preview -- + + // Mirrors GameState.find_path closely enough that a click feels instant; the + // next snapshot confirms or corrects the route. `planMove` computes the route + // without touching the view, so callers can inspect it (for example to warn + // before entering foreign territory) before committing to the prediction. + planMove(unitData, goal, speed = null) { + return this._planMove(unitData, goal, speed).then((plan) => (plan ? plan.route : null)); + }, + + predictMove(unitData, goal, speed = null, route = null) { + if (route) { + this.predictRoute(unitData, route, speed); + return Promise.resolve(route); + } + return this._planMove(unitData, goal, speed).then((plan) => { + if (!plan) return null; + this._applyPrediction(unitData, plan); + return plan.route; + }); + }, + + // Starts a prediction from a route that was already planned, skipping the + // search. Used right after a move order so the route is only found once. + predictRoute(unitData, route, speed = null) { + const proto = this.protoUnits[unitData.proto]; + if (!proto || !route || route.length < 2) return null; + const start = this._unitCoords(unitData.id) || + { x: unitData.coords[0], y: unitData.coords[1] }; + const segmentHours = []; + for (let i = 1; i < route.length; i++) { + segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed)); + } + this._applyPrediction(unitData, { route, segmentHours, start }); + return route; + }, + + _applyPrediction(unitData, plan) { + const existing = this._unitMotion.get(unitData.id); + this._unitMotion.set(unitData.id, { + path: plan.route, + segmentHours: plan.segmentHours, + pathHours: 0, + coords: plan.start, + predicting: true, + predictAge: 0, + offset: existing ? existing.offset : { x: 0, y: 0 }, + }); + this.animate(0); + }, + + async _planMove(unitData, goal, speed = null) { + const proto = this.protoUnits[unitData.proto]; + if (!proto || !proto.moveable || !this.topology) return null; + const start = this._unitCoords(unitData.id) || + { x: unitData.coords[0], y: unitData.coords[1] }; + if (start.x === goal.x && start.y === goal.y) return null; + const path = await this._search(unitData, proto, start, goal, speed); + if (!path || path.length < 2) return null; + const segmentHours = []; + for (let i = 1; i < path.length; i++) { + segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed)); + } + return { route: path, segmentHours, start }; + }, + + // The A* this mirrors recomputes the cheapest movement cost on every node, + // and the tile scan behind it dominated the search. Cache it here (the + // TerrainStats memoises it too) and index the units once instead of scanning + // every unit for every tile. + _search(unitData, proto, start, goal, speed) { + const civ = unitData.civ; + const targetCiv = this._targetCivFor(unitData, goal); + const occupancy = this._buildOccupancy(unitData.id); + const enterCache = new Map(); + const costCache = new Map(); + const heuristicCache = new Map(); + const bestSpeed = Math.max(speed || proto.speed, 0.0001); + const fastestHours = this.terrainStats.minMovementCostMultiplier() / (bestSpeed * 1.5); + const iterator = new HexPathfinder().findPathIter( + start, + goal, + (coords) => this.topology.neighbours(coords.x, coords.y), + (coords, isGoal) => { + const k = key(coords.x, coords.y) + (isGoal ? ":goal" : ""); + if (!enterCache.has(k)) { + enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv)); + } + return enterCache.get(k); + }, + (_from, to) => { + const k = key(to.x, to.y); + if (!costCache.has(k)) costCache.set(k, this._stepHours(proto, civ, to, speed)); + return costCache.get(k); + }, + (from, to) => { + const k = key(from.x, from.y); + if (!heuristicCache.has(k)) { + const delta = this.topology.pixelDelta(from, to); + heuristicCache.set(k, (Math.hypot(delta.x, delta.y) / this._maxStepLength) * fastestHours); + } + return heuristicCache.get(k); + } + ); + return this._runSearch(iterator); + }, + + // Drives the search in short time slices across timer turns, so a long route + // never freezes the frame. Starting a new search cancels the previous one. + _runSearch(iterator) { + this.cancelPathfinding(); + const token = { cancelled: false }; + this._pathToken = token; + return new Promise((resolve) => { + const pump = () => { + if (token.cancelled) { + resolve(null); + return; + } + const deadline = pathNow() + PATH_SLICE_MS; + let step = iterator.next(); + while (!step.done && pathNow() < deadline) step = iterator.next(); + if (step.done) { + this._pathToken = null; + resolve(step.value); + } else { + pathDefer(pump); + } + }; + pump(); + }); + }, + + cancelPathfinding() { + if (!this._pathToken) return; + this._pathToken.cancelled = true; + this._pathToken = null; + }, + + _isMilitaryData(data) { + const proto = this.protoUnits[data.proto]; + return !!(proto && proto.military); + }, + + _unitsAtCoords(coords, ignoreId = 0) { + const result = []; + for (const [id, data] of this._unitData) { + if (id === ignoreId) continue; + const motion = this._unitMotion.get(id); + const tile = motion && motion.path.length >= 2 + ? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)] + : { x: data.coords[0], y: data.coords[1] }; + if (tile.x === coords.x && tile.y === coords.y) result.push(data); + } + return result; + }, + + // Same as _unitsAtCoords, but every unit is bucketed by its tile once so a + // search can look up occupancy in constant time instead of scanning them all + // for every tile it considers. + _buildOccupancy(ignoreId) { + const index = new Map(); + for (const [id, data] of this._unitData) { + if (id === ignoreId) continue; + const motion = this._unitMotion.get(id); + const tile = motion && motion.path.length >= 2 + ? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)] + : { x: data.coords[0], y: data.coords[1] }; + const k = key(tile.x, tile.y); + const list = index.get(k); + if (list) list.push(data); + else index.set(k, [data]); + } + return index; + }, + + _unitCoords(id) { + const motion = this._unitMotion.get(id); + if (!motion) return null; + if (motion.path.length >= 2) { + const index = pathProgress(motion).index; + return motion.path[Math.min(index, motion.path.length - 1)]; + } + return motion.coords; + }, + + // The civilisation a route is aimed at: the enemy city or unit on the goal, + // or failing that whoever owns the goal tile. Crossing that civ's land is + // allowed; every other foreign territory is a wall. + _targetCivFor(unitData, goal) { + const k = key(goal.x, goal.y); + const cityCiv = this._cityCiv.get(k); + if (cityCiv !== undefined && cityCiv !== unitData.civ) return cityCiv; + const hostile = this._unitsAtCoords(goal, unitData.id).find((data) => data.civ !== unitData.civ); + if (hostile) return hostile.civ; + return this.territory.has(k) ? this.territory.get(k) : -1; + }, + + // Mirrors GameState._canUnitEnter: friendly units share a tile, while a + // hostile unit or city is only a valid destination (`isGoal`). Foreign + // territory is a wall too, except for the civilisation being targeted, so a + // route never cuts through a third party and provokes a war. + _canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1) { + const k = key(coords.x, coords.y); + if (!this.explored.has(k)) return false; + const tile = this.tiles[k]; + if (!tile) return false; + if (!proto.traversableTerrains.includes(tile.terrainClass)) return false; + const owner = this.territory.has(k) ? this.territory.get(k) : -1; + if (!isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false; + const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId); + const hostile = occupants.filter((data) => data.civ !== civ); + if (hostile.length > 0) { + if (!isGoal) return false; + if (proto.military !== true) return false; + const anyMilitary = hostile.some((data) => this._isMilitaryData(data)); + if (!anyMilitary) return false; + } + const cityCiv = this._cityCiv.get(k); + if (cityCiv !== undefined && cityCiv !== civ) { + if (!isGoal) return false; + if (proto.military !== true) return false; + } + return true; + }, + + _stepHours(proto, civ, coords, speed = null) { + const k = key(coords.x, coords.y); + let effective = Math.max(speed || proto.speed, 0.0001); + if (this.territory.get(k) === civ) effective *= 1.5; + return this.terrainStats.movementCostMultiplier(coords) / effective; + }, +}; diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js new file mode 100644 index 0000000..b4a334c --- /dev/null +++ b/client/js/map_view/terrain.js @@ -0,0 +1,486 @@ +// Terrain construction, world-space bounds, the coarse overview used at low +// zoom, and the chunked world-space layers. +// +// The detailed map is not one element per tile of the whole world. It is built +// from square CHUNK_SIZE x CHUNK_SIZE tile blocks that are materialised only +// while they intersect the viewport (plus a margin) and recycled the moment +// they leave it. Panning therefore costs the same on a 200x200 map as on a +// 20x20 one. The chunk wrappers live in the terrain, border and fog layers so +// the layer z-order is unchanged; tiles inside a wrapper are positioned +// relative to the chunk, and the wrapper itself is moved by `transform` and +// wrapped by a whole map period. + +import { MapTopology, mapToLocal, key, parseKey, HEX_W, HEX_H, COL_STEP } from "../../../shared/hex.js"; +import { MapGenerator } from "../../../shared/map_generator.js"; +import { TerrainStats } from "../../../shared/terrain_stats.js"; +import { MAP_CONFIG } from "../../../shared/data.js"; +import { OVERVIEW_BLOCK, OVERVIEW_FOG, CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT } from "./constants.js"; +import { overviewColour, blendColours } from "./utils.js"; + +export const terrainMethods = { + ensureTerrain(seed) { + if (seed === this.seed) return; + this.seed = seed; + this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical"); + const generator = new MapGenerator(MAP_CONFIG, this.topology); + generator.generate(seed); + this.tiles = generator.tiles; + this.terrainStats = new TerrainStats(this.tiles); + this._maxStepLength = this._computeMaxStepLength(); + this._period = this.topology.periodPixels(); + this._computeWorldBounds(); + + this._chunkSize = CHUNK_SIZE; + this._chunkCols = Math.ceil(this.topology.width / CHUNK_SIZE); + this._chunkRows = Math.ceil(this.topology.height / CHUNK_SIZE); + this._chunkOriginCache = new Map(); + this._chunkCache = new Map(); + this._chunkCacheLimit = CHUNK_CACHE_LIMIT; + this._destroyAllChunks(); + this.$terrain.empty(); + this.$borders.empty(); + this.$fog.empty(); + this.$terrain.addClass("detail-layer"); + this.$borders.addClass("detail-layer"); + this.$fog.addClass("detail-layer"); + + this._territorySignature = ""; + this._exploredCount = -1; + this._visibleSignature = null; + this._chunkDirty = true; + this._chunkView = { x: null, y: null, zoom: null }; + + this.$entities.empty(); + this.$labels.empty(); + this._unitViews.clear(); + this._unitMotion.clear(); + this._unitData.clear(); + this._cityViews.clear(); + this._cityCiv.clear(); + for (const view of this._battleViews.values()) view.remove(); + this._battleViews.clear(); + this._battles.clear(); + this._centered = false; + this._chunkDirty = true; + // The overview is only built when the player is (or becomes) zoomed out; + // keeping it out of startup leaves the initial view to the visible chunks. + if (this.lod) this._drawOverview(true); + this._syncChunks(); + }, + + // Pixel bounds of the whole world, used to lay out the overview and clamp the + // camera when the simplified map is on screen. + _computeWorldBounds() { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const k in this.tiles) { + const coords = parseKey(k); + const local = mapToLocal(coords.x, coords.y); + minX = Math.min(minX, local.x - HEX_W / 2); + maxX = Math.max(maxX, local.x + HEX_W / 2); + minY = Math.min(minY, local.y - HEX_H / 2); + maxY = Math.max(maxY, local.y + HEX_H / 2); + } + if (!isFinite(minX)) { + minX = 0; + maxX = 0; + minY = 0; + maxY = 0; + } + this._worldMinX = minX; + this._worldMinY = minY; + this._worldWidth = maxX - minX; + this._worldHeight = maxY - minY; + }, + + getTile(coords) { + return this.tiles[key(coords.x, coords.y)] || null; + }, + + // ------------------------------------------------------------- chunks -- + + // Geometry of the chunk at column/row (cx, cy): the inclusive tile range it + // covers, plus its unwrapped pixel origin and size. Cached because culling + // walks every chunk cell on each camera move. + _chunkOrigin(cx, cy) { + const cacheKey = cx + "," + cy; + const cached = this._chunkOriginCache.get(cacheKey); + if (cached) return cached; + const size = this._chunkSize; + const t = this.topology; + const x0 = t.originX + cx * size; + const y0 = t.originY + cy * size; + const x1 = Math.min(x0 + size - 1, t.originX + t.width - 1); + const y1 = Math.min(y0 + size - 1, t.originY + t.height - 1); + const center = mapToLocal(x0, y0); + const origin = { + x0, + y0, + x1, + y1, + x: center.x, + y: center.y, + px: (x1 - x0) * COL_STEP, + py: (y1 - y0) * HEX_H, + }; + this._chunkOriginCache.set(cacheKey, origin); + return origin; + }, + + // Creates the empty wrappers that hold one chunk in each detailed layer. + _newChunk() { + const terrain = this._acquireWrapper(); + const fog = this._acquireWrapper(); + const borders = this._acquireWrapper(); + this.$terrain[0].appendChild(terrain); + this.$fog[0].appendChild(fog); + this.$borders[0].appendChild(borders); + return { terrain, fog, borders }; + }, + + _createChunk(cx, cy) { + const chunkKey = cx + "," + cy; + const cached = this._chunkCache.get(chunkKey); + if (cached) { + // Reattach the already-built chunk; only the layers that changed while it + // was parked need rebuilding. + this._chunkCache.delete(chunkKey); + this.$terrain[0].appendChild(cached.terrainEl); + this.$fog[0].appendChild(cached.fogEl); + this.$borders[0].appendChild(cached.borderEl); + this._chunks.set(chunkKey, cached); + if (cached.fogDirty) this._buildFog(cached); + if (cached.borderDirty) this._buildBorders(cached); + this._setChunkPosition(cached, this._wrapShift(cached.x)); + return cached; + } + + const origin = this._chunkOrigin(cx, cy); + const rec = { + cx, + cy, + x0: origin.x0, + y0: origin.y0, + x1: origin.x1, + y1: origin.y1, + x: origin.x, + y: origin.y, + px: origin.px, + py: origin.py, + shift: null, + tileKeys: [], + fogDirty: false, + borderDirty: false, + terrainEl: null, + fogEl: null, + borderEl: null, + }; + const wrappers = this._newChunk(); + rec.terrainEl = wrappers.terrain; + rec.fogEl = wrappers.fog; + rec.borderEl = wrappers.borders; + this._chunks.set(chunkKey, rec); + this._buildTerrain(rec); + this._buildBorders(rec); + this._buildFog(rec); + this._setChunkPosition(rec, this._wrapShift(rec.x)); + return rec; + }, + + _setChunkPosition(rec, shift) { + rec.shift = shift; + const transform = `translateX(${rec.x + shift}px) translateY(${rec.y}px)`; + rec.terrainEl.style.transform = transform; + rec.fogEl.style.transform = transform; + rec.borderEl.style.transform = transform; + }, + + // Parks a built chunk off-screen so panning back over it is a reattach rather + // than a rebuild. Old chunks are dropped (and their tiles pooled) once the + // cache is full. + _detachChunk(chunkKey, rec) { + rec.terrainEl.parentNode && rec.terrainEl.parentNode.removeChild(rec.terrainEl); + rec.fogEl.parentNode && rec.fogEl.parentNode.removeChild(rec.fogEl); + rec.borderEl.parentNode && rec.borderEl.parentNode.removeChild(rec.borderEl); + this._chunks.delete(chunkKey); + this._chunkCache.set(chunkKey, rec); + while (this._chunkCache.size > this._chunkCacheLimit) { + const [oldKey, old] = this._chunkCache.entries().next().value; + this._chunkCache.delete(oldKey); + this._destroyChunk(old); + } + }, + + _destroyChunk(rec) { + this._recycleLayer(rec.terrainEl, this._hexPool); + this._recycleLayer(rec.fogEl, this._hexPool); + this._recycleLayer(rec.borderEl, this._borderPool); + this._releaseWrapper(rec.terrainEl); + this._releaseWrapper(rec.fogEl); + this._releaseWrapper(rec.borderEl); + }, + + // Parks every materialised chunk (used when the overview takes over) so + // zooming back in is cheap. + _clearChunks() { + if (!this._chunks || this._chunks.size === 0) return; + for (const [chunkKey, rec] of Array.from(this._chunks)) this._detachChunk(chunkKey, rec); + }, + + _destroyAllChunks() { + if (this._chunks) { + for (const rec of this._chunks.values()) this._destroyChunk(rec); + this._chunks.clear(); + } + if (this._chunkCache) { + for (const rec of this._chunkCache.values()) this._destroyChunk(rec); + this._chunkCache.clear(); + } + }, + + // Materialises every chunk that overlaps the viewport (expanded by + // CHUNK_MARGIN blocks) and releases the rest. Cheap enough to run each frame: + // it walks the chunk grid, not the tiles. + _syncChunks() { + if (!this.topology || !this._chunks || this.lod) return; + const width = this.$viewport.width(); + const height = this.$viewport.height(); + if (!width || !height) return; + const zoom = this.camera.zoom || 1; + const cameraX = this.camera.x; + const cameraY = this.camera.y; + if ( + !this._chunkDirty && + cameraX === this._chunkView.x && + cameraY === this._chunkView.y && + zoom === this._chunkView.zoom + ) { + return; + } + this._chunkDirty = false; + this._chunkView = { x: cameraX, y: cameraY, zoom }; + + const halfW = width / (2 * zoom); + const halfH = height / (2 * zoom); + const slackX = CHUNK_MARGIN * this._chunkSize * COL_STEP; + const slackY = CHUNK_MARGIN * this._chunkSize * HEX_H; + const left = cameraX - halfW - slackX; + const right = cameraX + halfW + slackX; + const top = cameraY - halfH - slackY; + const bottom = cameraY + halfH + slackY; + + const wanted = new Set(); + for (let cy = 0; cy < this._chunkRows; cy++) { + for (let cx = 0; cx < this._chunkCols; cx++) { + const origin = this._chunkOrigin(cx, cy); + const shift = this._wrapShift(origin.x); + const minX = origin.x + shift - HEX_W; + const maxX = origin.x + shift + origin.px + HEX_W; + const minY = origin.y - HEX_H; + const maxY = origin.y + origin.py + HEX_H; + if (minX > right || maxX < left || minY > bottom || maxY < top) continue; + const chunkKey = cx + "," + cy; + wanted.add(chunkKey); + let rec = this._chunks.get(chunkKey); + if (!rec) { + rec = this._createChunk(cx, cy); + } else if (rec.shift !== shift) { + this._setChunkPosition(rec, shift); + } + } + } + for (const [chunkKey, rec] of Array.from(this._chunks)) { + if (wanted.has(chunkKey)) continue; + this._detachChunk(chunkKey, rec); + } + }, + + // -------------------------------------------------------- chunk content -- + + _buildTerrain(rec) { + this._recycleLayer(rec.terrainEl, this._hexPool); + const fragment = document.createDocumentFragment(); + rec.tileKeys = []; + for (let y = rec.y0; y <= rec.y1; y++) { + for (let x = rec.x0; x <= rec.x1; x++) { + const k = key(x, y); + const tile = this.tiles[k]; + if (!tile) continue; + rec.tileKeys.push(k); + const local = mapToLocal(x, y); + const div = this._acquireHex(this._hexPool); + div.className = "hex terrain detail-layer"; + div.style.left = `${local.x - rec.x - HEX_W / 2}px`; + div.style.top = `${local.y - rec.y - HEX_H / 2}px`; + div.style.backgroundPosition = `${-tile.col * HEX_W}px 0`; + fragment.appendChild(div); + } + } + rec.terrainEl.appendChild(fragment); + }, + + // Fog is rebuilt per materialised chunk, so a change in visibility only costs + // the few hundred tiles actually on screen instead of the whole map. + _buildFog(rec) { + rec.fogDirty = false; + this._recycleLayer(rec.fogEl, this._hexPool); + const fragment = document.createDocumentFragment(); + for (let y = rec.y0; y <= rec.y1; y++) { + for (let x = rec.x0; x <= rec.x1; x++) { + const k = key(x, y); + if (!this.tiles[k] || this.visible.has(k)) continue; + const local = mapToLocal(x, y); + const div = this._acquireHex(this._hexPool); + div.className = "hex fog detail-layer " + + (this.explored.has(k) ? "fog-explored" : "fog-unexplored"); + div.style.left = `${local.x - rec.x - HEX_W / 2}px`; + div.style.top = `${local.y - rec.y - HEX_H / 2}px`; + fragment.appendChild(div); + } + } + rec.fogEl.appendChild(fragment); + }, + + _refreshFog() { + if (!this._chunks) return; + for (const rec of this._chunks.values()) this._buildFog(rec); + if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true; + }, + + // ------------------------------------------------------------ pooling -- + + _acquireWrapper() { + const el = this._wrapperPool.pop(); + if (el) { + el.className = "chunk"; + el.style.cssText = ""; + return el; + } + return document.createElement("div"); + }, + + _releaseWrapper(el) { + el.className = ""; + el.style.cssText = ""; + el.parentNode && el.parentNode.removeChild(el); + this._wrapperPool.push(el); + }, + + _acquireHex(pool) { + return pool.pop() || document.createElement("div"); + }, + + _recycleLayer(wrapper, pool) { + let child = wrapper.firstChild; + while (child) { + const next = child.nextSibling; + wrapper.removeChild(child); + child.className = ""; + child.style.cssText = ""; + pool.push(child); + child = next; + } + }, + + // --------------------------------------------------------------- wrap -- + + // Pixel offset (a whole number of map periods) that moves an element sitting + // at `centerX` to the copy closest to the camera. Choosing the half-open band + // [camera - P/2, camera + P/2) keeps neighbouring tiles contiguous, so the + // world tiles seamlessly in both directions. + _wrapShift(centerX) { + if (!this._period || this.lod) return 0; + const period = this._period; + return Math.ceil((this.camera.x - period / 2 - centerX) / period) * period; + }, + + _wrappedPosition(point) { + return { x: point.x + this._wrapShift(point.x), y: point.y }; + }, + + // ---------------------------------------------------------------- LOD -- + + // Builds the simplified map as a coarse grid: one flat block per + // OVERVIEW_BLOCK x OVERVIEW_BLOCK tiles. At low zoom these few hundred + // elements replace the detailed chunks, so seeing the entire map stays cheap. + _drawOverview(force = false) { + if (!this.$overview || !this.$overview.length || !this.topology) return; + // While the detailed view is on screen the overview is hidden; keep the + // redraw for the moment the player zooms out rather than paying for it on + // every hourly snapshot. + if (!force && !this.lod) return; + + const step = OVERVIEW_BLOCK; + const startX = this.topology.originX; + const startY = this.topology.originY; + const endX = startX + this.topology.width; + const endY = startY + this.topology.height; + const fragment = document.createDocumentFragment(); + for (let by = startY; by < endY; by += step) { + for (let bx = startX; bx < endX; bx += step) { + const block = this._overviewBlock(bx, by, step); + if (block) fragment.appendChild(block); + } + } + this.$overview.empty()[0].appendChild(fragment); + }, + + _overviewBlock(bx, by, step) { + const endX = Math.min(bx + step, this.topology.originX + this.topology.width); + const endY = Math.min(by + step, this.topology.originY + this.topology.height); + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + let representative = null; + let land = null; + let owner = -1; + let visible = false; + let explored = false; + for (let y = by; y < endY; y++) { + for (let x = bx; x < endX; x++) { + const k = key(x, y); + const tile = this.tiles[k]; + if (!tile) continue; + if (!representative) representative = tile; + if (tile.terrainClass !== "Sea" && !land) land = tile; + const local = mapToLocal(x, y); + minX = Math.min(minX, local.x - HEX_W / 2); + maxX = Math.max(maxX, local.x + HEX_W / 2); + minY = Math.min(minY, local.y - HEX_H / 2); + maxY = Math.max(maxY, local.y + HEX_H / 2); + const tileOwner = this.territory.get(k); + if (tileOwner !== undefined && tileOwner >= 0 && this.civilisations[tileOwner]) { + owner = tileOwner; + } + if (this.visible.has(k)) visible = true; + if (this.explored.has(k)) explored = true; + } + } + if (!representative) return null; + + let colour = overviewColour(land || representative); + if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45); + if (!visible) colour = blendColours(colour, OVERVIEW_FOG, explored ? 0.62 : 1); + + const div = document.createElement("div"); + div.className = "overview-block"; + div.style.left = `${minX}px`; + div.style.top = `${minY}px`; + div.style.width = `${maxX - minX}px`; + div.style.height = `${maxY - minY}px`; + div.style.background = colour; + return div; + }, + + _computeMaxStepLength() { + let longest = 0; + for (const neighbour of this.topology.neighbours(0, 0)) { + const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour); + longest = Math.max(longest, Math.hypot(delta.x, delta.y)); + } + return longest > 0 ? longest : 1.0; + }, +}; diff --git a/client/js/map_view/utils.js b/client/js/map_view/utils.js new file mode 100644 index 0000000..10d5022 --- /dev/null +++ b/client/js/map_view/utils.js @@ -0,0 +1,151 @@ +// Pure helpers for the map view: geometry, path timing and colour blending. +// Nothing here touches the DOM or the MapView instance, so it is all trivially +// testable. + +import { HEX_W, HEX_H } from "../../../shared/hex.js"; +import { OVERVIEW_COLOURS, OVERVIEW_SEA } from "./constants.js"; + +export function parity(x) { + return x & 1; +} + +// Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip +// rebuilding a layer when the snapshot did not actually change it. +export function hashEntries(entries) { + let hash = 2166136261; + for (const entry of entries) { + hash = Math.imul(hash ^ (entry[0] + 4096), 16777619); + hash = Math.imul(hash ^ (entry[1] + 4096), 16777619); + hash = Math.imul(hash ^ (entry.length > 2 ? entry[2] + 8 : 0), 16777619); + } + return `${entries.length}:${(hash >>> 0).toString(36)}`; +} + +// How far along its path a unit is, in segment-hours, from a snapshot. +export function hoursAlong(data, segmentHours) { + const index = Math.min(data.pathIndex || 0, segmentHours.length); + let total = 0; + for (let i = 0; i < index; i++) total += segmentHours[i]; + return total + (data.progressHours || 0); +} + +export function samePath(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].x !== b[i].x || a[i].y !== b[i].y) return false; + } + return true; +} + +// The segment index a motion is currently on and how far into it it is. +export function pathProgress(motion) { + let remaining = motion.pathHours; + let index = 0; + while (index < motion.segmentHours.length) { + const step = motion.segmentHours[index]; + if (step <= 0) { + index += 1; + continue; + } + if (remaining < step) break; + remaining -= step; + index += 1; + } + return { index, remaining }; +} + +export function nearestCorners(center, target) { + const half = { x: HEX_W / 2, y: HEX_H / 2 }; + const corners = [ + { x: half.x, y: 0 }, + { x: half.x * 0.5, y: -half.y }, + { x: -half.x * 0.5, y: -half.y }, + { x: -half.x, y: 0 }, + { x: -half.x * 0.5, y: half.y }, + { x: half.x * 0.5, y: half.y }, + ]; + let first = 0; + let second = 1; + let firstDistance = Infinity; + let secondDistance = Infinity; + for (let i = 0; i < corners.length; i++) { + const point = { x: center.x + corners[i].x, y: center.y + corners[i].y }; + const distance = (point.x - target.x) ** 2 + (point.y - target.y) ** 2; + if (distance < firstDistance) { + secondDistance = firstDistance; + second = first; + firstDistance = distance; + first = i; + } else if (distance < secondDistance) { + secondDistance = distance; + second = i; + } + } + return [ + { x: center.x + corners[first].x, y: center.y + corners[first].y }, + { x: center.x + corners[second].x, y: center.y + corners[second].y }, + ]; +} + +// Signed distance to nudge an edge segment along its normal so it moves towards +// `center` (the owning tile). Used to keep neighbours' borders side by side +// instead of overlapping on the shared edge. +export function inwardOffset(a, b, center, distance) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy) || 1; + const nx = -dy / length; + const ny = dx / length; + const midX = (a.x + b.x) / 2; + const midY = (a.y + b.y) / 2; + const dot = (center.x - midX) * nx + (center.y - midY) * ny; + return dot >= 0 ? distance : -distance; +} + +export function makeSegment(a, b, colour, width, offset = 0) { + const div = document.createElement("div"); + div.className = "border-seg"; + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy); + const angle = Math.atan2(dy, dx); + div.style.left = `${a.x}px`; + div.style.top = `${a.y - width / 2}px`; + div.style.width = `${length}px`; + div.style.height = `${width}px`; + div.style.background = colour; + div.style.transform = offset === 0 + ? `rotate(${angle}rad)` + : `rotate(${angle}rad) translateY(${offset}px)`; + div.style.borderRadius = `${width / 2}px`; + return div; +} + +export function overviewColour(tile) { + if (tile.terrainType && OVERVIEW_COLOURS[tile.terrainType]) { + return OVERVIEW_COLOURS[tile.terrainType]; + } + return tile.terrainClass === "Sea" ? OVERVIEW_SEA : OVERVIEW_COLOURS.Land; +} + +// Mixes two #rrggbb colours, `t` being how much of `b` to use. +export function blendColours(a, b, t) { + const ca = parseColour(a); + const cb = parseColour(b); + const mix = (i) => Math.round(ca[i] + (cb[i] - ca[i]) * t); + return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`; +} + +function parseColour(value) { + const hex = value.charAt(0) === "#" ? value.slice(1) : value; + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; +} + +export function clamp(value, min, max) { + if (min > max) return (min + max) / 2; + return Math.max(min, Math.min(max, value)); +} diff --git a/client/js/modals/buildings.js b/client/js/modals/buildings.js new file mode 100644 index 0000000..e8b1296 --- /dev/null +++ b/client/js/modals/buildings.js @@ -0,0 +1,92 @@ +// The city buildings modal: upgrade and demolish the buildings of one city. + +import { groupDigits } from "../../../shared/text_format.js"; +import { + buildingBuildCost, + buildingRefund, + buildingUpkeep, +} from "../../../shared/rules.js"; +import { effectsText } from "./format.js"; + +export class BuildingsModal { + constructor() { + this.$modal = $("#modal-buildings"); + this.$title = $("#buildings-title"); + this.$budget = $("#buildings-budget"); + this.$list = $("#buildings-list"); + this.cityId = 0; + this.cityName = ""; + this.buildings = []; + this.rows = []; + this.onBuild = () => {}; + this.onDemolish = () => {}; + this.$modal.find(".modal-close").on("click", () => this.close()); + } + + isShowing(cityId) { + return !this.$modal.hasClass("hidden") && this.cityId === cityId; + } + + show(city, gdp, budget, buildings, levels) { + this.cityId = city.id; + this.cityName = city.name; + this.buildings = buildings; + this.$title.text(`${city.name} — Buildings`); + this.$list.empty(); + this.rows = buildings.map((proto, index) => this._makeRow(proto, index)); + this.refresh(gdp, budget, levels); + this.$modal.removeClass("hidden"); + } + + _makeRow(proto, index) { + const $card = $("
"); + const $info = $("
"); + const $name = $("
"); + const $desc = $("
").text(proto.description); + const $effects = $("
"); + const $cost = $("
"); + $info.append($name, $desc, $effects, $cost); + const $actions = $("
"); + const $upgrade = $(""); + const $downgrade = $(""); + $upgrade.on("click", () => this.onBuild(this.cityId, index)); + $downgrade.on("click", () => this.onDemolish(this.cityId, index)); + $actions.append($upgrade, $downgrade); + $card.append($info, $actions); + this.$list.append($card); + return { proto, index, $name, $effects, $cost, $upgrade, $downgrade }; + } + + refresh(gdp, budget, levels) { + this.$budget.text(`Budget: €${groupDigits(Math.round(budget))}`); + for (const row of this.rows) { + const level = levels[row.index] || 0; + row.$name.text(`${row.proto.name} — Level ${level} / ${row.proto.maxLevel}`); + row.$effects.text(effectsText(row.proto.effects, level > 0 ? level : 1)); + const parts = []; + if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, gdp, level)))}/h`); + if (level < row.proto.maxLevel) { + parts.push(`Build cost €${groupDigits(Math.round(buildingBuildCost(row.proto, gdp, level)))}`); + } + row.$cost.text(parts.join(" · ")); + if (level < row.proto.maxLevel) { + const cost = buildingBuildCost(row.proto, gdp, level); + row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost); + } else { + row.$upgrade.text("Max level").prop("disabled", true); + } + if (level > 0) { + row.$downgrade + .text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, gdp, level)))}`) + .prop("disabled", false); + } else { + row.$downgrade.text("Not built").prop("disabled", true); + } + } + } + + close() { + this.$modal.addClass("hidden"); + this.cityId = 0; + } +} diff --git a/client/js/modals/confirm.js b/client/js/modals/confirm.js new file mode 100644 index 0000000..53c7e3e --- /dev/null +++ b/client/js/modals/confirm.js @@ -0,0 +1,34 @@ +// A generic yes/no prompt. `show` takes the callback it should run when the +// player confirms; cancelling (or closing) runs nothing. + +export class ConfirmModal { + constructor() { + this.$modal = $("#modal-confirm"); + this.$title = $("#confirm-title"); + this.$message = $("#confirm-message"); + this._onConfirm = null; + this.$modal.find(".modal-close").on("click", () => this.close()); + this.$modal.find("#confirm-cancel").on("click", () => this.close()); + this.$modal.find("#confirm-ok").on("click", () => { + const callback = this._onConfirm; + this.close(); + if (callback) callback(); + }); + } + + isShowing() { + return !this.$modal.hasClass("hidden"); + } + + show(title, message, onConfirm) { + this.$title.text(title); + this.$message.text(message); + this._onConfirm = onConfirm || null; + this.$modal.removeClass("hidden"); + } + + close() { + this.$modal.addClass("hidden"); + this._onConfirm = null; + } +} diff --git a/client/js/modals/format.js b/client/js/modals/format.js new file mode 100644 index 0000000..113e2b8 --- /dev/null +++ b/client/js/modals/format.js @@ -0,0 +1,51 @@ +// Shared formatting helpers for the modal panels. + +import { groupDigits } from "../../../shared/text_format.js"; + +export function formatEffect(effect, level) { + const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent); + switch (effect.stat) { + case "gdp": + return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% GDP`; + case "budget_income": + return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% budget income`; + case "research": + return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`; + case "culture": + return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`; + default: + return `${effect.stat} ${value.toFixed(2)}`; + } +} + +export function effectsText(effects, level) { + const parts = effects.map((e) => formatEffect(e, level)); + return parts.length > 0 ? parts.join(" · ") : "No effect"; +} + +export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" }; + +export function signedMoney(value) { + return `${value < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(value)))}/h`; +} + +export function percent(value) { + return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`; +} + +// Renders one news entry as text, resolving civ indices to names and demonyms. +export function describeNews(entry, civs) { + const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`); + const people = (index) => { + const civ = civs[index]; + return civ && civ.demonym ? `The ${civ.demonym}` : name(index); + }; + switch (entry.type) { + case "war": + return `${people(entry.actor)} declared war on ${name(entry.target)}`; + case "capture": + return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`; + default: + return entry.type; + } +} diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js new file mode 100644 index 0000000..140e260 --- /dev/null +++ b/client/js/modals/nation.js @@ -0,0 +1,388 @@ +// The nation panel: government, technology, budget, diplomacy and conflicts. + +import { compact, groupDigits } from "../../../shared/text_format.js"; +import { + governmentChangeCost, + prerequisitesMet, +} from "../../../shared/rules.js"; +import { effectsText, signedMoney, percent, SOURCE_LABELS } from "./format.js"; + +export class NationModal { + constructor() { + this.$modal = $("#modal-nation"); + this.$governmentSub = $("#government-sub"); + this.$governmentList = $("#government-list"); + this.$technologySub = $("#technology-sub"); + this.$technologyList = $("#technology-list"); + this.$budgetSub = $("#budget-sub"); + this.$budgetList = $("#budget-list"); + this.$diplomacySub = $("#diplomacy-sub"); + this.$diplomacyList = $("#diplomacy-list"); + this.$conflictsSub = $("#conflicts-sub"); + this.$conflictsList = $("#conflicts-list"); + this.governments = []; + this.technologies = []; + this.currentGovernment = 0; + this.researched = new Set(); + this.governmentRows = []; + this.technologyRows = []; + this.diplomacy = null; + this.diplomacySort = { key: "name", dir: 1 }; + this.conflicts = []; + this.budgetRows = { + income: this._makeBudgetRow("Tax income", "State revenue drawn from the nation's GDP."), + building: this._makeBudgetRow("Building upkeep", "Upkeep of every building across your cities."), + units: this._makeBudgetRow("Military upkeep", "Upkeep of all your units."), + net: this._makeBudgetRow("Net balance", "Projected change to the treasury each hour."), + }; + this.onGovernment = () => {}; + this.onTechnology = () => {}; + this.onDeclareWar = () => {}; + this.$modal.find(".modal-close").on("click", () => this.close()); + this.$modal.find(".tab").on("click", (event) => { + const tab = $(event.currentTarget).data("tab"); + this.$modal.find(".tab").removeClass("active"); + $(event.currentTarget).addClass("active"); + this.$modal.find(".tab-page").removeClass("active"); + $(`#tab-${tab}`).addClass("active"); + }); + } + + isShowing() { + return !this.$modal.hasClass("hidden"); + } + + show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null, conflicts = []) { + this.governments = governments; + this.technologies = technologies; + this.diplomacy = diplomacy; + this.conflicts = conflicts || []; + this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government")); + this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology")); + this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy, conflicts); + this.$modal.removeClass("hidden"); + } + + _makeBudgetRow(name, description) { + const $card = $("
"); + const $info = $("
"); + const $name = $("
").text(name); + const $desc = $("
").text(description); + const $details = $("
"); + const $value = $("
"); + $info.append($name, $desc, $details); + $card.append($info, $value); + this.$budgetList.append($card); + return { $value, $details }; + } + + _renderBudgetRow(row, value, lines) { + row.$value + .text(signedMoney(value)) + .toggleClass("positive", value >= 0) + .toggleClass("negative", value < 0); + row.$details.empty(); + for (const line of lines) { + row.$details.append($("
").text(line)); + } + } + + _sourceLines(sources, format, empty) { + if (!sources || sources.length === 0) return [empty]; + return sources.map((source) => format(source)); + } + + _makeRow($list, proto, index, kind) { + const $card = $("
"); + const $info = $("
"); + const $name = $("
"); + const $desc = $("
").text(proto.description || ""); + const $effects = $("
"); + const $cost = $("
"); + $info.append($name, $desc, $effects, $cost); + const $actions = $("
"); + const $action = $(""); + $action.on("click", () => { + if (kind === "government") this.onGovernment(index); + else this.onTechnology(index); + }); + $actions.append($action); + $card.append($info, $actions); + $list.append($card); + return { proto, index, kind, $name, $effects, $cost, $action }; + } + + refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null, conflicts = null) { + this.currentGovernment = currentGovernment; + this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); + if (diplomacy !== null) this.diplomacy = diplomacy; + if (conflicts !== null) this.conflicts = conflicts || []; + this.$governmentSub.text( + `Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}` + ); + this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`); + + const breakdown = finances || {}; + const income = breakdown.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [] }; + const building = breakdown.buildingUpkeep || { total: 0, sources: [] }; + const units = breakdown.unitUpkeep || { total: 0, sources: [] }; + const net = breakdown.net || 0; + + this.$budgetSub.text( + `Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}` + ); + + this._renderBudgetRow(this.budgetRows.income, income.total, [ + `Tax rate ${(income.rate * 100).toFixed(2)}% of GDP — base €${groupDigits(Math.round(income.base))}/h`, + ...this._sourceLines( + income.sources, + (s) => `${s.label} (${SOURCE_LABELS[s.kind] || s.kind}) ${percent(s.effect)} → ${signedMoney(s.amount)}`, + "No bonuses" + ), + ]); + this._renderBudgetRow( + this.budgetRows.building, + -building.total, + this._sourceLines( + building.sources, + (s) => `${s.label} — €${groupDigits(Math.round(s.amount))}/h`, + "No buildings" + ) + ); + this._renderBudgetRow( + this.budgetRows.units, + -units.total, + this._sourceLines( + units.sources, + (s) => `${s.label} ×${s.count} — €${groupDigits(Math.round(s.amount))}/h`, + "No units" + ) + ); + this._renderBudgetRow(this.budgetRows.net, net, [ + `Income ${signedMoney(income.total)}`, + `Expenses −€${groupDigits(Math.round(building.total + units.total))}/h`, + ]); + + for (const row of this.governmentRows) { + const proto = row.proto; + row.$name.text(proto.name); + row.$effects.text(effectsText(proto.effects, 1)); + if (row.index === currentGovernment) { + row.$cost.text("Currently in power"); + row.$action.text("Current government").prop("disabled", true); + } else { + const cost = governmentChangeCost(proto, gdp); + row.$cost.text(`Adoption cost €${groupDigits(Math.round(cost))}`); + row.$action.text(`Adopt €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost); + } + } + + for (const row of this.technologyRows) { + const proto = row.proto; + row.$name.text(proto.name); + row.$effects.text(effectsText(proto.effects, 1)); + if (this.researched.has(row.index)) { + row.$cost.text("Researched"); + row.$action.text("Researched").prop("disabled", true); + } else if (!prerequisitesMet(proto, this.researched, this.technologies)) { + const names = proto.prerequisites + .map((id) => { + const found = this.technologies.find((t) => t.id === id); + return found ? found.name : id; + }) + .join(", "); + row.$cost.text(`Requires: ${names}`); + row.$action.text("Locked").prop("disabled", true); + } else { + row.$cost.text(`Cost ${groupDigits(Math.round(proto.cost))} research points`); + row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost); + } + } + + this._renderDiplomacy(); + this._renderConflicts(); + } + + // Lists every other nation's economy in a sortable table, with its relation + // to the player and a button to declare war. `diplomacy` is + // { civs, localCiv, relations, stats }, where relations is the snapshot's + // [a, b, status] matrix and stats its per-civ { population, gdp, gdpPerCapita }. + // The table starts sorted alphabetically by nation and every header toggles + // the ordering. + _renderDiplomacy() { + if (!this.$diplomacyList || !this.$diplomacyList.length) return; + this.$diplomacyList.empty(); + const info = this.diplomacy; + if (!info) return; + const civs = info.civs || []; + const localCiv = info.localCiv; + const wars = new Set(); + for (const entry of info.relations || []) { + if (entry[2] !== "war") continue; + if (entry[0] === localCiv) wars.add(entry[1]); + else if (entry[1] === localCiv) wars.add(entry[0]); + } + if (this.$diplomacySub && this.$diplomacySub.length) { + const atWar = wars.size; + this.$diplomacySub.text( + atWar === 0 + ? "Your nation is at peace with every other nation." + : `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.` + ); + } + + const rows = []; + for (let index = 0; index < civs.length; index++) { + const stats = (info.stats || [])[index] || {}; + const self = index === localCiv; + rows.push({ + index, + name: civs[index].name, + gdp: stats.gdp || 0, + gdpPerCapita: stats.gdpPerCapita || 0, + population: stats.population || 0, + atWar: self ? false : wars.has(index), + self, + }); + } + this._sortDiplomacyRows(rows); + + const columns = [ + { key: "name", label: "Nation" }, + { key: "gdp", label: "GDP", numeric: true }, + { key: "gdpPerCapita", label: "GDP / capita", numeric: true }, + { key: "population", label: "Population", numeric: true }, + { key: "status", label: "Status" }, + ]; + const $table = $("
"); + const $head = $(""); + const $headRow = $(""); + for (const column of columns) { + const $th = $("").attr("data-sort", column.key).text(column.label); + if (column.numeric) $th.addClass("numeric"); + if (this.diplomacySort.key === column.key) { + const descending = this.diplomacySort.dir < 0; + $th.addClass(descending ? "sorted-desc" : "sorted-asc"); + $th.append( + $("").text(descending ? "▼" : "▲") + ); + } + $th.on("click", () => this._sortDiplomacyBy(column.key)); + $headRow.append($th); + } + $head.append($headRow); + + const $body = $(""); + for (const row of rows) { + const $tr = $(""); + if (row.self) $tr.addClass("local-nation"); + $tr.append($("").text(row.name)); + $tr.append($("").text(`€${compact(row.gdp)}`)); + $tr.append($("").text(`€${compact(row.gdpPerCapita)}`)); + $tr.append($("").text(compact(row.population))); + const $status = $(""); + if (row.self) { + $status.append($("").text("Your nation")); + } else { + $status.append( + $("").text(row.atWar ? "At war" : "At peace") + ); + const $action = $(""); + if (row.atWar) { + $action.text("At war").prop("disabled", true); + } else { + $action.text("Declare war"); + $action.on("click", () => this.onDeclareWar(row.index)); + } + $status.append($action); + } + $tr.append($status); + $body.append($tr); + } + $table.append($head, $body); + this.$diplomacyList.append($table); + } + + // Clicking a header sorts by it, reversing the order when it is already the + // active column. + _sortDiplomacyBy(key) { + if (this.diplomacySort.key === key) { + this.diplomacySort.dir *= -1; + } else { + this.diplomacySort.key = key; + this.diplomacySort.dir = 1; + } + this._renderDiplomacy(); + } + + _sortDiplomacyRows(rows) { + const { key, dir } = this.diplomacySort; + rows.sort((a, b) => { + let result; + if (key === "name") result = a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + else if (key === "status") result = (a.atWar ? 1 : 0) - (b.atWar ? 1 : 0); + else result = a[key] - b[key]; + if (result === 0) result = a.index - b.index; + return dir * result; + }); + } + + // Lists every ongoing war between the world's nations, whoever is fighting. + // Clicking a conflict reveals the date it began and the military and civilian + // dead on each side. + _renderConflicts() { + if (!this.$conflictsList || !this.$conflictsList.length) return; + this.$conflictsList.empty(); + const conflicts = this.conflicts || []; + if (this.$conflictsSub && this.$conflictsSub.length) { + this.$conflictsSub.text( + conflicts.length === 0 + ? "No ongoing conflicts." + : `${conflicts.length} ongoing conflict${conflicts.length === 1 ? "" : "s"} between the world's nations.` + ); + } + if (conflicts.length === 0) { + this.$conflictsList.append( + $("
").text("The world is at peace.") + ); + return; + } + for (const conflict of conflicts) this._makeConflictRow(conflict); + } + + _makeConflictRow(conflict) { + const $card = $("
"); + const $info = $("
"); + const $name = $("
").text( + `${this._civName(conflict.a)} vs ${this._civName(conflict.b)}` + ); + const $desc = $("
").text(`Ongoing since ${conflict.started}`); + $info.append($name, $desc); + + const $detail = $(""); + $detail.append( + $("
").text(`Conflict started ${conflict.started}`) + ); + for (const side of conflict.sides || []) { + $detail.append( + $("
").text( + `${this._civName(side.civ)} — Military: ${groupDigits(side.military || 0)} · ` + + `Civilians: ${groupDigits(side.civilians || 0)}` + ) + ); + } + + $card.append($info, $detail); + $card.on("click", () => $detail.toggleClass("hidden")); + this.$conflictsList.append($card); + } + + _civName(index) { + const civ = this.diplomacy && this.diplomacy.civs ? this.diplomacy.civs[index] : null; + return civ ? civ.name : `Nation ${index + 1}`; + } + + close() { + this.$modal.addClass("hidden"); + } +} diff --git a/client/js/modals/news.js b/client/js/modals/news.js new file mode 100644 index 0000000..1f353a8 --- /dev/null +++ b/client/js/modals/news.js @@ -0,0 +1,41 @@ +// The news feed: a reverse-chronological list of newsworthy events. Civ indices +// are resolved to names through the catalogue the game screen passes in. + +import { describeNews } from "./format.js"; + +export class NewsModal { + constructor() { + this.$modal = $("#modal-news"); + this.$list = $("#news-list"); + this.$modal.find(".modal-close").on("click", () => this.close()); + } + + isShowing() { + return !this.$modal.hasClass("hidden"); + } + + show(news, civs) { + this.refresh(news, civs); + this.$modal.removeClass("hidden"); + } + + refresh(news, civs = []) { + if (!this.$list || !this.$list.length) return; + this.$list.empty(); + const entries = (news || []).slice().reverse(); + if (entries.length === 0) { + this.$list.append($("
").text("No news yet.")); + return; + } + for (const entry of entries) { + const $item = $("
"); + $item.append($("
").text(describeNews(entry, civs))); + $item.append($("
").text(entry.date || "")); + this.$list.append($item); + } + } + + close() { + this.$modal.addClass("hidden"); + } +} diff --git a/client/js/modals/train.js b/client/js/modals/train.js new file mode 100644 index 0000000..815bf0f --- /dev/null +++ b/client/js/modals/train.js @@ -0,0 +1,126 @@ +// The city training modal: choose a unit to build and manage the city's queue. + +import { groupDigits, hours } from "../../../shared/text_format.js"; +import { ECONOMY } from "../../../shared/data.js"; + +export class TrainModal { + constructor() { + this.$modal = $("#modal-train"); + this.$title = $("#train-title"); + this.$list = $("#train-list"); + this.$status = $("#train-status"); + this.$queue = $("#train-queue"); + this.$label = $("#train-label"); + this.$progress = $("#train-progress"); + this.cityId = 0; + this.gdp = 0; + this.protoNames = new Map(); + this.onTrain = () => {}; + this.onCancel = () => {}; + this.$modal.find(".modal-close").on("click", () => this.close()); + } + + isShowing(cityId) { + return !this.$modal.hasClass("hidden") && this.cityId === cityId; + } + + show(city, gdp, trainableUnits, queue = []) { + this.cityId = city.id; + this.gdp = gdp; + this.protoNames = new Map(); + this.$title.text(`${city.name} — Train`); + this.$list.empty(); + this.$status.addClass("hidden"); + for (let index = 0; index < trainableUnits.length; index++) { + const proto = trainableUnits[index]; + if (!proto.cost && proto.cost !== 0) continue; + const button = $(""); + const duration = ECONOMY.trainingHours(gdp, proto.cost); + button.text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`); + const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index; + this.protoNames.set(serverIndex, proto.name); + button.on("click", () => this.onTrain(city.id, serverIndex)); + this.$list.append(button); + } + this.renderQueue(queue); + this.$modal.removeClass("hidden"); + } + + // Draws the city's training queue: one row per run of contiguous identical + // units, with the cumulated hours until each run is ready and a button to + // cancel its last unit. Cancelling refunds nothing. + renderQueue(queue) { + if (!this.$queue || !this.$queue.length) return; + this.$queue.empty(); + if (!queue || queue.length === 0) { + this.$queue.append( + $("
").text("No units queued.") + ); + return; + } + let cumulative = 0; + groupQueue(queue).forEach((group, groupIndex) => { + let firstReady = 0; + for (const index of group.indices) { + const entry = queue[index]; + const elapsed = index === 0 ? (entry.elapsedHours || 0) : 0; + cumulative += Math.max(entry.totalHours - elapsed, 0); + if (index === group.indices[0]) firstReady = cumulative; + } + const count = group.indices.length; + const name = this.protoNames.get(group.protoIndex) || "Unit"; + const label = count > 1 ? `${name} ×${count}` : name; + const $row = $("
"); + const $info = $("
"); + $info.append($("
").text(`${groupIndex + 1}. ${label}`)); + $info.append( + $("
").text( + count > 1 + ? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}` + : `Ready in ${hours(cumulative)}` + ) + ); + const $actions = $("
"); + const $cancel = $("").text(count > 1 ? "Cancel last" : "Cancel"); + $cancel.on("click", () => this.onCancel(this.cityId, group.indices[group.indices.length - 1])); + $actions.append($cancel); + $row.append($info, $actions); + this.$queue.append($row); + }); + } + + beginTraining(name, totalHours, elapsedHours = 0) { + this.$label.text(`Training ${name}…`); + this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`); + this.$status.removeClass("hidden"); + } + + setTrainingProgress(name, elapsedHours, totalHours) { + const remaining = Math.max(0, Math.ceil(totalHours - elapsedHours)); + this.$label.text(`Training ${name}… (${remaining} h left)`); + this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`); + } + + endTraining() { + this.$label.text("Training complete"); + this.$progress.css("width", "100%"); + } + + close() { + this.$modal.addClass("hidden"); + this.cityId = 0; + } +} + +// Runs of contiguous queue entries with the same unit type, as lists of queue +// indices. Non-adjacent entries of the same type stay separate so a row only +// ever represents the units sitting together. +function groupQueue(queue) { + const groups = []; + queue.forEach((entry, index) => { + const last = groups[groups.length - 1]; + if (last && last.protoIndex === entry.protoIndex) last.indices.push(index); + else groups.push({ protoIndex: entry.protoIndex, indices: [index] }); + }); + return groups; +} diff --git a/shared/data/buildings.js b/shared/data/buildings.js new file mode 100644 index 0000000..31dc5cb --- /dev/null +++ b/shared/data/buildings.js @@ -0,0 +1,95 @@ +// Buildings that can be raised in a city. Costs and upkeep scale with the +// city's share of national GDP, so early levels are cheap and later ones bite. + +import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js"; + +export const BUILDINGS = [ + { + id: "market", + name: "Market", + description: "A hub of local trade. Raises national GDP, with diminishing returns.", + maxLevel: 10, + baseCost: 0, + costGdpFraction: 0.01, + costAlpha: 1.6, + baseUpkeep: 0, + upkeepGdpFraction: 0.00003, + upkeepAlpha: 1.5, + refundFraction: 0.5, + effects: [effect(EFFECT_GDP, 0.04, 0.75)], + }, + { + id: "bank", + name: "Bank", + description: "Channels private wealth into the state coffers. Raises budget income.", + maxLevel: 10, + baseCost: 0, + costGdpFraction: 0.02, + costAlpha: 1.7, + baseUpkeep: 0, + upkeepGdpFraction: 0.00004, + upkeepAlpha: 1.6, + refundFraction: 0.5, + effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)], + }, + { + id: "research_lab", + name: "Research lab", + description: "Applies science to industry and government. Produces research points.", + maxLevel: 10, + baseCost: 0, + costGdpFraction: 0.015, + costAlpha: 1.65, + baseUpkeep: 0, + upkeepGdpFraction: 0.00005, + upkeepAlpha: 1.55, + refundFraction: 0.5, + effects: [effect(EFFECT_RESEARCH, 1.0, 0.9)], + }, + { + id: "university", + name: "University", + description: "Higher education produces research and a little culture.", + maxLevel: 10, + baseCost: 0, + costGdpFraction: 0.025, + costAlpha: 1.7, + baseUpkeep: 0, + upkeepGdpFraction: 0.00008, + upkeepAlpha: 1.6, + refundFraction: 0.5, + effects: [effect(EFFECT_RESEARCH, 2.0, 0.8), effect(EFFECT_CULTURE, 0.5, 0.7)], + }, + { + id: "museum", + name: "Museum", + description: "Preserves national heritage, strengthening culture against foreign ideas.", + maxLevel: 10, + baseCost: 0, + costGdpFraction: 0.02, + costAlpha: 1.6, + baseUpkeep: 0, + upkeepGdpFraction: 0.00005, + upkeepAlpha: 1.55, + refundFraction: 0.5, + effects: [effect(EFFECT_CULTURE, 1.5, 0.85)], + }, + { + id: "cinema_studio", + name: "Cinema studio", + description: "Mass media spreads the nation's culture at home and abroad.", + maxLevel: 10, + baseCost: 0, + costGdpFraction: 0.03, + costAlpha: 1.75, + baseUpkeep: 0, + upkeepGdpFraction: 0.0001, + upkeepAlpha: 1.65, + refundFraction: 0.5, + effects: [effect(EFFECT_CULTURE, 3.0, 0.7)], + }, +]; + +export function buildingById(id) { + return BUILDINGS.find((b) => b.id === id) || null; +} diff --git a/shared/data/civilisations.js b/shared/data/civilisations.js new file mode 100644 index 0000000..49bbb87 --- /dev/null +++ b/shared/data/civilisations.js @@ -0,0 +1,149 @@ +// The playable civilisations: colours, flag art and the pool of city names +// drawn on when settlements are placed. + +export const CIVILISATIONS = [ + { + id: "france", + name: "France", + demonym: "French", + primaryColour: "#0d0d5c", + accentColour: "#8c8c82", + flag: "flag_france.png", + cityNames: ["Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Montpellier", "Strasbourg", "Bordeaux", "Lille"], + }, + { + id: "britain", + name: "Britain", + demonym: "British", + primaryColour: "#a81818", + accentColour: "#0722ff", + flag: "flag_britain.png", + cityNames: ["Birmingham", "Manchester", "Glasgow", "Liverpool", "Leeds", "Sheffield", "Edinburgh", "Bristol", "Cardiff"], + }, + { + id: "slovenia", + name: "Slovenia", + demonym: "Slovenian", + primaryColour: "#005da4", + accentColour: "#ed1c24", + flag: "flag_slovenia.png", + cityNames: ["Ljubljana", "Maribor", "Celje", "Kranj", "Koper", "Velenje", "Novo Mesto", "Ptuj", "Kamnik", "Jesenice"], + }, + { + id: "poland", + name: "Poland", + demonym: "Polish", + primaryColour: "#a6000b", + accentColour: "#ffffff", + flag: "flag_poland.png", + cityNames: ["Warsaw", "Kraków", "Łódź", "Wrocław", "Poznań", "Gdańsk", "Szczecin", "Bydgoszcz", "Lublin", "Katowice"], + }, + { + id: "switzerland", + name: "Switzerland", + demonym: "Swiss", + primaryColour: "#da291c", + accentColour: "#ffffff", + flag: "flag_switzerland.png", + cityNames: ["Bern", "Zürich", "Geneva", "Basel", "Lausanne", "Lucerne", "Winterthur", "St. Gallen", "Lugano", "Biel"], + }, + { + id: "ticino", + name: "Ticino", + demonym: "Ticinese", + primaryColour: "#4646e0", + accentColour: "#a6000b", + flag: "flag_ticino.png", + cityNames: ["Bellinzona", "Lugano", "Locarno", "Mendrisio", "Chiasso", "Airolo", "Biasca", "Ascona", "Faido", "Giubiasco"], + }, + { + id: "galicia", + name: "Galicia", + demonym: "Galician", + primaryColour: "#006dad", + accentColour: "#ffffff", + flag: "flag_galicia.png", + cityNames: ["Santiago de Compostela", "Vigo", "A Coruña", "Ourense", "Lugo", "Pontevedra", "Ferrol", "Vilagarcía de Arousa", "Ribeira", "Redondela"], + }, + { + id: "germany", + name: "Germany", + demonym: "German", + primaryColour: "#141414", + accentColour: "#ffce00", + flag: "flag_germany.png", + cityNames: ["Berlin", "Hamburg", "Munich", "Cologne", "Frankfurt", "Stuttgart", "Düsseldorf", "Leipzig", "Dortmund", "Essen"], + }, + { + id: "iran", + name: "Iran", + demonym: "Iranian", + primaryColour: "#18a645", + accentColour: "#f01c21", + flag: "flag_iran.png", + cityNames: ["Tehran", "Mashhad", "Isfahan", "Karaj", "Shiraz", "Tabriz", "Qom", "Ahvaz", "Kermanshah", "Urmia"], + }, + { + id: "usa", + name: "United States", + demonym: "American", + primaryColour: "#3c3b6e", + accentColour: "#b22234", + flag: "flag_usa.png", + cityNames: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", "San Diego", "Dallas", "San Jose"], + }, + { + id: "aotearoa", + name: "Aotearoa", + demonym: "New Zealander", + primaryColour: "#00247d", + accentColour: "#cc142b", + flag: "flag_aotearoa.png", + cityNames: ["Auckland", "Wellington", "Christchurch", "Hamilton", "Tauranga", "Dunedin", "Palmerston North", "Napier", "Nelson", "Rotorua"], + }, + { + id: "new_hampshire", + name: "New Hampshire", + demonym: "New Hampshirite", + primaryColour: "#12355b", + accentColour: "#f2b705", + flag: "flag_new_hampshire.png", + cityNames: ["Manchester", "Nashua", "Concord", "Derry", "Dover", "Rochester", "Salem", "Merrimack", "Portsmouth", "Laconia"], + }, + { + id: "florida", + name: "Florida", + demonym: "Floridian", + primaryColour: "#782f40", + accentColour: "#ceb888", + flag: "flag_florida.png", + cityNames: ["Jacksonville", "Miami", "Tampa", "Orlando", "St. Petersburg", "Hialeah", "Tallahassee", "Fort Lauderdale", "Port St. Lucie", "Cape Coral"], + }, + { + id: "australia", + name: "Australia", + demonym: "Australian", + primaryColour: "#00843d", + accentColour: "#ffcd00", + flag: "flag_australia.png", + cityNames: ["Sydney", "Melbourne", "Brisbane", "Perth", "Adelaide", "Gold Coast", "Canberra", "Newcastle", "Wollongong", "Hobart"], + }, + { + id: "uruguay", + name: "Uruguay", + demonym: "Uruguayan", + primaryColour: "#0038a8", + accentColour: "#fcd116", + flag: "flag_uruguay.png", + cityNames: ["Montevideo", "Salto", "Paysandú", "Las Piedras", "Rivera", "Maldonado", "Tacuarembó", "Melo", "Artigas", "Mercedes"], + }, + { + id: "vietnam", + name: "Vietnam", + demonym: "Vietnamese", + primaryColour: "#da251d", + accentColour: "#ffff00", + flag: "flag_vietnam.png", + cityNames: ["Ho Chi Minh City", "Hanoi", "Haiphong", "Da Nang", "Can Tho", "Bien Hoa", "Hue", "Nha Trang", "Buon Ma Thuot", "Vung Tau"], + }, +]; diff --git a/shared/data/combat.js b/shared/data/combat.js new file mode 100644 index 0000000..4038a24 --- /dev/null +++ b/shared/data/combat.js @@ -0,0 +1,41 @@ +// Combat, garrison and pillage rules. Shared so the server (which applies them) +// and the browser (which forecasts them) compute the same numbers. + +// Units garrisoned in a friendly city regain this many hit points each hour. +export const CITY_GARRISON_HEAL_PER_HOUR = 1; + +// Combat is fought on a single tile: the attacking stack charges onto the +// defending stack's tile and the two share it until one side is wiped out. +// Every day a battle lasts the tile loses this fraction of its population and +// of its GDP per capita. Once the fighting stops, GDP per capita recovers by +// `recoveryPerDay` each day, starting `recoveryDelayDays` after the last battle. +export const BATTLE = { + populationLossPerDay: 0.3, + gdpPerCapitaLossPerDay: 0.3, + recoveryDelayDays: 30, + recoveryPerDay: 100, +}; + +// Pillaging: each unit removes this fraction of the tile's remaining +// population, cuts its GDP per capita by this multiplier, and heals one hit +// point per this many people driven off. +export const PILLAGE = { + populationLoss: 0.1, + gdpPenalty: 0.7, + healPerPeople: 1000, +}; + +// The result of pillaging a tile with `count` units in sequence, starting from +// its current population. Each step removes a tenth of what is left and heals +// for the people it removed; the GDP-per-capita multiplier compounds. Shared so +// the server and the forecast tooltip compute the same numbers. +export function pillageSteps(population, count) { + const steps = []; + let remaining = population; + for (let i = 0; i < count; i++) { + const removed = remaining * PILLAGE.populationLoss; + remaining -= removed; + steps.push({ removed, remaining, heal: removed / PILLAGE.healPerPeople }); + } + return { steps, gdpMultiplier: Math.pow(PILLAGE.gdpPenalty, count) }; +} diff --git a/shared/data/economy.js b/shared/data/economy.js new file mode 100644 index 0000000..68deb8c --- /dev/null +++ b/shared/data/economy.js @@ -0,0 +1,35 @@ +// National economy constants and the pure formulas the server and the browser +// both use to derive production, training time, budget income and population +// growth. + +export const ECONOMY = { + baseGdpPerCapita: 10_000, + basePopulation: 100_000, + cityTileGdpMultiplier: 2.0, + capitalTileGdpMultiplier: 2.5, + citySurroundingGdpBonus: 0.3, + gdpPerProductionPoint: 100_000, + budgetGdpRate: 0.001, + startingBudget: 0, + basePopulationGrowthRate: 0.005, + + productionCapacity(gdp) { + if (this.gdpPerProductionPoint <= 0) return 0; + return gdp / this.gdpPerProductionPoint; + }, + + trainingHours(gdp, cost) { + const capacity = this.productionCapacity(gdp); + if (capacity <= 0) return 0; + return cost / capacity; + }, + + budgetIncome(gdp, hours = 1) { + return gdp * this.budgetGdpRate * hours; + }, + + populationGrowthFactor(hoursPerYear, hours = 1) { + if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0; + return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1; + }, +}; diff --git a/shared/data/effects.js b/shared/data/effects.js new file mode 100644 index 0000000..b7020fd --- /dev/null +++ b/shared/data/effects.js @@ -0,0 +1,19 @@ +// Stat effects shared by every data-driven entry (buildings, governments, +// technologies). An effect scales with a level so a building's benefit grows +// with each upgrade. + +export const EFFECT_GDP = "gdp"; +export const EFFECT_RESEARCH = "research"; +export const EFFECT_CULTURE = "culture"; +export const EFFECT_BUDGET_INCOME = "budget_income"; + +export function effect(stat, baseValue, exponent = 1.0) { + return { stat, baseValue, exponent }; +} + +// The value of an effect at `level`. A level of zero or less contributes +// nothing, so an unbuilt building never has an effect. +export function effectValue(e, level) { + if (level <= 0) return 0; + return e.baseValue * Math.pow(level, e.exponent); +} diff --git a/shared/data/governments.js b/shared/data/governments.js new file mode 100644 index 0000000..c605e78 --- /dev/null +++ b/shared/data/governments.js @@ -0,0 +1,35 @@ +// Forms of government a civilisation may adopt. Each grants a flat modifier to +// the stats it favours. + +import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js"; + +export const GOVERNMENTS = [ + { + id: "republic", + name: "Republic", + description: "Rule by elected representatives. A balanced government that spreads its support across the economy, the treasury and the sciences.", + baseCost: 0, + costGdpFraction: 0.02, + effects: [effect(EFFECT_GDP, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)], + }, + { + id: "monarchy", + name: "Monarchy", + description: "Rule by a hereditary sovereign. The crown favours commerce and tradition, lifting the economy and the nation's cultural output.", + baseCost: 0, + costGdpFraction: 0.03, + effects: [effect(EFFECT_GDP, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)], + }, + { + id: "technocracy", + name: "Technocracy", + description: "Rule by technical experts. A single-minded drive for progress pours resources into research while economic growth takes second place.", + baseCost: 0, + costGdpFraction: 0.04, + effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_GDP, 0.03, 1.0)], + }, +]; + +export function governmentById(id) { + return GOVERNMENTS.find((g) => g.id === id) || null; +} diff --git a/shared/data/index.js b/shared/data/index.js new file mode 100644 index 0000000..86c8e68 --- /dev/null +++ b/shared/data/index.js @@ -0,0 +1,16 @@ +// Catalogue barrel. Every module re-exports its slice of the game's data here, +// so callers can import from a single place while each concern lives in its own +// small file. + +export * from "./effects.js"; +export * from "./relations.js"; +export * from "./civilisations.js"; +export * from "./units.js"; +export * from "./buildings.js"; +export * from "./governments.js"; +export * from "./technologies.js"; +export * from "./statuses.js"; +export * from "./combat.js"; +export * from "./economy.js"; +export * from "./map_config.js"; +export * from "./terrain.js"; diff --git a/shared/data/map_config.js b/shared/data/map_config.js new file mode 100644 index 0000000..6efc580 --- /dev/null +++ b/shared/data/map_config.js @@ -0,0 +1,50 @@ +// World-generation configuration. The seed is overridden per game; the rest +// tunes the shape and populating of the map. + +export const MAP_CONFIG = { + mapSize: { x: 100, y: 100 }, + topology: "cylindrical", + terrainSeed: 0, + seaLevel: -0.05, + falloffEnabled: true, + falloffStart: 0.55, + falloffStrength: 0.6, + iceLatitude: 0.9, + noise: { + frequency: 0.02, + octaves: 1, + lacunarity: 2.0, + gain: 0.5, + }, + // The finer field that roughens the coastline, and how strongly it bends the + // shore away from the smooth continent outline. + coastNoise: { + frequency: 0.09, + octaves: 2, + lacunarity: 2.0, + gain: 0.5, + }, + coastRoughness: 0.4, + // Third pass: clusters of terrain laid over the plains, independent of the + // continent shape so no terrain type follows the landmasses. `coverage` is + // the share of the land aimed for and `size` the tiles per cluster (jittered + // per cluster). Earlier entries take priority where clusters overlap. + terrainBiomes: [ + { terrainType: "Mountain", coverage: 0.03, size: 10 }, + { terrainType: "Hills", coverage: 0.1, size: 26 }, + { terrainType: "Forest", coverage: 0.16, size: 40 }, + { terrainType: "Desert", coverage: 0.08, size: 48 }, + ], + capitalMargin: 2, + minCapitalDistance: 8, + minCapitalLandmass: 40, + // Landmasses and seas smaller than these fractions of the map are smoothed + // away, so the world reads as a few big continents rather than speckle. The + // thresholds scale with the map, so tiny test maps are left alone. + minIslandFraction: 0.0015, + minLakeFraction: 0.0015, + connectOceans: true, + citiesPerCiv: 10, + // Minimum distance between any two cities during generation (never below 5). + citySpacing: 5, +}; diff --git a/shared/data/relations.js b/shared/data/relations.js new file mode 100644 index 0000000..987499b --- /dev/null +++ b/shared/data/relations.js @@ -0,0 +1,12 @@ +// Diplomatic relations between two civilisations. Only peace and war exist for +// now, but the catalogue is data-driven so further statuses (alliances, +// truces, ...) can be slotted in without touching the simulation. + +export const RELATION_PEACE = "peace"; +export const RELATION_WAR = "war"; +export const RELATIONS = [RELATION_PEACE, RELATION_WAR]; + +// Kinds of news item shown in the feed. Newsworthy events are recorded in +// GameState with their in-game hour so every client can render them. +export const NEWS_WAR = "war"; +export const NEWS_CAPTURE = "capture"; diff --git a/shared/data/statuses.js b/shared/data/statuses.js new file mode 100644 index 0000000..6a22472 --- /dev/null +++ b/shared/data/statuses.js @@ -0,0 +1,40 @@ +// Status effects a unit or city can carry. They are data-driven so new ones can +// be added without touching the simulation or the renderer: the server derives +// the active ids, applies their fields, and ships the ids in snapshots; the +// browser draws one icon per id. +// +// A status may define `hpLossPerDay` (a fraction of maximum hit points taken +// each day), a `cityHpLossPerDay` override for when the unit stands in a city, +// and an `upkeepMultiplier` applied to the unit's upkeep while it is active. + +export const STATUS_ENCIRCLED = "encircled"; +export const STATUS_BESIEGED = "besieged"; +export const STATUS_BATTLE = "battle"; + +export const STATUSES = [ + { + id: STATUS_ENCIRCLED, + label: "Encircled", + icon: "encirclement.svg", + description: "Cut off from every allied city while a hostile unit is adjacent. Loses 10% of its maximum hit points each day, or 2% inside a city.", + hpLossPerDay: 0.10, + cityHpLossPerDay: 0.02, + }, + { + id: STATUS_BESIEGED, + label: "Besieged", + icon: "encirclement.svg", + description: "Cut off from every other allied city while a hostile unit is adjacent.", + }, + { + id: STATUS_BATTLE, + label: "In battle", + icon: "icon_battle.svg", + description: "Fighting an enemy unit on the same tile. Upkeep is tripled and the unit cannot pillage while the battle rages.", + upkeepMultiplier: 3, + }, +]; + +export function statusById(id) { + return STATUSES.find((status) => status.id === id) || null; +} diff --git a/shared/data/technologies.js b/shared/data/technologies.js new file mode 100644 index 0000000..db862ef --- /dev/null +++ b/shared/data/technologies.js @@ -0,0 +1,51 @@ +// Research tree. A technology costs research points and may require earlier +// technologies before it can be started. + +import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js"; + +export const TECHNOLOGIES = [ + { + id: "scientific_method", + name: "Scientific Method", + description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.", + cost: 20, + prerequisites: [], + effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)], + }, + { + id: "industrial_automation", + name: "Industrial Automation", + description: "Robotics and control systems take over the factory floor, lifting national output.", + cost: 60, + prerequisites: ["scientific_method"], + effects: [effect(EFFECT_GDP, 0.08, 1.0)], + }, + { + id: "renewable_energy", + name: "Renewable Energy", + description: "Clean generation cuts waste and strengthens the public finances.", + cost: 80, + prerequisites: ["scientific_method"], + effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)], + }, + { + id: "quantum_computing", + name: "Quantum Computing", + description: "Machines that reason in superposition. A step change in the pace of discovery.", + cost: 120, + prerequisites: ["scientific_method"], + effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)], + }, + { + id: "space_program", + name: "Space Program", + description: "Reaching orbit inspires the nation and spins off technology across the economy.", + cost: 300, + prerequisites: ["industrial_automation", "quantum_computing"], + effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)], + }, +]; + +export function technologyById(id) { + return TECHNOLOGIES.find((t) => t.id === id) || null; +} diff --git a/shared/data/terrain.js b/shared/data/terrain.js new file mode 100644 index 0000000..7fdf9e0 --- /dev/null +++ b/shared/data/terrain.js @@ -0,0 +1,19 @@ +// The terrain TileSet, flattened. `col` is the atlas column in terrain.png +// (one row, 7 tiles of 63x56); the class decides land/sea/ice. The generator +// lays terrain types out as biome clusters over the land, so `minElevation` is +// only the authored ordering hint and no longer picks tiles. `defenseBonus` is +// the fraction added to a unit's defense while it stands on the tile. + +export const TERRAIN_TILES = [ + { col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 }, + { col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 }, + { col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 }, + { col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 }, + { col: 4, terrainType: "Desert", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 }, + { col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 }, + { col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 }, +]; + +// A city standing on a tile adds this fraction on top of the terrain's own +// defensive bonus. +export const CITY_DEFENSE_BONUS = 0.5; diff --git a/shared/data/units.js b/shared/data/units.js new file mode 100644 index 0000000..f7bd290 --- /dev/null +++ b/shared/data/units.js @@ -0,0 +1,62 @@ +// Unit archetypes. `military` units fight, capture cities and may enter an +// enemy military unit's tile. `attack` is the damage dealt per combat round and +// `defense` the damage absorbed per round; a defender's defense is doubled (the +// 100% defensive bonus). `range` is how many tiles away the unit can strike +// (1 is melee). + +export const PROTO_UNITS = [ + { + id: "modern_infantry", + name: "Modern infantry", + maxHp: 500, + cost: 100_000_000, + upkeep: 5000, + moveable: true, + speed: 0.5, + military: true, + attack: 100, + defense: 40, + range: 1, + traversableTerrains: ["Land"], + icon: "icon_modern_infantry.svg", + }, + { + id: "worker", + name: "Worker", + maxHp: 100, + cost: 0, + upkeep: 0, + moveable: true, + speed: 0.5, + military: false, + attack: 0, + defense: 0, + range: 1, + traversableTerrains: ["Land"], + icon: "icon_settler.svg", + }, + { + id: "artillery", + name: "Artillery", + maxHp: 400, + cost: 300_000_000, + upkeep: 12_000, + moveable: true, + speed: 0.25, + military: true, + attack: 250, + // Barely armoured: nearly every hit gets through. + defense: 5, + // Outranges infantry, so melee units cannot strike back from two tiles. + range: 2, + traversableTerrains: ["Land"], + icon: "icon_artillery.svg", + }, +]; + +// Only units in this list can be trained from the city screen. +export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"]; + +export function protoUnitById(id) { + return PROTO_UNITS.find((u) => u.id === id) || null; +} diff --git a/shared/game_state/combat.js b/shared/game_state/combat.js new file mode 100644 index 0000000..36ac198 --- /dev/null +++ b/shared/game_state/combat.js @@ -0,0 +1,305 @@ +// Combat: battle detection, the daily combat round, captures, daily attrition +// and the lasting scars a battlefield leaves on a tile. + +import { key } from "../hex.js"; +import { BATTLE } from "../data/combat.js"; +import { STATUS_BATTLE, statusById } from "../data/statuses.js"; +import { defenseMultiplier } from "../rules.js"; +import { DEFENDER_DEFENSE_MULTIPLIER, HOURS_PER_DAY } from "./constants.js"; + +export const combatMethods = { + isMilitary(unit) { + return this._isMilitary(unit); + }, + + _isMilitary(unit) { + const proto = this.unitProto(unit); + return !!(proto && proto.military); + }, + + _unitAttack(unit) { + const proto = this.unitProto(unit); + return proto && typeof proto.attack === "number" ? proto.attack : 0; + }, + + _unitDefense(unit) { + const proto = this.unitProto(unit); + return proto && typeof proto.defense === "number" ? proto.defense : 0; + }, + + // Fraction of extra defense granted by the tile a unit occupies (terrain + // plus any city), as a multiplier. + _defenseMultiplierAt(coords) { + const tile = this.tiles[key(coords.x, coords.y)]; + return defenseMultiplier(tile, !!this.cityAt(coords)); + }, + + // A unit's base defense, boosted by its tile, and doubled when it is the + // defending side of the exchange. + _effectiveDefense(unit, isDefender) { + let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords); + if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER; + return defense; + }, + + // Unique attacker/defender pairs. A pair is two hostile military units on the + // same tile; the lower id is treated as the attacker so both sides trade one + // blow rather than two. + _combatPairs() { + const pairs = new Map(); + for (const unit of this.units) { + const defender = this._combatTarget(unit); + if (!defender) continue; + const pairKey = unit.id < defender.id + ? `${unit.id}:${defender.id}` + : `${defender.id}:${unit.id}`; + if (!pairs.has(pairKey)) pairs.set(pairKey, { attacker: unit, defender }); + } + return Array.from(pairs.values()); + }, + + // The enemy military unit on the given unit's own tile it trades blows with, + // or null. With every combatant on one tile this is simply the weakest + // hostile military unit there, so a stack focuses its fire. + _combatTarget(unit) { + if (!this._isMilitary(unit)) return null; + const enemies = this.unitsAt(unit.coords).filter( + (other) => other.id !== unit.id && other.civ !== unit.civ && this._isMilitary(other) + ); + if (enemies.length === 0) return null; + enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id)); + return enemies[0]; + }, + + // Combat is fought on a single tile, so every unit sharing a tile with a + // hostile military unit is locked in place until one side is wiped out. + _engagedUnits() { + return this._battleUnitIds(); + }, + + // Every tile holding military units of two or more hostile civilisations is + // a battlefield: the stacks share the tile and fight there. + _battleTiles() { + const byTile = new Map(); + for (const unit of this.units) { + if (!this._isMilitary(unit)) continue; + const k = key(unit.coords.x, unit.coords.y); + let entry = byTile.get(k); + if (!entry) { + entry = { coords: { x: unit.coords.x, y: unit.coords.y }, civs: new Set(), units: [] }; + byTile.set(k, entry); + } + entry.civs.add(unit.civ); + entry.units.push(unit); + } + const battles = []; + for (const entry of byTile.values()) { + if (entry.civs.size >= 2) battles.push(entry); + } + return battles; + }, + + // The ids of every unit currently trading blows in a battle. + _battleUnitIds() { + const ids = new Set(); + for (const battle of this._battleTiles()) { + for (const unit of battle.units) ids.add(unit.id); + } + return ids; + }, + + // The statuses shown for a unit this instant: the supply statuses stored on + // the unit plus "in battle", which is derived from the live combat state so + // it appears and clears with the fighting rather than only on the daily tick. + _effectiveStatuses(unit, inBattle) { + const statuses = unit.statuses || []; + if (!inBattle || statuses.includes(STATUS_BATTLE)) return statuses; + return [...statuses, STATUS_BATTLE]; + }, + + // The combined upkeep multiplier of the given statuses. + _statusUpkeepMultiplier(statuses) { + let multiplier = 1; + for (const id of statuses) { + const def = statusById(id); + if (def && typeof def.upkeepMultiplier === "number") multiplier *= def.upkeepMultiplier; + } + return multiplier; + }, + + // Applies every active status once per day. The only effect so far is + // attrition, but the loop is generic so new daily fields slot in here. + _applyDailyStatusEffects() { + const battle = this._battleUnitIds(); + const damage = new Map(); + for (const unit of this.units) { + const statuses = this._effectiveStatuses(unit, battle.has(unit.id)); + if (statuses.length === 0) continue; + const inCity = !!this.cityAt(unit.coords); + let fraction = 0; + for (const id of statuses) { + const def = statusById(id); + if (def) fraction += this._statusHpLossPerDay(def, inCity); + } + if (fraction > 0) damage.set(unit, unit.maxHp * fraction); + } + if (damage.size === 0) return; + const destroyed = []; + for (const [unit, amount] of damage) { + unit.hp -= amount; + if (unit.hp <= 0) { + unit.hp = 0; + destroyed.push(unit); + } + } + this._recordMilitaryCasualties(destroyed); + for (const unit of destroyed) this._destroyUnit(unit); + if (destroyed.length > 0) this._visibilityDirty = true; + this._emitChanged(); + }, + + // One combat round: at most one per day, resolved in `tickHour`. Every pair + // of hostile military units sharing a tile trades blows and damage lands + // simultaneously, so a mutual kill destroys both units. + _resolveCombatRound() { + const damage = new Map(); + for (const { attacker, defender } of this._combatPairs()) { + const toDefender = Math.max( + 1, + this._unitAttack(attacker) - this._effectiveDefense(defender, true) + ); + damage.set(defender, (damage.get(defender) || 0) + toDefender); + const toAttacker = Math.max( + 1, + this._unitAttack(defender) - this._effectiveDefense(attacker, false) + ); + damage.set(attacker, (damage.get(attacker) || 0) + toAttacker); + } + if (damage.size > 0) { + const destroyed = []; + for (const [unit, amount] of damage) { + unit.hp -= amount; + if (unit.hp <= 0) { + unit.hp = 0; + destroyed.push(unit); + } + } + this._recordMilitaryCasualties(destroyed); + for (const unit of destroyed) this._destroyUnit(unit); + if (destroyed.length > 0) { + this._visibilityDirty = true; + this._emitChanged(); + } + } + // A battle can free a besieged city the same tick it ends, so claim it now. + this._resolveCaptures(); + }, + + // A military unit standing on an enemy city with no defender left captures it. + _resolveCaptures() { + const claimed = new Set(); + for (const unit of this.units) { + if (!this._isMilitary(unit)) continue; + const coords = unit.coords; + if (this._hasHostileOn(coords, unit.civ)) continue; + const city = this._cityByCoords.get(key(coords.x, coords.y)); + if (!city || city.civ === unit.civ || claimed.has(city.id)) continue; + claimed.add(city.id); + this._captureCity(city, unit.civ); + } + }, + + _destroyUnit(unit) { + const index = this.units.indexOf(unit); + if (index >= 0) this.units.splice(index, 1); + this._unitById.delete(unit.id); + }, + + // Called when a unit finishes stepping onto a tile. A military unit entering + // an enemy city captures it, but only once every defender has fallen. + _onUnitArrived(unit) { + if (this._hasHostileOn(unit.coords, unit.civ)) return; + const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y)); + if (city && city.civ !== unit.civ && this._isMilitary(unit)) { + this._captureCity(city, unit.civ); + } + }, + + // Tallies the military dead of a battle before the units are removed, while + // every participant is still on the tile so the enemies can be identified. + _recordMilitaryCasualties(destroyed) { + for (const unit of destroyed) { + if (!this._isMilitary(unit)) continue; + const enemies = new Set(); + for (const other of this.unitsAt(unit.coords)) { + if (other.id !== unit.id && other.civ !== unit.civ) enemies.add(other.civ); + } + if (enemies.size === 0) { + for (const neighbour of this._neighbours(unit.coords)) { + for (const other of this.unitsAt(neighbour)) { + if (other.civ !== unit.civ) enemies.add(other.civ); + } + } + } + for (const enemy of enemies) this._recordCasualties(unit.civ, enemy, "military", 1); + } + }, + + // The civilians killed on a battlefield belong to the tile's owner; when + // several hostile civilisations share the tile the toll is split between them. + _recordBattleCivilianCasualties(battle, amount) { + const owner = this.civAt(battle.coords); + if (owner < 0) return; + const opponents = Array.from(battle.civs).filter((civ) => civ !== owner); + if (opponents.length === 0) return; + const share = amount / opponents.length; + for (const enemy of opponents) this._recordCasualties(owner, enemy, "civilians", share); + }, + + // Every tile where a battle is raging loses a share of its population and its + // GDP per capita each day, and its recovery clock is pushed back to 30 days + // after the last day of fighting. + _tickBattleTiles() { + for (const battle of this._battleTiles()) { + const k = key(battle.coords.x, battle.coords.y); + const population = this.tilePopulation.get(k); + if (population !== undefined) { + const removed = population * BATTLE.populationLossPerDay; + this.tilePopulation.set(k, population - removed); + this._recordBattleCivilianCasualties(battle, removed); + } + const perCapita = this.getTileGdpPerCapita(battle.coords); + let scarred = this.tileBattleGdpDeficit.has(k); + if (perCapita > 0) { + const deficit = (this.tileBattleGdpDeficit.get(k) || 0) + + perCapita * BATTLE.gdpPerCapitaLossPerDay; + this.tileBattleGdpDeficit.set(k, deficit); + this._gdpPerCapitaCache.delete(k); + scarred = true; + } + if (scarred) { + this.tileBattleRecoveryStart.set( + k, + this.totalHours + BATTLE.recoveryDelayDays * HOURS_PER_DAY + ); + } + } + }, + + // Once a battlefield has been quiet for `recoveryDelayDays`, its GDP per + // capita climbs back by `recoveryPerDay` a day until the scar is gone. + _tickGdpRecovery() { + for (const [k, deficit] of Array.from(this.tileBattleGdpDeficit)) { + const start = this.tileBattleRecoveryStart.get(k); + if (start === undefined || this.totalHours < start) continue; + const remaining = deficit - BATTLE.recoveryPerDay; + if (remaining <= 0) { + this.tileBattleGdpDeficit.delete(k); + this.tileBattleRecoveryStart.delete(k); + } else { + this.tileBattleGdpDeficit.set(k, remaining); + } + this._gdpPerCapitaCache.delete(k); + } + }, +}; diff --git a/shared/game_state/constants.js b/shared/game_state/constants.js new file mode 100644 index 0000000..e24720b --- /dev/null +++ b/shared/game_state/constants.js @@ -0,0 +1,19 @@ +// Simulation constants and tuning knobs. Kept together so the rest of the +// model can import the ones it needs. + +// The news feed keeps the most recent entries and drops the oldest ones, so a +// long-running game does not grow the snapshot without bound. +export const MAX_NEWS = 100; + +export const UNIT_VISION = 2; +export const TERRITORY_VISION = 1; +export const HOURS_PER_DAY = 24; +export const DAYS_PER_YEAR = 365; +export const HOURS_PER_YEAR = HOURS_PER_DAY * DAYS_PER_YEAR; +export const CONTROLLED_SPEED_MULTIPLIER = 1.5; +export const DEFAULT_UNIT_SPEED = 0.5; +export const MIN_STEP_COST = 0.0001; +// A city may hold at most this many ordered units in its training queue. +export const TRAINING_QUEUE_LIMIT = 100; +// A defending unit's defense is doubled: a 100% defensive bonus. +export const DEFENDER_DEFENSE_MULTIPLIER = 2.0; diff --git a/shared/game_state/diplomacy.js b/shared/game_state/diplomacy.js new file mode 100644 index 0000000..a0f547b --- /dev/null +++ b/shared/game_state/diplomacy.js @@ -0,0 +1,85 @@ +// Diplomacy: war declarations, the war record with its casualty tallies, and +// the news feed. + +import { RELATION_PEACE, RELATION_WAR, NEWS_WAR } from "../data/relations.js"; +import { dateString } from "../game_clock.js"; +import { MAX_NEWS } from "./constants.js"; +import { warKey } from "./helpers.js"; + +export const diplomacyMethods = { + isAtWar(a, b) { + if (a < 0 || b < 0 || a === b) return false; + return this.wars.has(warKey(a, b)); + }, + + getRelation(a, b) { + return this.isAtWar(a, b) ? RELATION_WAR : RELATION_PEACE; + }, + + // Declares war from `civ` on `target`. Fails for unknown or identical + // civilisations and when the two are already at war. The declaration is + // newsworthy and reaches every player through the next snapshot. + requestDeclareWar(civ, target) { + if (!this._validCiv(civ) || !this._validCiv(target) || civ === target) return false; + if (!this._declareWar(civ, target)) return false; + this._emitChanged(); + return true; + }, + + _validCiv(civ) { + return Number.isInteger(civ) && civ >= 0 && civ < this.civilisations.length; + }, + + // Records the war and its news item without emitting; the caller decides when + // to publish. Returns false when the pair is already at war. + _declareWar(actor, target) { + if (actor === target) return false; + const k = warKey(actor, target); + if (this.wars.has(k)) return false; + this.wars.add(k); + this.conflicts.set(k, { + a: Math.min(actor, target), + b: Math.max(actor, target), + startHours: this.totalHours, + startDate: dateString(this.totalHours), + military: new Map(), + civilians: new Map(), + }); + this._addNews({ type: NEWS_WAR, actor, target }); + return true; + }, + + // The ongoing war record between two civilisations, or null when they are at + // peace. Casualties are only tallied while a war is recorded, so fighting that + // somehow happens without a declaration leaves no conflict behind. + _conflictBetween(a, b) { + return this.conflicts.get(warKey(a, b)) || null; + }, + + // Records that `civ` has suffered `amount` casualties of `kind` ("military" + // or "civilians") in its war against `enemy`. + _recordCasualties(civ, enemy, kind, amount) { + if (!(amount > 0)) return; + const conflict = this._conflictBetween(civ, enemy); + if (!conflict) return; + const counts = kind === "military" ? conflict.military : conflict.civilians; + counts.set(civ, (counts.get(civ) || 0) + amount); + }, + + getNews() { + return this.news; + }, + + _addNews(entry) { + const record = { + id: this._nextNewsId, + totalHours: this.totalHours, + date: dateString(this.totalHours), + ...entry, + }; + this._nextNewsId += 1; + this.news.push(record); + while (this.news.length > MAX_NEWS) this.news.shift(); + return record; + }, +}; diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js new file mode 100644 index 0000000..416f9f6 --- /dev/null +++ b/shared/game_state/economy.js @@ -0,0 +1,388 @@ +// Economy: population and GDP aggregates, budget income and upkeep breakdowns, +// research/culture accrual and the daily economic ticks. + +import { ECONOMY } from "../data/economy.js"; +import { + EFFECT_GDP, + EFFECT_BUDGET_INCOME, + EFFECT_RESEARCH, + EFFECT_CULTURE, +} from "../data/effects.js"; +import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js"; +import { buildingUpkeep, prerequisitesMet } from "../rules.js"; +import { key } from "../hex.js"; +import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js"; +import { effectAt, mergeModifiers } from "./helpers.js"; + +export const economyMethods = { + // ------------------------------------------------------ aggregates -- + + getPlayerPopulation(civ) { + return Math.round(this._playerAggregates(civ).population); + }, + + // The live economy: the raw sums over the civ's territory, adjusted by the + // balancing factors map generation assigned so every civilisation starts with + // the same population and GDP per capita. The factors stay applied as the raw + // figures grow, so terrain and war keep shaping the economy without ever + // recreating the arbitrary starting gap. + _playerAggregates(civ) { + const base = this._basePlayerAggregates(civ); + const populationScale = this._populationBalance.get(civ) || 1; + const perCapitaScale = this._perCapitaBalance.get(civ) || 1; + return { + population: base.population * populationScale, + gdp: base.gdp * populationScale * perCapitaScale, + }; + }, + + // The economy exactly as the tiles describe it, before balancing. + _basePlayerAggregates(civ) { + let population = 0; + let baseGdp = 0; + const cells = this._territoryByCiv.get(civ) || []; + for (const coords of cells) { + const pop = this.tilePopulation.get(key(coords.x, coords.y)) || 0; + population += pop; + baseGdp += pop * this.getTileGdpPerCapita(coords); + } + const modifiers = this.getCivModifiers(civ); + const gdp = baseGdp * (1 + (modifiers[EFFECT_GDP] || 0)); + return { population, gdp }; + }, + + getTileGdpPerCapita(coords) { + const k = key(coords.x, coords.y); + if (this._gdpPerCapitaCache.has(k)) return this._gdpPerCapitaCache.get(k); + const owner = this.civAt(coords); + if (owner < 0) return 0; + let perCapita = ECONOMY.baseGdpPerCapita * this.terrainStats.gdpMultiplier(coords); + const city = this.cityAt(coords); + if (city) { + perCapita *= city.isCapital + ? ECONOMY.capitalTileGdpMultiplier + : ECONOMY.cityTileGdpMultiplier; + } else if (this._isNextToCity(coords, owner)) { + perCapita *= 1 + ECONOMY.citySurroundingGdpBonus; + } + // Pillaging leaves a lasting mark on the tile's productivity. + perCapita *= this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1; + // Fighting scars the tile too, but that loss recovers over time. + perCapita -= this.tileBattleGdpDeficit.get(k) || 0; + if (perCapita < 0) perCapita = 0; + this._gdpPerCapitaCache.set(k, perCapita); + return perCapita; + }, + + getTileGdp(coords) { + return (this.tilePopulation.get(key(coords.x, coords.y)) || 0) * this.getTileGdpPerCapita(coords); + }, + + getPlayerGdp(civ) { + return this._playerAggregates(civ).gdp; + }, + + getPlayerUpkeep(civ, gdp = -1) { + let total = this.getBuildingUpkeep(civ, gdp); + const battle = this._battleUnitIds(); + for (const unit of this.units) { + if (unit.civ !== civ) continue; + const proto = this.unitProto(unit); + if (!proto) continue; + const statuses = this._effectiveStatuses(unit, battle.has(unit.id)); + total += proto.upkeep * this._statusUpkeepMultiplier(statuses); + } + return total; + }, + + getBuildingUpkeep(civ, gdp = -1) { + let total = 0; + if (gdp < 0) gdp = this.getPlayerGdp(civ); + for (const city of this.cities) { + if (city.civ !== civ) continue; + for (const indexStr of Object.keys(city.buildings)) { + const index = Number(indexStr); + total += buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]); + } + } + return total; + }, + + getCityBuildingLevel(city, protoIndex) { + return city.buildings[protoIndex] || 0; + }, + + // Income and upkeep with the source behind every figure, so the UI can show + // which buildings, government or technologies shape the budget. Amounts are + // per in-game hour, matching the rest of the economy figures. + budgetBreakdown(civ) { + const gdp = this.getPlayerGdp(civ); + const rate = ECONOMY.budgetGdpRate; + const base = gdp * rate; + const incomeSources = this._incomeSources(civ); + const modifier = incomeSources.reduce((sum, source) => sum + source.effect, 0); + const income = base * (1 + modifier); + const buildingSources = this._buildingUpkeepSources(civ, gdp); + const buildingUpkeep = buildingSources.reduce((sum, source) => sum + source.amount, 0); + const unitSources = this._unitUpkeepSources(civ); + const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0); + return { + income: { + rate, + base, + modifier, + total: income, + sources: incomeSources.map((source) => ({ ...source, amount: base * source.effect })), + }, + buildingUpkeep: { total: buildingUpkeep, sources: buildingSources }, + unitUpkeep: { total: unitUpkeep, sources: unitSources }, + net: income - buildingUpkeep - unitUpkeep, + }; + }, + + // Every building, government and researched technology that shifts budget + // income, combined per source and expressed as a fraction of the base take. + _incomeSources(civ) { + const sources = new Map(); + const add = (label, kind, effect) => { + if (effect === 0) return; + const k = `${kind}:${label}`; + const entry = sources.get(k) || { label, kind, effect: 0 }; + entry.effect += effect; + sources.set(k, entry); + }; + for (const city of this.cities) { + if (city.civ !== civ) continue; + for (const indexStr of Object.keys(city.buildings)) { + const proto = this.protoBuildings[Number(indexStr)]; + const level = city.buildings[indexStr]; + for (const e of proto.effects) { + if (e.stat === EFFECT_BUDGET_INCOME) add(proto.name, "building", effectAt(e, level)); + } + } + } + const government = this.governments[this.getGovernment(civ)]; + if (government) { + for (const e of government.effects) { + if (e.stat === EFFECT_BUDGET_INCOME) add(government.name, "government", effectAt(e, 1)); + } + } + const researched = this.researched.get(civ) || new Set(); + for (const index of researched) { + const tech = this.technologies[index]; + for (const e of tech.effects) { + if (e.stat === EFFECT_BUDGET_INCOME) add(tech.name, "technology", effectAt(e, 1)); + } + } + return this._sortSources(Array.from(sources.values())); + }, + + _buildingUpkeepSources(civ, gdp) { + const sources = new Map(); + for (const city of this.cities) { + if (city.civ !== civ) continue; + for (const indexStr of Object.keys(city.buildings)) { + const index = Number(indexStr); + const amount = buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]); + if (amount === 0) continue; + const entry = sources.get(index) || { label: this.protoBuildings[index].name, kind: "building", amount: 0 }; + entry.amount += amount; + sources.set(index, entry); + } + } + return this._sortSources(Array.from(sources.values())); + }, + + _unitUpkeepSources(civ) { + const sources = new Map(); + const battle = this._battleUnitIds(); + for (const unit of this.units) { + if (unit.civ !== civ) continue; + const proto = this.unitProto(unit); + if (!proto || !proto.upkeep) continue; + const statuses = this._effectiveStatuses(unit, battle.has(unit.id)); + const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 }; + entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses); + entry.count += 1; + sources.set(unit.proto, entry); + } + return this._sortSources(Array.from(sources.values())); + }, + + _sortSources(sources) { + const weight = (source) => (source.amount !== undefined ? source.amount : source.effect); + return sources.sort((a, b) => weight(b) - weight(a)); + }, + + // ------------------------------------------------------- modifiers -- + + getCivBuildingModifiers(civ) { + const totals = {}; + for (const city of this.cities) { + if (city.civ !== civ) continue; + for (const indexStr of Object.keys(city.buildings)) { + const proto = this.protoBuildings[Number(indexStr)]; + const level = city.buildings[indexStr]; + for (const e of proto.effects) { + totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, level); + } + } + } + return totals; + }, + + getCivGovernmentModifiers(civ) { + const totals = {}; + const resource = this.governments[this.getGovernment(civ)]; + if (!resource) return totals; + for (const e of resource.effects) { + totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1); + } + return totals; + }, + + getCivModifiers(civ) { + const totals = this.getCivBuildingModifiers(civ); + mergeModifiers(totals, this.getCivGovernmentModifiers(civ)); + mergeModifiers(totals, this.getCivTechnologyModifiers(civ)); + return totals; + }, + + // ------------------------------------------- research and culture -- + + getResearch(civ) { + return this.research.get(civ) || 0; + }, + + getCulture(civ) { + return this.culture.get(civ) || 0; + }, + + getGovernment(civ) { + return this.government.get(civ) || 0; + }, + + getGovernmentResource(civ) { + const index = this.getGovernment(civ); + if (index < 0 || index >= this.governments.length) return null; + return this.governments[index]; + }, + + isResearched(civ, index) { + const set = this.researched.get(civ); + return set ? set.has(index) : false; + }, + + getResearchedIndices(civ) { + const set = this.researched.get(civ) || new Set(); + return Array.from(set); + }, + + canResearchTechnology(civ, index) { + if (civ < 0 || civ >= this.civilisations.length) return false; + if (index < 0 || index >= this.technologies.length) return false; + if (this.isResearched(civ, index)) return false; + return prerequisitesMet( + this.technologies[index], + this.researched.get(civ) || new Set(), + this.technologies + ); + }, + + getCivTechnologyModifiers(civ) { + const totals = {}; + const set = this.researched.get(civ) || new Set(); + for (const index of set) { + for (const e of this.technologies[index].effects) { + totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1); + } + } + return totals; + }, + + getBudget(civ) { + return this.budgets.has(civ) ? this.budgets.get(civ) : ECONOMY.startingBudget; + }, + + getTraining(cityId) { + return this.training.get(cityId) || null; + }, + + // ---------------------------------------------------- daily ticks -- + + _tickPopulation() { + const factor = ECONOMY.populationGrowthFactor(HOURS_PER_YEAR, HOURS_PER_DAY); + if (factor === 0) return; + for (const k of Array.from(this.tilePopulation.keys())) { + this.tilePopulation.set(k, this.tilePopulation.get(k) * (1 + factor)); + } + }, + + _tickEconomy() { + const hours = HOURS_PER_DAY; + for (const civ of Array.from(this.budgets.keys())) { + const gdp = this.getPlayerGdp(civ); + const modifiers = this.getCivModifiers(civ); + const income = ECONOMY.budgetIncome(gdp, hours) * (1 + (modifiers[EFFECT_BUDGET_INCOME] || 0)); + const upkeep = this.getPlayerUpkeep(civ, gdp) * hours; + this.budgets.set(civ, this.getBudget(civ) + income - upkeep); + } + }, + + _tickBuildings() { + for (let civ = 0; civ < this.civilisations.length; civ++) { + const modifiers = this.getCivModifiers(civ); + const scienceRate = modifiers[EFFECT_RESEARCH] || 0; + const cultureRate = modifiers[EFFECT_CULTURE] || 0; + if (scienceRate === 0 && cultureRate === 0) continue; + this.research.set(civ, this.getResearch(civ) + scienceRate); + this.culture.set(civ, this.getCulture(civ) + cultureRate); + } + }, + + _tickTraining() { + const completed = []; + for (const [cityId, queue] of this.training) { + const city = this.findCity(cityId); + if (!city) { + completed.push(cityId); + continue; + } + // One hour of production is spent on the head of the queue; any overflow + // carries into the next ordered unit, so a fast producer can finish + // several cheap units in the same tick. + let hoursLeft = 1; + while (hoursLeft > 0 && queue.length > 0) { + const entry = queue[0]; + const proto = this.protoUnits[entry.protoIndex]; + if (!proto) { + queue.shift(); + continue; + } + const remaining = Math.max(entry.totalHours - entry.elapsedHours, 0); + if (hoursLeft >= remaining) { + hoursLeft -= remaining; + this._spawnUnit(city.coords, city.civ, proto); + this._visibilityDirty = true; + queue.shift(); + } else { + entry.elapsedHours += hoursLeft; + hoursLeft = 0; + } + } + if (queue.length === 0) completed.push(cityId); + } + for (const cityId of completed) this.training.delete(cityId); + }, + + // Units resting inside one of their own cities recover a little health each + // in-game hour. + _tickGarrisonHealing() { + for (const unit of this.units) { + if (unit.hp >= unit.maxHp) continue; + const city = this.cityAt(unit.coords); + if (!city || city.civ !== unit.civ) continue; + unit.hp = Math.min(unit.maxHp, unit.hp + CITY_GARRISON_HEAL_PER_HOUR); + } + }, +}; diff --git a/shared/game_state/helpers.js b/shared/game_state/helpers.js new file mode 100644 index 0000000..014f12e --- /dev/null +++ b/shared/game_state/helpers.js @@ -0,0 +1,41 @@ +// Small helpers shared by the GameState mixins. + +import { parseKey } from "../hex.js"; +import { effectValue } from "../data/effects.js"; + +// Canonical key for an unordered pair of civilisations. +export function warKey(a, b) { + return a < b ? `${a}:${b}` : `${b}:${a}`; +} + +// The value of a catalogue effect at `level`. Kept as a local name so the +// economy code reads naturally. +export function effectAt(e, level) { + return effectValue(e, level); +} + +export function mergeModifiers(totals, additions) { + for (const stat of Object.keys(additions)) { + totals[stat] = (totals[stat] || 0) + additions[stat]; + } +} + +// Flattens a Map<"x,y", value> into [x, y, value] triples for the wire. +export function serializeCells(map) { + const result = []; + for (const [k, value] of map) { + const coords = parseKey(k); + result.push([coords.x, coords.y, value]); + } + return result; +} + +// Flattens a Set<"x,y"> into [x, y, true] triples for the wire. +export function serializeSet(set) { + const result = []; + for (const k of set) { + const coords = parseKey(k); + result.push([coords.x, coords.y, true]); + } + return result; +} diff --git a/shared/game_state/movement.js b/shared/game_state/movement.js new file mode 100644 index 0000000..61922a2 --- /dev/null +++ b/shared/game_state/movement.js @@ -0,0 +1,362 @@ +// Movement: translating orders into paths, advancing units along them each +// tick, and the travel-cost model the browser mirrors for prediction. + +import { key } from "../hex.js"; +import { + MIN_STEP_COST, + DEFAULT_UNIT_SPEED, + CONTROLLED_SPEED_MULTIPLIER, +} from "./constants.js"; + +export const movementMethods = { + requestMove(unitId, goal) { + return this.requestGroupMove([unitId], goal); + }, + + // Orders one or more units onto `goal`. Friendly units may stack there; a + // hostile military unit turns the move into an attack. Units moving together + // adopt the speed of the slowest member, so a stack holds formation. + requestGroupMove(unitIds, goal) { + const units = this._moveableUnits(unitIds); + if (units.length === 0) return false; + const speed = units.length > 1 + ? Math.min(...units.map((unit) => this._unitSpeed(unit))) + : null; + let moved = 0; + for (const unit of units) { + // Only a unit that actually receives the new route drops its scheduled + // stops; a unit whose route cannot be found keeps its existing movement + // rather than being silently emptied out. + if (this._orderMove(unit, goal, speed)) { + unit.waypoints = []; + moved += 1; + } + } + if (moved === 0) return false; + this._emitChanged(); + return true; + }, + + // Explicit attack on the hostile force holding `goal`: an enemy unit stack or + // an enemy city. The attack is a march to the tile; combat is joined on + // arrival. If the enemy has moved away by then, the order is simply a move. + requestAttack(unitIds, goal) { + const units = this._moveableUnits(unitIds).filter((unit) => this._isMilitary(unit)); + if (units.length === 0) return false; + const civ = units[0].civ; + const enemyMilitary = this.enemyUnitsAt(goal, civ).some((other) => this._isMilitary(other)); + const city = this.cityAt(goal); + const enemyCity = !!city && city.civ !== civ; + if (!enemyMilitary && !enemyCity) return false; + return this.requestGroupMove(units.map((unit) => unit.id), goal); + }, + + // Orders units onto one or more destinations in turn. With `append`, the new + // destinations join the queue already scheduled for each unit; otherwise they + // replace it. When a unit reaches one destination a fresh path is computed to + // the next, so the route adapts to a changing world. + requestScheduledMove(unitIds, goals, append = false) { + const destinations = (Array.isArray(goals) ? goals : [goals]) + .filter((goal) => goal && Number.isFinite(goal.x) && Number.isFinite(goal.y)) + .map((goal) => ({ x: goal.x, y: goal.y })); + if (destinations.length === 0) return false; + const units = this._moveableUnits(unitIds); + if (units.length === 0) return false; + const speed = units.length > 1 + ? Math.min(...units.map((unit) => this._unitSpeed(unit))) + : null; + let moved = 0; + for (const unit of units) { + if (!Array.isArray(unit.waypoints)) unit.waypoints = []; + if (append) { + for (const goal of destinations) unit.waypoints.push({ ...goal }); + if (this._hasRoute(unit) || this._followNextWaypoint(unit, speed)) moved += 1; + } else { + const previous = unit.waypoints; + unit.waypoints = destinations.slice(1).map((goal) => ({ ...goal })); + if (this._orderMove(unit, destinations[0], speed)) moved += 1; + // An unroutable first leg leaves the unit exactly as it was. + else unit.waypoints = previous; + } + } + if (moved === 0) return false; + this._emitChanged(); + return true; + }, + + _moveableUnits(unitIds) { + const ids = Array.isArray(unitIds) ? unitIds : [unitIds]; + const units = []; + for (const id of ids) { + const unit = this.findUnit(Number(id)); + if (!unit) continue; + const proto = this.unitProto(unit); + if (!proto || !proto.moveable) continue; + units.push(unit); + } + return units; + }, + + // Whether the unit still has an unfinished route to its current destination. + _hasRoute(unit) { + return !!unit.path && unit.path.length > 1 && unit.pathIndex < unit.path.length - 1; + }, + + // Pops the next scheduled destination and routes to it, keeping the rest of + // the queue. Returns false when nothing is left or the next destination is + // unreachable (in which case the whole schedule is abandoned). + _followNextWaypoint(unit, speed = null) { + if (!unit.waypoints || unit.waypoints.length === 0) return false; + const next = unit.waypoints.shift(); + const pace = speed !== null ? speed : unit.moveSpeed; + if (!this._orderMove(unit, next, pace)) { + this._stopUnit(unit); + return false; + } + return true; + }, + + _orderMove(unit, goal, speed) { + // Attacking: a hostile military unit standing on the goal. Combat itself is + // joined on arrival; this only decides whether the march declares war. + const target = this.enemyUnitsAt(goal, unit.civ).find((other) => this._isMilitary(other)) || null; + const attacking = !!target && this._isMilitary(unit); + const path = this.findPath(unit, unit.coords, goal); + if (path.length < 2) { + return false; + } + // Marching a military unit into, or attacking, a country we are at peace + // with declares war on it. The browser confirms the move first, but the + // server enforces the rule so the two can never disagree. + if (this._isMilitary(unit)) { + if (attacking) this._declareWar(unit.civ, target.civ); + for (const coords of path) { + const owner = this.civAt(coords); + if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner); + } + } + unit.path = path; + unit.pathIndex = 0; + unit.progressHours = 0; + unit.moveSpeed = speed; + return true; + }, + + advanceMovement(hours) { + if (hours <= 0) return; + // Units sharing a tile with a hostile military unit are locked in place + // until the daily combat round resolves, so neither side can slip away + // mid-fight. The set grows as units charge in so an ordered swap cannot let + // two enemies march through each other. + const engaged = this._engagedUnits(); + for (const unit of this.units) { + if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) continue; + if (engaged.has(unit)) continue; + let hoursLeft = hours; + while (hoursLeft > 0) { + // Reaching the end of one leg picks up the next scheduled destination + // and recomputes the path from where the unit now stands. + if (!this._hasRoute(unit) && !this._followNextWaypoint(unit)) break; + const next = unit.path[unit.pathIndex + 1]; + const occupants = this.unitsAt(next).filter((other) => other !== unit); + const enemy = occupants.find((other) => other.civ !== unit.civ); + if (enemy && !this._isBlockingEnemy(unit, enemy)) { + // A hostile civilian is an obstacle; route around it, and when the + // destination has become unreachable, abandon the move instead of + // stalling on the spot forever. + if (this._repathAround(unit)) { + const ahead = this.unitsAt(unit.path[unit.pathIndex + 1]) + .find((other) => other !== unit && other.civ !== unit.civ); + if (ahead) break; + continue; + } + this._stopUnit(unit); + break; + } + // A hostile military unit does not stop the charge: combat is fought on + // a single tile, so the unit steps in and the two stacks share it. + // Friendly occupants do not block either: the unit simply stacks. + const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST); + const needed = cost - unit.progressHours; + if (hoursLeft >= needed) { + hoursLeft -= needed; + unit.pathIndex += 1; + unit.progressHours = 0; + unit.coords = next; + this._visibilityDirty = true; + this._onUnitArrived(unit); + if (this._hasHostileOn(unit.coords, unit.civ)) { + // The fight is joined: hold here and lock every participant. The + // unit that charged in is the attacker; the occupants defend. + this._stopUnit(unit); + unit.invader = true; + for (const other of this.unitsAt(unit.coords)) { + if (other.civ !== unit.civ && this._isMilitary(other)) { + other.invader = false; + engaged.add(other); + } + } + engaged.add(unit); + break; + } + } else { + unit.progressHours += hoursLeft; + hoursLeft = 0; + } + } + if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) { + this._stopUnit(unit); + } + } + }, + + // A hostile military unit standing on the next tile is an opponent to fight, + // not an obstacle to route around. + _isBlockingEnemy(unit, occupant) { + return this._isMilitary(unit) && this._isMilitary(occupant) && occupant.civ !== unit.civ; + }, + + // Every unit on the tile, ours or not, that is hostile to `civ`. + _hasHostileOn(coords, civ) { + return this.unitsAt(coords).some((unit) => unit.civ !== civ); + }, + + // Recomputes a route from where the unit stands to the destination it was + // already heading for. Returns false when no route exists any more. + _repathAround(unit) { + if (!unit.path || unit.path.length < 2) return false; + const goal = unit.path[unit.path.length - 1]; + const path = this.findPath(unit, unit.coords, goal); + if (path.length < 2) return false; + unit.path = path; + unit.pathIndex = 0; + unit.progressHours = 0; + return true; + }, + + // Abandons every movement for the unit: no route, no partial step and no + // remaining scheduled destinations. + _stopUnit(unit) { + unit.path = []; + unit.pathIndex = 0; + unit.progressHours = 0; + unit.moveSpeed = null; + unit.waypoints = []; + }, + + // ------------------------------------------------------ travel cost -- + + _isControlledBy(unit, coords) { + return this.civAt(coords) === unit.civ; + }, + + _unitSpeed(unit) { + const proto = this.unitProto(unit); + return Math.max(proto ? proto.speed : DEFAULT_UNIT_SPEED, 0.0001); + }, + + _effectiveSpeedAt(unit, coords) { + // A group move pins every unit to the slowest member's speed. + let speed = unit.moveSpeed || this._unitSpeed(unit); + if (this._isControlledBy(unit, coords)) speed *= CONTROLLED_SPEED_MULTIPLIER; + return speed; + }, + + _tileTravelHours(unit, coords) { + return this.terrainStats.movementCostMultiplier(coords) / this._effectiveSpeedAt(unit, coords); + }, + + _fastestTileTravelHours(unit) { + return this._minMovementCost / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER); + }, + + _heuristic(unit, from, to) { + const delta = this.topology.pixelDelta(from, to); + const tiles = Math.hypot(delta.x, delta.y) / this._maxStepLength; + return tiles * this._fastestTileTravelHours(unit); + }, + + _computeMaxStepLength() { + let longest = 0; + for (const neighbour of this._neighbours({ x: 0, y: 0 })) { + const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour); + longest = Math.max(longest, Math.hypot(delta.x, delta.y)); + } + return longest > 0 ? longest : 1.0; + }, + + // --------------------------------------------------------- pathfinding -- + + // Whether `unit` may occupy `coords`: passable terrain and no hostile unit + // there unless the move is an attack (military into military, which turns the + // step into a fight). Friendly units may share a tile, so they never block. + // Enemy cities are only open to military units, since walking one in + // captures it. + // + // Foreign territory is a wall as well, so a route never cuts through a third + // party and provokes a war for purely technical reasons. The one exception is + // the civilisation being targeted (`targetCiv`, the owner of the goal), whose + // land the route may cross to reach it. + // + // A hostile unit or city is only a valid destination (`isGoal`), never a tile + // to pass through: pathfinding routes around enemies instead of through them. + _canUnitEnter(unit, coords, isGoal = false, targetCiv = -1) { + const proto = this.unitProto(unit); + if (!this.canEnter(unit.civ, proto, coords)) return false; + const owner = this.civAt(coords); + if (!isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false; + const occupants = this.unitsAt(coords).filter((other) => other !== unit); + if (occupants.length > 0) { + const hostile = occupants.filter((other) => other.civ !== unit.civ); + if (hostile.length > 0) { + if (!isGoal) return false; + if (!this._isMilitary(unit)) return false; + if (!hostile.some((other) => this._isMilitary(other))) return false; + } + } + const city = this._cityByCoords.get(key(coords.x, coords.y)); + if (city && city.civ !== unit.civ) { + if (!isGoal) return false; + if (!this._isMilitary(unit)) return false; + } + return true; + }, + + findPath(unit, start, goal) { + const targetCiv = this._targetCivFor(unit, goal); + const enterCache = new Map(); + const costCache = new Map(); + const heuristicCache = new Map(); + return this._pathfinder.findPath( + start, + goal, + (coords) => this._neighbours(coords), + (coords, isGoal) => { + const k = key(coords.x, coords.y) + (isGoal ? ":goal" : ""); + if (!enterCache.has(k)) enterCache.set(k, this._canUnitEnter(unit, coords, isGoal, targetCiv)); + return enterCache.get(k); + }, + (_from, to) => { + const k = key(to.x, to.y); + if (!costCache.has(k)) costCache.set(k, this._tileTravelHours(unit, to)); + return costCache.get(k); + }, + (from, to) => { + const k = key(from.x, from.y); + if (!heuristicCache.has(k)) heuristicCache.set(k, this._heuristic(unit, from, to)); + return heuristicCache.get(k); + } + ); + }, + // The civilisation a route is aimed at: the enemy city or unit on the goal, + // or failing that whoever owns the goal tile. Crossing that civ's land is + // allowed; every other foreign territory is a wall. + _targetCivFor(unit, goal) { + const city = this._cityByCoords.get(key(goal.x, goal.y)); + if (city && city.civ !== unit.civ) return city.civ; + for (const other of this.unitsAt(goal)) { + if (other !== unit && other.civ !== unit.civ) return other.civ; + } + return this.civAt(goal); + }, +}; diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js new file mode 100644 index 0000000..5e8de6e --- /dev/null +++ b/shared/game_state/orders.js @@ -0,0 +1,164 @@ +// Player-issued orders that are not movement: training, building, government, +// research and pillaging. + +import { key } from "../hex.js"; +import { ECONOMY } from "../data/economy.js"; +import { GOVERNMENTS } from "../data/governments.js"; +import { PILLAGE, pillageSteps } from "../data/combat.js"; +import { buildingBuildCost, buildingRefund } from "../rules.js"; +import { TRAINING_QUEUE_LIMIT } from "./constants.js"; + +export const orderMethods = { + // Adds one unit to the city's training queue. The queue holds at most + // TRAINING_QUEUE_LIMIT units; the first entry is built while the rest wait. + requestTrain(cityId, protoIndex) { + const city = this.findCity(cityId); + if (!city) return false; + if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false; + const proto = this.protoUnits[protoIndex]; + const queue = this.training.get(cityId); + if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false; + const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ)); + if (capacity <= 0 || proto.cost <= 0) { + this._spawnUnit(city.coords, city.civ, proto); + this._visibilityDirty = true; + this._emitChanged(); + return true; + } + const entry = { + protoIndex, + capacity, + elapsedHours: 0, + totalHours: proto.cost / capacity, + }; + if (queue) queue.push(entry); + else this.training.set(cityId, [entry]); + this._emitChanged(); + return true; + }, + + // Cancels the queued unit at `index` in the city's training queue. Nothing is + // refunded: the time already spent on it is lost. + cancelTrain(cityId, index) { + const queue = this.training.get(cityId); + if (!queue) return false; + const at = Number(index); + if (!Number.isInteger(at) || at < 0 || at >= queue.length) return false; + queue.splice(at, 1); + if (queue.length === 0) this.training.delete(cityId); + this._emitChanged(); + return true; + }, + + requestBuild(cityId, protoIndex) { + const city = this.findCity(cityId); + if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; + const proto = this.protoBuildings[protoIndex]; + const civ = city.civ; + const level = this.getCityBuildingLevel(city, protoIndex); + if (level >= proto.maxLevel) return false; + const gdp = this.getPlayerGdp(civ); + const cost = buildingBuildCost(proto, gdp, level); + if (cost > 0 && this.getBudget(civ) < cost) return false; + if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost); + city.buildings[protoIndex] = level + 1; + this._emitChanged(); + return true; + }, + + requestDemolish(cityId, protoIndex) { + const city = this.findCity(cityId); + if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; + const proto = this.protoBuildings[protoIndex]; + const civ = city.civ; + const level = this.getCityBuildingLevel(city, protoIndex); + if (level <= 0) return false; + const refund = buildingRefund(proto, this.getPlayerGdp(civ), level); + if (refund > 0) this.budgets.set(civ, this.getBudget(civ) + refund); + if (level <= 1) delete city.buildings[protoIndex]; + else city.buildings[protoIndex] = level - 1; + this._emitChanged(); + return true; + }, + + requestSetGovernment(civ, index) { + if (civ < 0 || civ >= this.civilisations.length) return false; + if (index < 0 || index >= this.governments.length) return false; + if (this.getGovernment(civ) === index) return false; + const cost = GOVERNMENTS[index].baseCost + + GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0); + if (cost > 0 && this.getBudget(civ) < cost) return false; + if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost); + this.government.set(civ, index); + this._emitChanged(); + return true; + }, + + requestResearchTechnology(civ, index) { + if (!this.canResearchTechnology(civ, index)) return false; + const technology = this.technologies[index]; + if (this.getResearch(civ) < technology.cost) return false; + this.research.set(civ, this.getResearch(civ) - technology.cost); + this.researched.get(civ).add(index); + this._emitChanged(); + return true; + }, + + // Whether the units may pillage the tile they stand on: all of them are on + // one hostile tile where no battle is raging. + canPillage(unitIds) { + const units = this._unitsForOrder(unitIds); + if (units.length === 0) return false; + const coords = units[0].coords; + const owner = this.civAt(coords); + if (owner < 0 || owner === units[0].civ) return false; + if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) { + return false; + } + return !this._battleTiles().some( + (battle) => battle.coords.x === coords.x && battle.coords.y === coords.y + ); + }, + + // Pillages the tile the units stand on. Every unit in the stack takes a turn, + // the weakest first (least HP relative to maximum): each removes 10% of the + // remaining population, cuts the tile's GDP per capita by 30% and heals one + // hit point per 1000 people it drives off. + requestPillage(unitIds) { + const units = this._unitsForOrder(unitIds); + if (!this.canPillage(unitIds)) return false; + const coords = units[0].coords; + const owner = this.civAt(coords); + const k = key(coords.x, coords.y); + const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp); + let population = this.tilePopulation.get(k) || 0; + const { steps } = pillageSteps(population, ordered.length); + let penalty = this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1; + let drivenOff = 0; + for (let i = 0; i < ordered.length; i++) { + penalty *= PILLAGE.gdpPenalty; + const unit = ordered[i]; + unit.hp = Math.min(unit.maxHp, unit.hp + steps[i].heal); + drivenOff += steps[i].removed; + } + population = steps.length > 0 ? steps[steps.length - 1].remaining : population; + this.tilePopulation.set(k, population); + this.tileGdpPenalty.set(k, penalty); + this._gdpPerCapitaCache.delete(k); + // The people driven off the tile are civilians lost to its owner in the war + // against the pillaging civilisation. + if (owner >= 0) this._recordCasualties(owner, units[0].civ, "civilians", drivenOff); + this._emitChanged(); + return true; + }, + + _unitsForOrder(unitIds) { + const ids = Array.isArray(unitIds) ? unitIds : [unitIds]; + const units = []; + for (const id of ids) { + const unit = this.findUnit(Number(id)); + if (unit) units.push(unit); + } + return units; + }, +}; diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js new file mode 100644 index 0000000..eccccf7 --- /dev/null +++ b/shared/game_state/serialization.js @@ -0,0 +1,244 @@ +// Serialisation: turning the live model into the snapshots the server ships to +// each viewer, with fog-of-war applied per civilisation. + +import { key, parseKey } from "../hex.js"; +import { serializeSet } from "./helpers.js"; + +export const serializationMethods = { + snapshot(viewerCiv = -1) { + return this.viewerSnapshot(this.serializeShared(), viewerCiv); + }, + + serializeShared() { + return { + seed: this.seed, + totalHours: this.totalHours, + civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })), + protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })), + buildings: this.protoBuildings.map((b) => ({ id: b.id, name: b.name })), + governments: this.governments.map((g) => ({ id: g.id, name: g.name })), + technologies: this.technologies.map((t) => ({ id: t.id, name: t.name })), + cities: this._serializeCities(), + units: this._serializeUnits(), + territory: this._serializedTerritory, + population: this._serializePopulation(), + civStats: this._serializeCivStats(), + budgets: this._serializeCivFloats(this.budgets), + research: this._serializeCivFloats(this.research), + culture: this._serializeCivFloats(this.culture), + government: this._serializeCivInts(this.government), + researched: this._serializeResearched(), + diplomacy: this._serializeDiplomacy(), + conflicts: this._serializeConflicts(), + news: this._serializeNews(), + battles: this._serializeBattles(), + training: Object.fromEntries( + Array.from(this.training.entries()).map(([cityId, queue]) => [ + cityId, + queue.map((entry) => ({ ...entry })), + ]) + ), + }; + }, + + viewerSnapshot(shared, viewerCiv, stats = null) { + const result = { ...shared }; + result.viewer = viewerCiv; + result.viewerStats = stats || this.viewerStats(viewerCiv); + result.explored = this._cachedExplored(viewerCiv); + result.visible = serializeSet(this.visible.get(viewerCiv) || new Set()); + return result; + }, + + viewerStats(viewerCiv) { + if (viewerCiv < 0) { + return { + population: 0, gdp: 0, gdpPerCapita: 0, budget: 0, + upkeep: 0, research: 0, culture: 0, government: 0, + breakdown: null, + }; + } + const aggregates = this._playerAggregates(viewerCiv); + const population = Math.round(aggregates.population); + const gdp = aggregates.gdp; + return { + population, + gdp, + gdpPerCapita: population > 0 ? gdp / population : 0, + budget: this.getBudget(viewerCiv), + upkeep: this.getPlayerUpkeep(viewerCiv, gdp), + research: this.getResearch(viewerCiv), + culture: this.getCulture(viewerCiv), + government: this.getGovernment(viewerCiv), + breakdown: this.budgetBreakdown(viewerCiv), + }; + }, + + _serializeCities() { + return this.cities.map((city) => ({ + id: city.id, + civ: city.civ, + coords: [city.coords.x, city.coords.y], + name: city.name, + isCapital: city.isCapital, + population: city.population, + improvements: city.improvements, + buildings: { ...city.buildings }, + statuses: city.statuses || [], + })); + }, + + _serializeUnits() { + const battle = this._battleUnitIds(); + return this.units.map((unit) => { + const rawPath = unit.path || []; + const pathIndex = unit.pathIndex; + const path = rawPath.map((coords) => [coords.x, coords.y]); + const segmentHours = []; + for (let i = 1; i < rawPath.length; i++) { + segmentHours.push(this._tileTravelHours(unit, rawPath[i])); + } + let stepHours = 0; + if (pathIndex + 1 < rawPath.length) { + stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]); + } + return { + id: unit.id, + civ: unit.civ, + proto: unit.proto, + coords: [unit.coords.x, unit.coords.y], + hp: unit.hp, + maxHp: unit.maxHp, + statuses: this._effectiveStatuses(unit, battle.has(unit.id)), + path, + pathIndex, + progressHours: unit.progressHours, + stepHours, + segmentHours, + waypoints: (unit.waypoints || []).map((coords) => [coords.x, coords.y]), + }; + }); + }, + + _serializePopulation() { + const result = []; + for (const [k, value] of this.tilePopulation) { + const coords = parseKey(k); + result.push([coords.x, coords.y, Math.round(value)]); + } + return result; + }, + + // One entry per civilisation with its headline economy figures, so the + // diplomacy panel can show every nation's standing without a second lookup. + _serializeCivStats() { + return this.civilisations.map((civ, index) => { + const aggregates = this._playerAggregates(index); + const population = Math.round(aggregates.population); + const gdp = aggregates.gdp; + return { + population, + gdp, + gdpPerCapita: population > 0 ? gdp / population : 0, + }; + }); + }, + + _serializeCivFloats(values) { + const result = []; + for (let i = 0; i < this.civilisations.length; i++) { + result.push(values.has(i) ? values.get(i) : 0); + } + return result; + }, + + _serializeCivInts(values) { + const result = []; + for (let i = 0; i < this.civilisations.length; i++) { + result.push(values.has(i) ? values.get(i) : 0); + } + return result; + }, + + _serializeResearched() { + const result = []; + for (let i = 0; i < this.civilisations.length; i++) { + const set = this.researched.get(i) || new Set(); + const indices = Array.from(set).sort((a, b) => a - b); + result.push(indices); + } + return result; + }, + + // Every unordered pair with its current status. With a handful of + // civilisations the whole matrix is tiny, and it lets the client show the + // status of every nation without a second lookup. + _serializeDiplomacy() { + const result = []; + for (let a = 0; a < this.civilisations.length; a++) { + for (let b = a + 1; b < this.civilisations.length; b++) { + result.push([a, b, this.getRelation(a, b)]); + } + } + return result; + }, + + // Every ongoing war with its start date and the casualties each side has + // suffered, broken down into military and civilian dead. + _serializeConflicts() { + const result = []; + for (const conflict of this.conflicts.values()) { + result.push({ + a: conflict.a, + b: conflict.b, + started: conflict.startDate, + startHours: conflict.startHours, + sides: [conflict.a, conflict.b].map((civ) => ({ + civ, + military: conflict.military.get(civ) || 0, + civilians: Math.round(conflict.civilians.get(civ) || 0), + })), + }); + } + return result; + }, + + _serializeNews() { + return this.news.map((entry) => ({ ...entry })); + }, + + // One entry per battlefield: [x, y, defenderCiv, attackerCiv]. The tile's + // owner is the defender when present; otherwise the lowest-numbered civ on + // the tile defends. The browser uses this to draw the two stacks facing each + // other with the battle icon between them. + _serializeBattles() { + const result = []; + for (const battle of this._battleTiles()) { + const civs = Array.from(battle.civs).sort((a, b) => a - b); + const owner = this.civAt(battle.coords); + const defender = this._defenderCiv(battle, civs, owner); + const attacker = civs.find((civ) => civ !== defender); + if (attacker === undefined) continue; + result.push([battle.coords.x, battle.coords.y, defender, attacker]); + } + return result; + }, + + // The side that charged in is the attacker; the occupants defend. When that is + // ambiguous (a mutual charge, or a third party) fall back to the tile's owner + // and then to the lowest-numbered civ present. + _defenderCiv(battle, civs, owner) { + const invaders = new Set(); + let hasDefender = false; + for (const unit of battle.units) { + if (unit.invader) invaders.add(unit.civ); + else hasDefender = true; + } + const defenders = civs.filter((civ) => !invaders.has(civ)); + if (invaders.size > 0 && hasDefender && defenders.length > 0) { + if (defenders.length === 1) return defenders[0]; + return defenders.includes(owner) ? owner : defenders[0]; + } + return civs.includes(owner) ? owner : civs[0]; + }, +}; diff --git a/shared/game_state/statuses.js b/shared/game_state/statuses.js new file mode 100644 index 0000000..69e3e34 --- /dev/null +++ b/shared/game_state/statuses.js @@ -0,0 +1,165 @@ +// Supply: encirclement and siege detection, including the maritime exception +// that keeps a coastal city supplied through allied naval units. + +import { key } from "../hex.js"; +import { STATUS_ENCIRCLED, STATUS_BESIEGED } from "../data/statuses.js"; + +export const statusMethods = { + // Recomputes the status of every unit and city. The supply search only runs + // for an occupant that has a hostile unit adjacent, and only once a day (and + // at setup), so it never touches the snapshot path. + _refreshStatuses() { + const components = new Map(); + for (const unit of this.units) { + if (!this._hasAdjacentHostile(unit.coords, unit.civ)) { + unit.statuses = []; + continue; + } + unit.statuses = this._unitStatuses(unit, this._componentsFor(components, unit.civ)); + } + for (const city of this.cities) { + if (!this._hasAdjacentHostile(city.coords, city.civ)) { + city.statuses = []; + continue; + } + city.statuses = this._cityStatuses(city, this._componentsFor(components, city.civ)); + } + }, + + // The trigger: encirclement is only evaluated when a hostile unit is next to + // the tile. + _hasAdjacentHostile(coords, civ) { + for (const neighbour of this._neighbours(coords)) { + if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) return true; + } + return false; + }, + + _componentsFor(cache, civ) { + if (!cache.has(civ)) cache.set(civ, this._landComponents(civ)); + return cache.get(civ); + }, + + _isNaval(unit) { + const proto = this.unitProto(unit); + return !!(proto && proto.traversableTerrains.includes("Sea")); + }, + + // Land connected components for one civ, with enemy-occupied and enemy-held + // city tiles acting as walls. Each component remembers how many of the civ's + // cities it contains, which is all the supply check needs. + _landComponents(civ) { + const byTile = new Map(); + const cityCountByComp = new Map(); + const totalCities = this.cities.filter((city) => city.civ === civ).length; + let component = 0; + for (const start of this.landCells) { + const startKey = key(start.x, start.y); + if (byTile.has(startKey) || !this._landPassable(start, civ)) continue; + const id = component++; + byTile.set(startKey, id); + let cities = 0; + let frontier = [start]; + while (frontier.length > 0) { + const next = []; + for (const coords of frontier) { + const city = this.cityAt(coords); + if (city && city.civ === civ) cities += 1; + for (const neighbour of this._neighbours(coords)) { + const nk = key(neighbour.x, neighbour.y); + if (byTile.has(nk) || !this._landPassable(neighbour, civ)) continue; + byTile.set(nk, id); + next.push(neighbour); + } + } + frontier = next; + } + cityCountByComp.set(id, cities); + } + return { byTile, cityCountByComp, totalCities }; + }, + + _landPassable(coords, civ) { + const tile = this.tiles[key(coords.x, coords.y)]; + if (!tile || tile.terrainClass !== "Land") return false; + if (this.unitsAt(coords).some((unit) => unit.civ !== civ)) return false; + const city = this.cityAt(coords); + if (city && city.civ !== civ) return false; + return true; + }, + + // A city is besieged when it no longer shares a land component with any + // other city of its civ. A lone city is never besieged. + _isCityBesieged(city, info) { + if (info.totalCities < 2) return false; + const component = info.byTile.get(key(city.coords.x, city.coords.y)); + const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0); + return count <= 1; + }, + + _unitStatuses(unit, info) { + const city = this.cityAt(unit.coords); + const inOwnCity = city && city.civ === unit.civ; + let encircled; + if (inOwnCity) { + encircled = this._isCityBesieged(city, info); + } else { + const component = info.byTile.get(key(unit.coords.x, unit.coords.y)); + const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0); + encircled = count === 0; + } + const excludeCity = inOwnCity ? city.id : 0; + if (encircled && this._hasMaritimeRoute(unit.coords, unit.civ, excludeCity)) { + encircled = false; + } + return encircled ? [STATUS_ENCIRCLED] : []; + }, + + _cityStatuses(city, info) { + const besieged = this._isCityBesieged(city, info) && + !this._hasMaritimeRoute(city.coords, city.civ, city.id); + return besieged ? [STATUS_BESIEGED] : []; + }, + + // The maritime exception: with an allied naval unit on an adjacent sea tile, + // a lane is traced across water to a different coastal allied city. Enemy + // units on the water block it. + _hasMaritimeRoute(origin, civ, excludeCityId) { + const visited = new Set(); + let frontier = []; + for (const neighbour of this._neighbours(origin)) { + const tile = this.tiles[key(neighbour.x, neighbour.y)]; + if (!tile || tile.terrainClass !== "Sea") continue; + if (!this.unitsAt(neighbour).some((unit) => unit.civ === civ && this._isNaval(unit))) continue; + const nk = key(neighbour.x, neighbour.y); + if (visited.has(nk)) continue; + visited.add(nk); + frontier.push(neighbour); + } + while (frontier.length > 0) { + const next = []; + for (const coords of frontier) { + for (const neighbour of this._neighbours(coords)) { + const city = this.cityAt(neighbour); + if (city && city.civ === civ && city.id !== excludeCityId) return true; + } + for (const neighbour of this._neighbours(coords)) { + const tile = this.tiles[key(neighbour.x, neighbour.y)]; + if (!tile || tile.terrainClass !== "Sea") continue; + const nk = key(neighbour.x, neighbour.y); + if (visited.has(nk)) continue; + if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) continue; + visited.add(nk); + next.push(neighbour); + } + } + frontier = next; + } + return false; + }, + + _statusHpLossPerDay(def, inCity) { + if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay; + return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0; + }, +}; diff --git a/shared/game_state/territory.js b/shared/game_state/territory.js new file mode 100644 index 0000000..8397f16 --- /dev/null +++ b/shared/game_state/territory.js @@ -0,0 +1,147 @@ +// Territory: which civilisation owns each land tile, how ownership spreads +// from cities, and how it is redrawn when a city changes hands. + +import { key, parseKey } from "../hex.js"; +import { NEWS_CAPTURE } from "../data/relations.js"; +import { serializeCells } from "./helpers.js"; + +export const territoryMethods = { + civAt(coords) { + const k = key(coords.x, coords.y); + return this.territory.has(k) ? this.territory.get(k) : -1; + }, + + terrainTypeAt(coords) { + const tile = this.tiles[key(coords.x, coords.y)]; + return tile ? tile.terrainType : "Unknown"; + }, + + getTilePopulation(coords) { + return Math.round(this.tilePopulation.get(key(coords.x, coords.y)) || 0); + }, + + getTileInfo(coords) { + const city = this.cityAt(coords); + return { + coords, + owner: this.civAt(coords), + terrainType: this.terrainTypeAt(coords), + population: this.getTilePopulation(coords), + improvements: city ? city.improvements : [], + }; + }, + + _recomputeTerritory() { + this.territory = new Map(); + this._gdpPerCapitaCache = new Map(); + const ownerByCell = new Map(); + let frontier = []; + for (const city of this.cities) { + const k = key(city.coords.x, city.coords.y); + ownerByCell.set(k, city.civ); + this.territory.set(k, city.civ); + frontier.push(city.coords); + } + while (frontier.length > 0) { + const next = []; + for (const coords of frontier) { + const owner = ownerByCell.get(key(coords.x, coords.y)); + for (const neighbour of this._neighbours(coords)) { + const nk = key(neighbour.x, neighbour.y); + if (ownerByCell.has(nk) || !this._isLand(neighbour)) continue; + ownerByCell.set(nk, owner); + this.territory.set(nk, owner); + next.push(neighbour); + } + } + frontier = next; + } + this._territoryByCiv = new Map(); + for (const [k, owner] of this.territory) { + if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []); + this._territoryByCiv.get(owner).push(parseKey(k)); + } + this._serializedTerritory = serializeCells(this.territory); + }, + + // Rebuilds the owner index, the serialised territory and the GDP-per-capita + // cache after `this.territory` changes outside a full recompute. + _reindexTerritory() { + this._territoryByCiv = new Map(); + for (const [k, owner] of this.territory) { + if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []); + this._territoryByCiv.get(owner).push(parseKey(k)); + } + this._serializedTerritory = serializeCells(this.territory); + this._gdpPerCapitaCache = new Map(); + }, + + // Transfers `city` to `newCiv` and, with it, every tile of the old owner's + // territory that is strictly closer (through land) to the captured city than + // to any of their remaining cities. Ties stay with the old owner. + _captureCity(city, newCiv) { + const oldCiv = city.civ; + if (oldCiv === newCiv) return false; + const region = (this._territoryByCiv.get(oldCiv) || []).slice(); + const claim = this._nearestCityOwner(city, oldCiv, newCiv); + city.civ = newCiv; + for (const coords of region) { + const k = key(coords.x, coords.y); + if (claim.get(k) === newCiv) this.territory.set(k, newCiv); + } + // The captured city's own tile always changes hands, ties or not. + this.territory.set(key(city.coords.x, city.coords.y), newCiv); + this.training.delete(city.id); + this._reindexTerritory(); + this._rebuildCityIndex(); + this._visibilityDirty = true; + this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv }); + this._emitChanged(); + return true; + }, + + // Multi-source BFS over land from the captured city and every remaining city + // of the old owner. Each tile records the civ of the nearest city; a tile + // reached at the same distance from both sides is credited to the old owner. + _nearestCityOwner(captured, oldCiv, newCiv) { + const claim = new Map(); + const visited = new Set(); + let frontier = []; + const sources = [{ coords: captured.coords, civ: newCiv }]; + for (const city of this.cities) { + if (city !== captured && city.civ === oldCiv) { + sources.push({ coords: city.coords, civ: oldCiv }); + } + } + for (const source of sources) { + const k = key(source.coords.x, source.coords.y); + if (visited.has(k)) continue; + visited.add(k); + claim.set(k, source.civ); + frontier.push(source.coords); + } + while (frontier.length > 0) { + const nextFrontier = []; + const nextClaim = new Map(); + for (const coords of frontier) { + const civ = claim.get(key(coords.x, coords.y)); + for (const neighbour of this._neighbours(coords)) { + const nk = key(neighbour.x, neighbour.y); + if (visited.has(nk) || !this._isLand(neighbour)) continue; + if (nextClaim.has(nk)) { + if (nextClaim.get(nk) === newCiv && civ === oldCiv) nextClaim.set(nk, oldCiv); + continue; + } + nextClaim.set(nk, civ); + nextFrontier.push(neighbour); + } + } + for (const [k, civ] of nextClaim) { + claim.set(k, civ); + visited.add(k); + } + frontier = nextFrontier; + } + return claim; + }, +}; diff --git a/shared/game_state/visibility.js b/shared/game_state/visibility.js new file mode 100644 index 0000000..bc83edd --- /dev/null +++ b/shared/game_state/visibility.js @@ -0,0 +1,94 @@ +// Fog of war and the grid walkers. Explored cells stay known; visible cells +// are the ones currently watched by territory and units. + +import { key } from "../hex.js"; +import { UNIT_VISION, TERRITORY_VISION } from "./constants.js"; +import { serializeSet } from "./helpers.js"; + +export const visibilityMethods = { + isExplored(civ, coords) { + const set = this.explored.get(civ); + return set ? set.has(key(coords.x, coords.y)) : false; + }, + + isVisible(civ, coords) { + const set = this.visible.get(civ); + return set ? set.has(key(coords.x, coords.y)) : false; + }, + + canEnter(civ, proto, coords) { + if (!proto || !this.isExplored(civ, coords)) return false; + const tile = this.tiles[key(coords.x, coords.y)]; + if (!tile) return false; + return proto.traversableTerrains.includes(tile.terrainClass); + }, + + _refreshVisibility() { + this.visible = new Map(); + for (let i = 0; i < this.civilisations.length; i++) this.visible.set(i, new Set()); + for (const [civ, cells] of this._territoryByCiv) { + for (const coords of cells) this._reveal(civ, coords, TERRITORY_VISION); + } + for (const unit of this.units) { + this._reveal(unit.civ, unit.coords, UNIT_VISION); + } + }, + + _revealAll() { + for (let i = 0; i < this.civilisations.length; i++) { + this.explored.set(i, new Set(Object.keys(this.tiles))); + } + }, + + _reveal(civ, center, radius) { + if (!this.explored.has(civ)) this.explored.set(civ, new Set()); + if (!this.visible.has(civ)) this.visible.set(civ, new Set()); + const exploredCells = this.explored.get(civ); + const visibleCells = this.visible.get(civ); + let discovered = false; + for (const coords of this._cellsInRadius(center, radius)) { + const k = key(coords.x, coords.y); + if (!exploredCells.has(k)) { + exploredCells.add(k); + discovered = true; + } + visibleCells.add(k); + } + if (discovered) this._serializedExplored.delete(civ); + }, + + _cachedExplored(civ) { + if (!this._serializedExplored.has(civ)) { + this._serializedExplored.set(civ, serializeSet(this.explored.get(civ) || new Set())); + } + return this._serializedExplored.get(civ); + }, + + _cellsInRadius(center, radius) { + const result = [center]; + const visited = new Set([key(center.x, center.y)]); + let frontier = [center]; + for (let step = 0; step < radius; step++) { + const next = []; + for (const coords of frontier) { + for (const neighbour of this._neighbours(coords)) { + const k = key(neighbour.x, neighbour.y); + if (visited.has(k)) continue; + visited.add(k); + next.push(neighbour); + result.push(neighbour); + } + } + frontier = next; + } + return result; + }, + + _neighbours(coords) { + const k = key(coords.x, coords.y); + if (this._neighbourCache.has(k)) return this._neighbourCache.get(k); + const result = this.topology.neighbours(coords.x, coords.y); + this._neighbourCache.set(k, result); + return result; + }, +}; diff --git a/shared/game_state/world.js b/shared/game_state/world.js new file mode 100644 index 0000000..e518d48 --- /dev/null +++ b/shared/game_state/world.js @@ -0,0 +1,192 @@ +// World generation and settlement placement: laying out capitals and cities, +// spawning their starting units, seeding population and budgets, and the small +// terrain predicates the rest of the model leans on. + +import { ECONOMY } from "../data/economy.js"; +import { Random } from "../rng.js"; +import { key, parseKey } from "../hex.js"; + +export const worldMethods = { + _placeSettlements() { + const capitals = this._placeCapitals(this.civilisations.length); + for (const site of this._placeCities(capitals)) this._spawnCity(site); + this._rebuildCityIndex(); + for (let i = 0; i < Math.min(this.civilisations.length, capitals.length); i++) { + this._spawnUnit(capitals[i], i, this.protoUnits[0]); + } + this._recomputeTerritory(); + this._generatePopulation(); + this._initBudgets(); + this._balanceStartingEconomy(); + }, + + _placeCapitals(count) { + const rng = new Random(this.seed); + return this._generator.pickCapitals(count, rng); + }, + + _placeCities(capitals) { + return this._generator.pickCities(capitals, this.mapConfig.citiesPerCiv, this.mapConfig.citySpacing); + }, + + _spawnCity(site) { + const civIndex = site.civIndex; + if (civIndex < 0 || civIndex >= this.civilisations.length) return; + const civ = this.civilisations[civIndex]; + const coords = site.coords; + const isCapital = site.isCapital; + const description = this._cityDescriptionFor(civ, isCapital, coords); + const city = { + id: this._nextCityId, + civ: civIndex, + coords, + name: description.name, + isCapital, + population: description.population, + improvements: description.improvements.slice(), + buildings: {}, + statuses: [], + }; + this.cities.push(city); + this._cityById.set(this._nextCityId, city); + this._nextCityId += 1; + }, + + _cityDescriptionFor(civ, _isCapital, center) { + return this._generatedCityDescription(civ, center); + }, + + _rebuildCityIndex() { + this._cityByCoords = new Map(); + this._cityAdjacent = new Map(); + for (const city of this.cities) { + this._cityByCoords.set(key(city.coords.x, city.coords.y), city); + const owner = city.civ; + if (!this._cityAdjacent.has(owner)) this._cityAdjacent.set(owner, new Set()); + const adjacent = this._cityAdjacent.get(owner); + for (const neighbour of this._neighbours(city.coords)) { + adjacent.add(key(neighbour.x, neighbour.y)); + } + } + }, + + _generatedCityDescription(civ, center) { + const index = this.civilisations.indexOf(civ); + const name = this._nextCityName(civ, index); + const population = 500000 + + (Math.abs((center.x * 92837111) ^ (center.y * 689287499)) % 3500000); + return { name, population, improvements: [] }; + }, + + // Picks the civilisation's next city name, skipping any a city elsewhere has + // already taken, and falls back to a numbered name so the whole world never + // has two cities called the same thing. + _nextCityName(civ, index) { + const start = this._cityNamesUsed.get(index) || 0; + for (let i = start; i < civ.cityNames.length; i++) { + const candidate = civ.cityNames[i]; + if (this._usedCityNames.has(candidate)) continue; + this._cityNamesUsed.set(index, i + 1); + this._usedCityNames.add(candidate); + return candidate; + } + let suffix = Math.max(start, civ.cityNames.length) + 1; + let candidate = `${civ.name} ${suffix}`; + while (this._usedCityNames.has(candidate)) { + suffix += 1; + candidate = `${civ.name} ${suffix}`; + } + this._cityNamesUsed.set(index, suffix); + this._usedCityNames.add(candidate); + return candidate; + }, + + _spawnUnit(center, civIndex, proto) { + let index = this.protoUnits.indexOf(proto); + if (index < 0) { + this.protoUnits.push(proto); + index = this.protoUnits.length - 1; + } + const unit = { + id: this._nextUnitId, + civ: civIndex, + proto: index, + coords: center, + hp: proto.maxHp, + maxHp: proto.maxHp, + path: [], + pathIndex: 0, + progressHours: 0, + moveSpeed: null, + waypoints: [], + invader: false, + statuses: [], + }; + this.units.push(unit); + this._unitById.set(this._nextUnitId, unit); + this._nextUnitId += 1; + return unit; + }, + + _generatePopulation() { + this.tilePopulation = new Map(); + for (const k in this.tiles) { + if (this._isLand(parseKey(k))) this.tilePopulation.set(k, this._basePopulation(parseKey(k))); + } + for (const city of this.cities) { + this.tilePopulation.set(key(city.coords.x, city.coords.y), city.population); + } + }, + + _basePopulation(coords) { + return Math.round(ECONOMY.basePopulation * this.terrainStats.populationMultiplier(coords)); + }, + + _initBudgets() { + for (let i = 0; i < this.civilisations.length; i++) { + this.budgets.set(i, ECONOMY.startingBudget); + } + }, + + // Map generation should not hand one civilisation a richer start than + // another. Terrain makes the raw territories unequal, so after the world is + // populated we scale each civ's population to the common mean and then its + // GDP per capita to the mean of the raw per-capita figures. Total population + // and total GDP are preserved; from here on the factors are constants and + // terrain, buildings, technology and war move the numbers normally. + _balanceStartingEconomy() { + const count = this.civilisations.length; + this._populationBalance = new Map(); + this._perCapitaBalance = new Map(); + if (count <= 1) return; + const bases = []; + let totalPopulation = 0; + let totalGdp = 0; + for (let i = 0; i < count; i++) { + const base = this._basePlayerAggregates(i); + bases.push(base); + totalPopulation += base.population; + totalGdp += base.gdp; + } + if (totalPopulation <= 0) return; + const targetPopulation = totalPopulation / count; + const targetPerCapita = totalGdp / totalPopulation; + for (let i = 0; i < count; i++) { + const base = bases[i]; + if (base.population <= 0) continue; + const perCapita = base.gdp / base.population; + this._populationBalance.set(i, targetPopulation / base.population); + this._perCapitaBalance.set(i, perCapita > 0 ? targetPerCapita / perCapita : 1); + } + }, + + _isLand(coords) { + const tile = this.tiles[key(coords.x, coords.y)]; + return !!tile && tile.terrainClass === "Land"; + }, + + _isNextToCity(coords, owner) { + const adjacent = this._cityAdjacent.get(owner); + return adjacent ? adjacent.has(key(coords.x, coords.y)) : false; + }, +}; diff --git a/tests/combat_test.js b/tests/combat_test.js new file mode 100644 index 0000000..a504b11 --- /dev/null +++ b/tests/combat_test.js @@ -0,0 +1,767 @@ +import { TestCase } from "./framework/test_case.js"; +import { setupDom, teardownDom } from "./framework/dom.js"; +import { smallState, defaultState, mapLayers } from "./framework/helpers.js"; +import { key } from "../shared/hex.js"; +import { PROTO_UNITS, CIVILISATIONS, STATUS_BATTLE, BATTLE } from "../shared/data.js"; +import { MapView } from "../client/js/map_view.js"; + +const DAY = 24; + +export class CombatTest extends TestCase { + test_military_units_carry_combat_stats() { + const infantry = PROTO_UNITS.find((u) => u.id === "modern_infantry"); + const worker = PROTO_UNITS.find((u) => u.id === "worker"); + this.assertTrue(infantry.military, "infantry is military"); + this.assertGreater(infantry.attack, 0, "infantry attacks"); + this.assertGreater(infantry.defense, 0, "infantry defends"); + this.assertFalse(worker.military, "worker is not military"); + } + + test_an_attacker_charges_onto_the_defender_tile() { + const state = smallState(); + const { attacker, defender, a, b } = engagement(state); + this.assertEqual(attacker.coords, b, "the attacker joins the defender's tile"); + this.assertEqual(defender.coords, b, "the defender holds its tile"); + this.assertSize(state.unitsAt(b), 2, "the two stacks share the tile"); + this.assertEqual(state._combatTarget(attacker), defender); + this.assertEqual(state._combatTarget(defender), attacker); + this.assertNotEqual(a, b); + + const battles = state.snapshot(0).battles; + this.assertSize(battles, 1, "one battlefield is reported"); + this.assertEqual(battles[0].slice(0, 2), [b.x, b.y], "it is on the contested tile"); + this.assertEqual(battles[0][3], 0, "the charging civ is the attacker"); + this.assertEqual(battles[0][2], 1, "the occupant defends"); + } + + test_pathfinding_rejects_friendly_and_non_military_targets() { + const state = smallState(); + const pair = findEmptyLandPair(state); + const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + + // Friendly units may stack on the same tile. + const friend = state._spawnUnit(pair.b, 0, state.protoUnits[0]); + this.assertTrue(state._canUnitEnter(mover, pair.b), "a friendly unit does not block"); + this.assertTrue(state.requestMove(mover.id, pair.b), "a unit can join a friendly stack"); + state.advanceMovement(1000); + this.assertEqual(state.unitsAt(pair.b).length, 2, "both units share the tile"); + + // An enemy worker blocks too: military only fights military. + const other = findAnotherEmptyLandPair(state, pair); + this.assertNotNull(other, "found a second empty pair"); + const enemyWorker = state._spawnUnit(other.b, 1, state.protoUnits[1]); + const secondMover = state._spawnUnit(other.a, 0, state.protoUnits[0]); + this.assertFalse(state.requestMove(secondMover.id, other.b), "no move onto a worker"); + this.assertFalse(state._canUnitEnter(secondMover, other.b)); + this.assertTrue(state.isMilitary(friend)); + this.assertFalse(state.isMilitary(enemyWorker)); + } + + test_hostile_tiles_are_only_entered_as_the_goal() { + const state = smallState(); + const pair = findEmptyLandPair(state); + const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + state._spawnUnit(pair.b, 1, state.protoUnits[0]); + this.assertFalse( + state._canUnitEnter(mover, pair.b, false), + "an enemy unit is avoided in transit" + ); + this.assertTrue( + state._canUnitEnter(mover, pair.b, true), + "an enemy unit is allowed as the goal" + ); + + const city = state.cities.find((c) => c.civ === 1 && !state.unitAt(c.coords)); + this.assertNotNull(city, "found an undefended enemy city"); + this.assertFalse( + state._canUnitEnter(mover, city.coords, false), + "an enemy city is avoided in transit" + ); + this.assertTrue( + state._canUnitEnter(mover, city.coords, true), + "an enemy city is allowed as the goal" + ); + } + + test_charging_does_not_tick_damage_before_the_daily_round() { + const state = smallState(); + const { attacker, defender, b } = engagement(state); + const attackerHp = attacker.hp; + const defenderHp = defender.hp; + state.advanceMovement(48); + this.assertEqual(attacker.coords, b, "the attacker is on the battlefield"); + this.assertEqual(defender.coords, b, "the defender cannot slip away"); + this.assertEqual(attacker.hp, attackerHp, "no damage before the daily round"); + this.assertEqual(defender.hp, defenderHp, "no damage before the daily round"); + } + + test_fighting_units_carry_the_battle_status() { + const state = smallState(); + const { attacker, defender } = engagement(state); + const units = state.snapshot(0).units; + const attackerData = units.find((u) => u.id === attacker.id); + const defenderData = units.find((u) => u.id === defender.id); + this.assertTrue(attackerData.statuses.includes(STATUS_BATTLE), "the attacker is in battle"); + this.assertTrue(defenderData.statuses.includes(STATUS_BATTLE), "the defender is in battle"); + } + + test_battle_triples_the_fighting_unit_upkeep() { + const state = smallState(); + const { attacker } = engagement(state); + const proto = state.protoUnits[attacker.proto]; + const idleUpkeep = state.getPlayerUpkeep(0) - proto.upkeep * 2; + const idleUnits = state.budgetBreakdown(0).unitUpkeep.total - proto.upkeep * 2; + this.assertApprox( + state.getPlayerUpkeep(0) - idleUpkeep, + proto.upkeep * 2, + 1e-9, + "the battle premium is twice the base upkeep" + ); + this.assertApprox( + state.budgetBreakdown(0).unitUpkeep.total - idleUnits, + proto.upkeep * 2, + 1e-9, + "the budget breakdown reflects the premium" + ); + } + + test_a_blocked_move_with_no_route_is_abandoned() { + const state = smallState(); + const pair = findEmptyLandPair(state); + const mover = state._spawnUnit(pair.a, 0, state.protoUnits[1]); + state._spawnUnit(pair.b, 1, state.protoUnits[0]); + mover.path = [{ x: pair.a.x, y: pair.a.y }, { x: pair.b.x, y: pair.b.y }]; + mover.pathIndex = 0; + mover.progressHours = 0; + state.advanceMovement(48); + this.assertEqual(mover.coords, pair.a, "the blocked worker stays put"); + this.assertEmpty(mover.path, "the unreachable move is abandoned"); + this.assertNull(state._combatTarget(mover), "the worker has no fight"); + } + + test_a_blocked_move_recalculates_around_an_obstacle() { + const state = smallState(); + const spot = findDetour(state); + this.assertNotNull(spot, "found a start/goal pair with a detour"); + const mover = state._spawnUnit(spot.start, 0, state.protoUnits[0]); + state._spawnUnit(spot.mid, 0, state.protoUnits[1]); + mover.path = [spot.start, spot.mid, spot.goal]; + mover.pathIndex = 0; + mover.progressHours = 0; + state.advanceMovement(1000); + this.assertEqual(mover.coords, spot.goal, "the unit reaches the goal by another route"); + this.assertEmpty(mover.path); + } + + test_combat_deals_one_round_per_day_with_a_defensive_bonus() { + const state = smallState(); + const { attacker, defender } = engagement(state); + for (let hour = 0; hour < DAY - 1; hour++) state.tickHour(); + this.assertEqual(attacker.hp, attacker.maxHp, "no round before the day is up"); + state.tickHour(); + this.assertEqual(state.totalHours, DAY); + const toAttacker = Math.max( + 1, + state._unitAttack(defender) - state._effectiveDefense(attacker, false) + ); + const toDefender = Math.max( + 1, + state._unitAttack(attacker) - state._effectiveDefense(defender, true) + ); + this.assertEqual(attacker.hp, attacker.maxHp - toAttacker, "attacker takes the counterblow"); + this.assertEqual(defender.hp, defender.maxHp - toDefender, "defender gets the 100% bonus"); + } + + test_terrain_defense_bonus_reduces_the_damage_taken() { + const flat = combatScenario(null); + const forest = combatScenario("Forest"); + const hills = combatScenario("Hills"); + const mountain = combatScenario("Mountain"); + this.assertApprox(flat.defenderDamage, 300 - 40 * 2, 1e-9); + this.assertApprox(forest.defenderDamage, 300 - 40 * 2 * 1.33, 1e-9); + this.assertApprox(hills.defenderDamage, 300 - 40 * 2 * 1.33, 1e-9); + this.assertApprox(mountain.defenderDamage, 300 - 40 * 2 * 2, 1e-9); + this.assertGreater(flat.defenderDamage, forest.defenderDamage, "forest defends better"); + this.assertGreater(forest.defenderDamage, mountain.defenderDamage, "mountain defends best"); + } + + test_a_city_adds_its_own_defense_bonus() { + const city = combatScenario(null, { city: true }); + const flat = combatScenario(null); + this.assertApprox(city.defenderDamage, 300 - 40 * 2 * 1.5, 1e-9); + this.assertLess(city.defenderDamage, flat.defenderDamage, "a city defends better than open land"); + } + + test_defeated_defender_disappears_and_attacker_holds_the_tile() { + const state = smallState(); + const { attacker, defender, b } = engagement(state); + defender.hp = 1; + state._resolveCombatRound(); + this.assertNull(state.findUnit(defender.id), "the vanquished unit is gone"); + this.assertGreater(attacker.hp, 0, "the attacker survives"); + this.assertEqual(attacker.coords, b, "the attacker already holds the tile"); + } + + test_attacker_disappears_when_the_defender_holds() { + const state = smallState(); + const { attacker, defender } = engagement(state); + for (let round = 0; round < 40 && state.findUnit(attacker.id); round++) { + state._resolveCombatRound(); + } + this.assertNull(state.findUnit(attacker.id), "the attacker is destroyed"); + this.assertNotNull(state.findUnit(defender.id), "the defender holds"); + this.assertGreater(defender.hp, 0); + } + + test_mutual_destruction_removes_both_units() { + const state = smallState(); + const { attacker, defender } = engagement(state); + attacker.hp = 1; + defender.hp = 1; + state._resolveCombatRound(); + this.assertNull(state.findUnit(attacker.id)); + this.assertNull(state.findUnit(defender.id)); + } + + test_units_ordered_into_each_other_fight_on_one_tile() { + const state = smallState(); + const { attacker, defender, a, b } = engagement(state); + // Pull them apart again and order both to charge. + attacker.coords = a; + defender.coords = b; + attacker.hp = attacker.maxHp; + defender.hp = defender.maxHp; + attacker.path = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }]; + attacker.pathIndex = 0; + attacker.progressHours = 0; + defender.path = [{ x: b.x, y: b.y }, { x: a.x, y: a.y }]; + defender.pathIndex = 0; + defender.progressHours = 0; + state.advanceMovement(1000); + this.assertEqual(state._combatPairs().length, 1, "the exchange is a single pair"); + const toAttacker = Math.max( + 1, + state._unitAttack(defender) - state._effectiveDefense(attacker, false) + ); + const toDefender = Math.max( + 1, + state._unitAttack(attacker) - state._effectiveDefense(defender, true) + ); + state._resolveCombatRound(); + this.assertEqual(attacker.hp, attacker.maxHp - toAttacker, "one counterblow, not two"); + this.assertEqual(defender.hp, defender.maxHp - toDefender, "one blow, not two"); + } + + test_non_military_units_never_fight() { + const state = smallState(); + const pair = findEmptyLandPair(state); + const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[1]); + const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]); + // A worker can never path into the enemy, so give it the route by hand. + attacker.path = [{ x: pair.a.x, y: pair.a.y }, { x: pair.b.x, y: pair.b.y }]; + attacker.pathIndex = 0; + attacker.progressHours = 0; + this.assertNull(state._combatTarget(attacker), "a worker does not attack"); + state.advanceMovement(48); + this.assertEqual(attacker.coords, pair.a, "a worker is blocked by the enemy"); + this.assertEqual(defender.hp, defender.maxHp, "no damage from a worker"); + } + + test_a_military_unit_captures_an_undefended_city() { + const state = smallState(); + let city = null; + let launch = null; + for (const candidate of state.cities) { + if (candidate.civ !== 1 || state.unitAt(candidate.coords)) continue; + const neighbour = emptyLandNeighbour(state, candidate.coords); + if (!neighbour) continue; + city = candidate; + launch = neighbour; + break; + } + this.assertNotNull(city, "found an undefended enemy city with a staging tile"); + const attacker = state._spawnUnit(launch, 0, state.protoUnits[0]); + state.training.set(city.id, [{ protoIndex: 0, capacity: 1, elapsedHours: 0, totalHours: 10 }]); + this.assertTrue(state.requestMove(attacker.id, city.coords)); + state.advanceMovement(1000); + this.assertEqual(attacker.coords, city.coords, "the attacker enters the city"); + this.assertEqual(city.civ, 0, "the city changes hands"); + this.assertEqual(state.civAt(city.coords), 0, "the city tile changes hands"); + this.assertNull(state.getTraining(city.id), "the old owner's queue is cancelled"); + } + + test_a_defended_city_falls_only_after_its_defender_dies() { + const state = smallState(); + let city = null; + let launch = null; + for (const candidate of state.cities) { + if (candidate.civ !== 1) continue; + const defender = state.unitAt(candidate.coords); + if (!defender || !state.isMilitary(defender)) continue; + const neighbour = emptyLandNeighbour(state, candidate.coords); + if (!neighbour) continue; + city = candidate; + launch = neighbour; + break; + } + this.assertNotNull(city, "found a defended enemy city with a staging tile"); + const defender = state.unitAt(city.coords); + const attacker = state._spawnUnit(launch, 0, state.protoUnits[0]); + this.assertTrue(state.requestMove(attacker.id, city.coords)); + state.advanceMovement(100); + this.assertEqual(attacker.coords, city.coords, "the attacker charges into the city"); + this.assertEqual(city.civ, 1, "the city has not fallen while it is defended"); + this.assertEqual(state._combatTarget(attacker), defender); + defender.hp = 1; + state._resolveCombatRound(); + this.assertNull(state.findUnit(defender.id), "the defender dies"); + this.assertEqual(city.civ, 0, "the city is captured once the defender is gone"); + } + + test_non_military_units_cannot_enter_an_enemy_city() { + const state = smallState(); + const city = state.cities.find((c) => c.civ === 1); + const worker = state.units.find((u) => u.civ === 0); + const workerProto = state.protoUnits[1]; + const proxy = { ...worker, proto: state.protoUnits.indexOf(workerProto) }; + this.assertFalse( + state._canUnitEnter(proxy, city.coords), + "a worker cannot walk into an enemy city" + ); + } + + test_capture_takes_only_the_closest_territory() { + const state = smallState(); + const cities = state.cities.filter((c) => c.civ === 1); + this.assertGreaterOrEqual(cities.length, 2, "civ 1 has two cities"); + const captured = cities[0]; + const before = (state._territoryByCiv.get(1) || []).map((c) => ({ x: c.x, y: c.y })); + const distances = new Map(); + for (const city of cities) distances.set(city.id, landDistances(state, city.coords)); + + const shouldFlip = new Set(); + for (const coords of before) { + const k = key(coords.x, coords.y); + const capturedDist = distances.get(captured.id).get(k); + let stays = capturedDist === undefined; + if (!stays) { + for (const other of cities) { + if (other === captured) continue; + const otherDist = distances.get(other.id).get(k); + if (otherDist !== undefined && otherDist <= capturedDist) { + stays = true; + break; + } + } + } + if (!stays) shouldFlip.add(k); + } + + this.assertTrue(state._captureCity(captured, 0), "the city is captured"); + this.assertEqual(captured.civ, 0); + for (const coords of before) { + const k = key(coords.x, coords.y); + if (shouldFlip.has(k)) this.assertEqual(state.civAt(coords), 0, `tile ${k} is captured`); + else this.assertEqual(state.civAt(coords), 1, `tile ${k} stays with its owner`); + } + this.assertEqual(state._territoryByCiv.get(1).length, before.length - shouldFlip.size); + } + + test_dead_units_leave_the_snapshot() { + const state = smallState(); + const { defender } = engagement(state); + const before = state.snapshot(0).units.length; + defender.hp = 1; + state._resolveCombatRound(); + this.assertEqual(state.snapshot(0).units.length, before - 1, "the dead unit leaves the snapshot"); + this.assertNull(state.findUnit(defender.id)); + } + + test_artillery_and_infantry_trade_blows_on_one_tile() { + const state = smallState(); + const pair = findEmptyLandPair(state); + const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]); + const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]); + this.assertTrue(state.requestMove(artillery.id, pair.b), "attack order accepted"); + state.advanceMovement(1000); + this.assertEqual(artillery.coords, pair.b, "the artillery charges onto the infantry"); + this.assertEqual(state._combatTarget(artillery), infantry); + const artilleryHp = artillery.hp; + const infantryHp = infantry.hp; + state._resolveCombatRound(); + this.assertLess(artillery.hp, artilleryHp, "the thin-skinned artillery is hit"); + this.assertLess(infantry.hp, infantryHp, "the infantry is hit back"); + } + + test_melee_charges_an_artillery_and_hits_it() { + const state = smallState(); + const pair = findEmptyLandPair(state); + const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]); + const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]); + this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in"); + state.advanceMovement(1000); + this.assertEqual(state._combatTarget(infantry), artillery); + const artilleryHp = artillery.hp; + state._resolveCombatRound(); + this.assertLess(artillery.hp, artilleryHp, "an adjacent artillery has almost no protection"); + } + + test_a_battle_removes_a_share_of_population_and_gdp_each_day() { + const state = smallState(); + const { b } = joinBattleOn(state, ownedLandPair(state)); + const k = key(b.x, b.y); + const population = state.tilePopulation.get(k); + const perCapita = state.getTileGdpPerCapita(b); + this.assertGreater(perCapita, 0, "the battlefield has a taxable economy"); + state._tickBattleTiles(); + this.assertApprox( + state.tilePopulation.get(k), + population * (1 - BATTLE.populationLossPerDay), + 1e-6, + "the tile loses population" + ); + this.assertApprox( + state.getTileGdpPerCapita(b), + perCapita * (1 - BATTLE.gdpPerCapitaLossPerDay), + 1e-6, + "the tile loses GDP per capita" + ); + } + + test_gdp_per_capita_recovers_only_after_thirty_days() { + const state = smallState(); + const { b } = joinBattleOn(state, ownedLandPair(state)); + const k = key(b.x, b.y); + const perCapita = state.getTileGdpPerCapita(b); + state._tickBattleTiles(); + const deficit = state.tileBattleGdpDeficit.get(k); + this.assertApprox(deficit, perCapita * BATTLE.gdpPerCapitaLossPerDay, 1e-6); + const start = state.tileBattleRecoveryStart.get(k); + this.assertEqual(start, BATTLE.recoveryDelayDays * DAY, "recovery waits thirty days"); + + state.totalHours = start - 1; + state._tickGdpRecovery(); + this.assertApprox(state.tileBattleGdpDeficit.get(k), deficit, 1e-9, "nothing recovers early"); + + state.totalHours = start; + state._tickGdpRecovery(); + this.assertApprox( + state.tileBattleGdpDeficit.get(k), + deficit - BATTLE.recoveryPerDay, + 1e-9, + "recovery begins at $100 a day" + ); + + for (let day = 0; day < Math.ceil(deficit / BATTLE.recoveryPerDay) + 1; day++) { + state.totalHours += DAY; + state._tickGdpRecovery(); + } + this.assertFalse(state.tileBattleGdpDeficit.has(k), "the wound heals completely"); + this.assertFalse(state.tileBattleRecoveryStart.has(k), "and the clock is cleared"); + } + + test_a_full_day_of_fighting_scars_the_tile() { + const state = smallState(); + const { b } = joinBattleOn(state, ownedLandPair(state)); + const k = key(b.x, b.y); + const before = state.getTileGdpPerCapita(b); + this.assertFalse(state.tileBattleGdpDeficit.has(k), "the tile is untouched at first"); + for (let hour = 0; hour < DAY; hour++) state.tickHour(); + this.assertTrue(state.tileBattleGdpDeficit.has(k), "the daily tick records the scar"); + this.assertLess(state.getTileGdpPerCapita(b), before, "and the tile is worth less"); + this.assertEqual(state.tileBattleRecoveryStart.get(k), DAY + BATTLE.recoveryDelayDays * DAY); + } + + test_a_unit_in_combat_cannot_pillage() { + const state = smallState(); + const tile = (state._territoryByCiv.get(1) || []).find( + (coords) => state._isLand(coords) && !state.cityAt(coords) + ); + this.assertNotNull(tile, "found an enemy-owned land tile"); + const raider = state._spawnUnit(tile, 0, state.protoUnits[0]); + const defender = state._spawnUnit(tile, 1, state.protoUnits[0]); + this.assertEqual(state.civAt(tile), 1, "the tile belongs to the defender"); + this.assertFalse(state.canPillage([raider.id]), "a unit in combat cannot pillage"); + + state._destroyUnit(defender); + this.assertTrue(state.canPillage([raider.id]), "once the fight is over it can"); + } + + async test_client_prediction_mirrors_occupancy() { + const state = defaultState(); + const pair = findEmptyLandPair(state); + const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + state._spawnUnit(pair.b, 1, state.protoUnits[0]); + + const env = await setupDom(); + try { + const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$)); + map.$viewport.width = () => 800; + map.$viewport.height = () => 600; + map.setCatalogue(CIVILISATIONS, PROTO_UNITS); + map.applySnapshot(state.snapshot(0)); + + const military = PROTO_UNITS.find((u) => u.id === "modern_infantry"); + const worker = PROTO_UNITS.find((u) => u.id === "worker"); + this.assertTrue( + map._canEnter(0, military, pair.b, attacker.id, true), + "military may target an enemy military unit as the goal" + ); + this.assertFalse( + map._canEnter(0, military, pair.b, attacker.id, false), + "an enemy military unit is avoided in transit" + ); + this.assertFalse( + map._canEnter(0, worker, pair.b, 0, true), + "a worker is blocked by an enemy" + ); + this.assertTrue( + map._canEnter(0, military, pair.a, 999), + "a friendly unit does not block; units stack" + ); + this.assertTrue( + map._canEnter(0, military, pair.a, attacker.id), + "a unit ignores itself" + ); + } finally { + teardownDom(env); + } + } + + async test_client_prediction_charges_onto_the_target() { + const state = defaultState(); + const pair = findEmptyLandPair(state); + const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]); + state._spawnUnit(pair.b, 1, state.protoUnits[0]); + + const env = await setupDom(); + try { + const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$)); + map.$viewport.width = () => 800; + map.$viewport.height = () => 600; + map.setCatalogue(CIVILISATIONS, PROTO_UNITS); + map.applySnapshot(state.snapshot(0)); + + const data = state.snapshot(0).units.find((u) => u.id === artillery.id); + const route = await map.predictMove(data, pair.b); + this.assertNotNull(route, "a route is predicted"); + const last = route[route.length - 1]; + this.assertEqual(last, pair.b, "the predicted route enters the target tile"); + } finally { + teardownDom(env); + } + } + + async test_client_animates_the_charge_onto_the_enemy() { + const state = defaultState(); + const pair = findEmptyLandPair(state); + const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + state._spawnUnit(pair.b, 1, state.protoUnits[0]); + state.requestMove(attacker.id, pair.b); + + const env = await setupDom(); + try { + const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$)); + map.$viewport.width = () => 800; + map.$viewport.height = () => 600; + map.setCatalogue(CIVILISATIONS, PROTO_UNITS); + map.applySnapshot(state.snapshot(0)); + + const motion = map._unitMotion.get(attacker.id); + this.assertNotNull(motion, "the attacker has a motion"); + const last = motion.path[motion.path.length - 1]; + this.assertEqual(last, pair.b, "the animation charges onto the defender"); + } finally { + teardownDom(env); + } + } + + async test_client_draws_half_size_stacks_with_the_battle_icon_between_them() { + const state = defaultState(); + const { b } = engagement(state); + + const env = await setupDom(); + try { + const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$)); + map.$viewport.width = () => 800; + map.$viewport.height = () => 600; + map.setCatalogue(CIVILISATIONS, PROTO_UNITS); + map.applySnapshot(state.snapshot(0)); + + const units = env.$("#layer-entities .unit.in-battle"); + this.assertEqual(units.length, 2, "both stacks are drawn half-size"); + + const marker = env.$("#layer-entities .battle-marker"); + this.assertEqual(marker.length, 1, "one battle marker is drawn"); + this.assertTrue( + (marker.find("img").attr("src") || "").endsWith("icon_battle.svg"), + "the marker uses the battle icon" + ); + + const lefts = units.map((_, el) => parseFloat(el.style.left)).get(); + const tops = units.map((_, el) => parseFloat(el.style.top)).get(); + const markerLeft = parseFloat(marker[0].style.left); + const markerTop = parseFloat(marker[0].style.top); + this.assertLess(Math.min(...lefts), markerLeft, "one stack sits left of the icon"); + this.assertGreater(Math.max(...lefts), markerLeft, "the other sits right of it"); + this.assertApprox(markerTop, tops[0], 1e-6, "the marker is on the tile"); + this.assertTrue(map._battles.has(key(b.x, b.y)), "the tile is registered as a battlefield"); + } finally { + teardownDom(env); + } + } +} + +// Spawns an attacker (civ 0) and a defender (civ 1) beside each other, then +// orders the attacker in. Returns the two units and both tiles, with the fight +// already joined on `b`. +function engagement(state) { + return joinBattleOn(state, findEmptyLandPair(state)); +} + +function joinBattleOn(state, pair) { + const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]); + state.requestMove(attacker.id, pair.b); + state.advanceMovement(1000); + return { attacker, defender, a: pair.a, b: pair.b }; +} + +// Two adjacent empty land tiles that both belong to a civilisation, so the +// battlefield has a population and a GDP per capita to scar. +function ownedLandPair(state) { + for (const cell of state.landCells) { + if (state.civAt(cell) < 0) continue; + if (state.unitAt(cell) || state.cityAt(cell)) continue; + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + if (!state._isLand(neighbour)) continue; + if (state.civAt(neighbour) < 0) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; + } + } + return findEmptyLandPair(state); +} + +// Two units planted on one tile of the given terrain, resolved for one round. +// A `city` option adds a city under them; the attacker's stats are boosted so +// the damage numbers are easy to read. +function combatScenario(defenderType, options = {}) { + const state = smallState(); + const attackerProto = { ...state.protoUnits[0], attack: 300 }; + const tile = findTerrainTile(state, defenderType); + if (!tile) return null; + if (options.city) { + state._spawnCity({ civIndex: 1, coords: { x: tile.x, y: tile.y }, isCapital: false }); + state._rebuildCityIndex(); + } + const attacker = state._spawnUnit(tile, 0, attackerProto); + const defender = state._spawnUnit(tile, 1, state.protoUnits[0]); + const attackerHp = attacker.hp; + const defenderHp = defender.hp; + state._resolveCombatRound(); + return { + state, + attacker, + defender, + attackerDamage: attackerHp - attacker.hp, + defenderDamage: defenderHp - defender.hp, + }; +} + +function findTerrainTile(state, terrainType) { + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + if (terrainType && state.tiles[key(cell.x, cell.y)].terrainType !== terrainType) continue; + return { x: cell.x, y: cell.y }; + } + return null; +} + +function findEmptyLandPair(state) { + let fallback = null; + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + const pair = { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; + if (!fallback) fallback = pair; + const ownA = state.civAt(cell) < 0 || state.civAt(cell) === 0; + const ownB = state.civAt(neighbour) < 0 || state.civAt(neighbour) === 0; + if (ownA && ownB) return pair; + } + } + return fallback; +} + +function findAnotherEmptyLandPair(state, first) { + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + if (sameCoords(cell, first.a) || sameCoords(cell, first.b)) continue; + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + if (sameCoords(neighbour, first.a) || sameCoords(neighbour, first.b)) continue; + return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; + } + } + return null; +} + +// A start tile, an adjacent direct step and a goal two tiles away, with a +// distinct land neighbour of the start that also touches the goal: the detour +// a blocked unit can take. +function findDetour(state) { + for (const start of state.landCells) { + if (state.unitAt(start) || state.cityAt(start)) continue; + for (const mid of state.topology.neighbours(start.x, start.y)) { + if (!state._isLand(mid) || state.unitAt(mid) || state.cityAt(mid)) continue; + for (const goal of state.topology.neighbours(mid.x, mid.y)) { + if (sameCoords(goal, start) || !state._isLand(goal)) continue; + if (state.unitAt(goal) || state.cityAt(goal)) continue; + for (const alt of state.topology.neighbours(start.x, start.y)) { + if (sameCoords(alt, mid) || !state._isLand(alt)) continue; + if (state.unitAt(alt) || state.cityAt(alt)) continue; + if (state.topology.tileDistance(alt, goal) === 1) { + return { start, mid, goal, alt }; + } + } + } + } + } + return null; +} + +function emptyLandNeighbour(state, coords) { + for (const neighbour of state.topology.neighbours(coords.x, coords.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + return { x: neighbour.x, y: neighbour.y }; + } + return null; +} + +function landDistances(state, start) { + const dist = new Map(); + dist.set(key(start.x, start.y), 0); + let frontier = [start]; + while (frontier.length > 0) { + const next = []; + for (const coords of frontier) { + const distance = dist.get(key(coords.x, coords.y)); + for (const neighbour of state.topology.neighbours(coords.x, coords.y)) { + const nk = key(neighbour.x, neighbour.y); + if (dist.has(nk) || !state._isLand(neighbour)) continue; + dist.set(nk, distance + 1); + next.push(neighbour); + } + } + frontier = next; + } + return dist; +} + +function sameCoords(a, b) { + return a.x === b.x && a.y === b.y; +} diff --git a/tests/conflicts_test.js b/tests/conflicts_test.js new file mode 100644 index 0000000..b8f07d5 --- /dev/null +++ b/tests/conflicts_test.js @@ -0,0 +1,142 @@ +import { TestCase } from "./framework/test_case.js"; +import { setupDom, teardownDom } from "./framework/dom.js"; +import { smallState } from "./framework/helpers.js"; +import { NationModal } from "../client/js/modals.js"; +import { GOVERNMENTS, TECHNOLOGIES } from "../shared/data.js"; + +function tileOwnedBy(state, owner) { + for (const [k, tileOwner] of state.territory) { + if (tileOwner !== owner) continue; + const i = k.indexOf(","); + const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; + if (state.unitAt(coords) || state.cityAt(coords)) continue; + return coords; + } + return null; +} + +function findEmptyLandPair(state) { + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; + } + } + return null; +} + +export class ConflictsStateTest extends TestCase { + test_declaring_war_opens_a_dated_conflict() { + const state = smallState(); + for (let i = 0; i < 7; i++) state.advanceHour(); + this.assertTrue(state.requestDeclareWar(0, 1)); + + this.assertSize(state.conflicts, 1, "one conflict is recorded"); + const conflict = state.snapshot(0).conflicts[0]; + this.assertEqual(conflict.a, 0); + this.assertEqual(conflict.b, 1); + this.assertEqual(conflict.startHours, 7); + this.assertTrue(conflict.started.includes("07:00"), `the start hour is kept: ${conflict.started}`); + this.assertSize(conflict.sides, 2); + this.assertEqual(conflict.sides[0], { civ: 0, military: 0, civilians: 0 }); + this.assertEqual(conflict.sides[1], { civ: 1, military: 0, civilians: 0 }); + } + + test_military_deaths_are_tallied_per_side() { + const state = smallState(); + state.requestDeclareWar(0, 1); + const pair = findEmptyLandPair(state); + const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + const defender = state._spawnUnit(pair.a, 1, state.protoUnits[0]); + attacker.hp = attacker.maxHp; + defender.hp = 1; + state._resolveCombatRound(); + + const conflict = state.conflicts.values().next().value; + this.assertEqual(conflict.military.get(1), 1, "the fallen defender's civ is credited the loss"); + this.assertFalse(conflict.military.has(0), "the surviving attacker lost no unit"); + } + + test_pillaging_counts_civilian_casualties() { + const state = smallState(); + state.requestDeclareWar(0, 1); + const coords = tileOwnedBy(state, 1); + this.assertNotNull(coords, "found an enemy tile"); + const k = `${coords.x},${coords.y}`; + state.tilePopulation.set(k, 100000); + const unit = state._spawnUnit(coords, 0, state.protoUnits[0]); + unit.hp = 100; + + this.assertTrue(state.requestPillage([unit.id])); + + const conflict = state.conflicts.values().next().value; + this.assertEqual(conflict.civilians.get(1), 10000, "the civilians driven off are counted"); + } + + test_conflicts_are_shared_by_every_viewer() { + const state = smallState(); + state.requestDeclareWar(0, 1); + this.assertEqual(state.snapshot(0).conflicts, state.snapshot(1).conflicts); + } +} + +export class ConflictsModalTest extends TestCase { + async test_conflicts_tab_lists_and_expands() { + const env = await setupDom(); + try { + const modal = new NationModal(); + const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }]; + const conflicts = [ + { + a: 0, + b: 1, + started: "1 January 2000, 00:00", + startHours: 0, + sides: [ + { civ: 0, military: 12, civilians: 3400 }, + { civ: 1, military: 7, civilians: 900 }, + ], + }, + ]; + modal.show( + GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, + { civs, localCiv: 0, relations: [[0, 1, "war"]] }, + conflicts + ); + env.$("#modal-nation .tab[data-tab=conflicts]").click(); + this.assertTrue(env.$("#tab-conflicts").hasClass("active")); + this.assertTrue(env.$("#conflicts-sub").text().includes("1 ongoing conflict")); + + const rows = env.$("#conflicts-list .conflict-card"); + this.assertSize(rows, 1); + this.assertTrue(rows.eq(0).find(".name").text().includes("France vs Britain")); + + const detail = rows.eq(0).find(".conflict-detail"); + this.assertTrue(detail.hasClass("hidden"), "details start collapsed"); + rows.eq(0).click(); + this.assertFalse(detail.hasClass("hidden"), "clicking a conflict reveals its details"); + const text = detail.text(); + this.assertTrue(text.includes("Conflict started 1 January 2000, 00:00")); + this.assertTrue(text.includes("France — Military: 12 · Civilians: 3 400")); + this.assertTrue(text.includes("Britain — Military: 7 · Civilians: 900")); + modal.close(); + } finally { + teardownDom(env); + } + } + + async test_conflicts_tab_reports_peace() { + const env = await setupDom(); + try { + const modal = new NationModal(); + modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, null, []); + this.assertTrue(env.$("#conflicts-sub").text().includes("No ongoing conflicts")); + this.assertTrue(env.$("#conflicts-list").text().includes("at peace")); + modal.close(); + } finally { + teardownDom(env); + } + } +} diff --git a/tests/diplomacy_test.js b/tests/diplomacy_test.js new file mode 100644 index 0000000..1a52200 --- /dev/null +++ b/tests/diplomacy_test.js @@ -0,0 +1,248 @@ +import { TestCase } from "./framework/test_case.js"; +import { GameServer } from "../server/game_server.js"; +import { smallState, SEED } from "./framework/helpers.js"; + +function parseKey(k) { + const i = k.indexOf(","); + return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; +} + +// A tile owned by a civilisation other than `mover`, plus an adjacent land tile +// the mover can stage a unit on. Both tiles are free of units and cities. +function foreignTerritoryWithStaging(state, mover) { + for (const [k, owner] of state.territory) { + if (owner === mover) continue; + const target = parseKey(k); + if (state.unitAt(target) || state.cityAt(target)) continue; + for (const neighbour of state.topology.neighbours(target.x, target.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + if (state.civAt(neighbour) === owner) continue; + return { staging: neighbour, target }; + } + } + return null; +} + +function ownLandWithStaging(state, civ) { + for (const cell of state.landCells) { + if (state.civAt(cell) !== civ) continue; + if (state.unitAt(cell) || state.cityAt(cell)) continue; + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + if (!state._isLand(neighbour)) continue; + if (state.civAt(neighbour) !== civ) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + return { staging: { x: cell.x, y: cell.y }, target: { x: neighbour.x, y: neighbour.y } }; + } + } + return null; +} + +function fakeNetwork() { + return { + peers: new Set(), + sent: [], + getPeerIds() { + return Array.from(this.peers); + }, + send(peerId, message) { + this.sent.push({ peerId, message }); + }, + }; +} + +function makeServer(civIds = ["france", "britain"]) { + const network = fakeNetwork(); + const server = new GameServer(network); + server.configureGame({ seed: SEED, player_civ: 0, civilisations: civIds }); + return { server, network }; +} + +export class DiplomacyStateTest extends TestCase { + test_everyone_starts_at_peace() { + const state = smallState(["france", "britain", "poland"]); + this.assertFalse(state.isAtWar(0, 1)); + this.assertFalse(state.isAtWar(1, 2)); + this.assertEqual(state.getRelation(0, 1), "peace"); + this.assertSize(state.wars, 0); + this.assertEmpty(state.news); + } + + test_declaring_war_sets_the_relation() { + const state = smallState(); + this.assertTrue(state.requestDeclareWar(0, 1)); + this.assertTrue(state.isAtWar(0, 1)); + this.assertTrue(state.isAtWar(1, 0), "the relation is symmetric"); + this.assertEqual(state.getRelation(1, 0), "war"); + } + + test_declaring_war_twice_is_rejected() { + const state = smallState(); + this.assertTrue(state.requestDeclareWar(0, 1)); + this.assertFalse(state.requestDeclareWar(0, 1), "already at war"); + this.assertFalse(state.requestDeclareWar(1, 0), "the other direction is the same war"); + } + + test_declaring_war_rejects_invalid_targets() { + const state = smallState(); + this.assertFalse(state.requestDeclareWar(0, 0), "cannot fight yourself"); + this.assertFalse(state.requestDeclareWar(0, 99), "unknown target"); + this.assertFalse(state.requestDeclareWar(-1, 1), "unknown attacker"); + } + + test_declaration_is_newsworthy() { + const state = smallState(); + for (let i = 0; i < 5; i++) state.advanceHour(); + state.requestDeclareWar(0, 1); + this.assertSize(state.news, 1); + const entry = state.news[0]; + this.assertEqual(entry.type, "war"); + this.assertEqual(entry.actor, 0); + this.assertEqual(entry.target, 1); + this.assertEqual(entry.totalHours, 5); + this.assertTrue(entry.date.includes("05:00"), `date has the hour: ${entry.date}`); + } + + test_the_news_feed_is_capped() { + const state = smallState(); + for (let i = 0; i < 250; i++) state._addNews({ type: "war", actor: 0, target: 1 }); + this.assertEqual(state.news.length, 100); + this.assertEqual(state.news[0].id, 151); + this.assertEqual(state.news[state.news.length - 1].id, 250); + } + + test_moving_a_military_unit_into_peaceful_territory_declares_war() { + const state = smallState(); + const site = foreignTerritoryWithStaging(state, 0); + this.assertNotNull(site, "found foreign territory with a staging tile"); + const unit = state._spawnUnit(site.staging, 0, state.protoUnits[0]); + this.assertTrue(state.requestMove(unit.id, site.target)); + this.assertTrue(state.isAtWar(0, 1), "the march declared war"); + this.assertSize(state.news.filter((e) => e.type === "war"), 1); + } + + test_peaceful_movement_adds_no_news_when_already_at_war() { + const state = smallState(); + state.requestDeclareWar(0, 1); + const site = foreignTerritoryWithStaging(state, 0); + const unit = state._spawnUnit(site.staging, 0, state.protoUnits[0]); + this.assertTrue(state.requestMove(unit.id, site.target)); + this.assertSize(state.news, 1, "only the original declaration is news"); + this.assertSize(state.news.filter((e) => e.type === "war"), 1); + } + + test_civilian_movement_does_not_declare_war() { + const state = smallState(); + const site = foreignTerritoryWithStaging(state, 0); + const worker = state._spawnUnit(site.staging, 0, state.protoUnits[1]); + this.assertFalse(state.isMilitary(worker), "the unit is a civilian"); + this.assertTrue(state.requestMove(worker.id, site.target)); + this.assertFalse(state.isAtWar(0, 1), "civilians cross without a war"); + this.assertEmpty(state.news); + } + + test_capturing_a_city_is_newsworthy() { + const state = smallState(); + const city = state.cities.find((c) => c.civ === 1); + this.assertTrue(state._captureCity(city, 0)); + const capture = state.news.find((e) => e.type === "capture"); + this.assertNotNull(capture); + this.assertEqual(capture.city, city.name); + this.assertEqual(capture.from, 1); + this.assertEqual(capture.to, 0); + this.assertHas(capture, "date"); + } + + test_snapshot_carries_diplomacy_and_news() { + const state = smallState(); + state.requestDeclareWar(0, 1); + const snapshot = state.snapshot(0); + this.assertHas(snapshot, "diplomacy"); + this.assertHas(snapshot, "news"); + const relation = snapshot.diplomacy.find((e) => e[0] === 0 && e[1] === 1); + this.assertEqual(relation[2], "war"); + this.assertSize(snapshot.news, 1); + } + + test_diplomacy_and_news_are_shared_by_every_viewer() { + const state = smallState(); + state.requestDeclareWar(0, 1); + const first = state.snapshot(0); + const second = state.snapshot(1); + this.assertEqual(first.diplomacy, second.diplomacy); + this.assertEqual(first.news, second.news); + } + + test_snapshot_carries_every_nations_economy() { + const state = smallState(["france", "britain", "poland"]); + const stats = state.snapshot(0).civStats; + this.assertSize(stats, 3, "one entry per nation"); + for (let civ = 0; civ < 3; civ++) { + this.assertEqual(stats[civ].population, state.getPlayerPopulation(civ)); + this.assertApprox(stats[civ].gdp, state.getPlayerGdp(civ), 1e-6); + const expected = stats[civ].population > 0 + ? stats[civ].gdp / stats[civ].population + : 0; + this.assertApprox(stats[civ].gdpPerCapita, expected, 1e-9); + } + this.assertEqual(state.snapshot(0).civStats, state.snapshot(1).civStats, "shared by every viewer"); + } + + test_declaration_notifies_listeners() { + const state = smallState(); + let changes = 0; + state.onChanged(() => { changes += 1; }); + state.requestDeclareWar(0, 1); + this.assertGreater(changes, 0, "the declaration is published"); + } +} + +export class DiplomacyServerTest extends TestCase { + test_declare_war_order_is_accepted() { + const { server } = makeServer(); + server.peerCiv.set(1, 0); + this.assertTrue(server.handleOrder(1, { type: "declare_war", target: 1 })); + this.assertTrue(server.state.isAtWar(0, 1)); + this.assertSize(server.state.news, 1); + } + + test_declare_war_order_rejects_bad_targets() { + const { server } = makeServer(); + server.peerCiv.set(1, 0); + this.assertFalse(server.handleOrder(1, { type: "declare_war", target: 0 })); + this.assertFalse(server.handleOrder(1, { type: "declare_war", target: 99 })); + } + + test_declare_war_order_is_rejected_for_spectators() { + const { server } = makeServer(); + this.assertFalse(server.handleOrder(7, { type: "declare_war", target: 1 })); + } + + test_move_order_into_peaceful_territory_declares_war() { + const { server } = makeServer(); + server.peerCiv.set(1, 0); + const site = foreignTerritoryWithStaging(server.state, 0); + const unit = server.state._spawnUnit(site.staging, 0, server.state.protoUnits[0]); + this.assertTrue(server.handleOrder(1, { + type: "move", + unit: unit.id, + coords: [site.target.x, site.target.y], + })); + this.assertTrue(server.state.isAtWar(0, 1)); + } + + test_move_order_into_own_territory_is_not_news() { + const { server } = makeServer(); + server.peerCiv.set(1, 0); + const site = ownLandWithStaging(server.state, 0); + this.assertNotNull(site, "found two adjacent friendly land tiles"); + const unit = server.state._spawnUnit(site.staging, 0, server.state.protoUnits[0]); + this.assertTrue(server.handleOrder(1, { + type: "move", + unit: unit.id, + coords: [site.target.x, site.target.y], + })); + this.assertFalse(server.state.isAtWar(0, 1)); + this.assertEmpty(server.state.news); + } +} diff --git a/tests/feed_test.js b/tests/feed_test.js new file mode 100644 index 0000000..1b00f58 --- /dev/null +++ b/tests/feed_test.js @@ -0,0 +1,63 @@ +import { TestCase } from "./framework/test_case.js"; +import { setupDom, teardownDom } from "./framework/dom.js"; +import { GameFeed } from "../client/js/feed.js"; + +const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class GameFeedTest extends TestCase { + async test_lines_stack_with_the_newest_at_the_bottom() { + const env = await setupDom(); + try { + const feed = new GameFeed(env.$("#game-feed"), { ttl: -1 }); + feed.push("first"); + feed.push("second"); + const lines = env.$("#game-feed .feed-line"); + this.assertSize(lines, 2); + this.assertEqual(lines.eq(0).text(), "first", "older lines stay on top"); + this.assertEqual(lines.eq(1).text(), "second", "newest line is at the bottom"); + feed.clear(); + } finally { + teardownDom(env); + } + } + + async test_alerts_are_marked() { + const env = await setupDom(); + try { + const feed = new GameFeed(env.$("#game-feed"), { ttl: -1 }); + feed.push("you are at war", { alert: true }); + this.assertSize(env.$("#game-feed .feed-line.feed-alert"), 1); + feed.clear(); + } finally { + teardownDom(env); + } + } + + async test_lines_retire_on_their_own() { + const env = await setupDom(); + try { + const feed = new GameFeed(env.$("#game-feed"), { ttl: 5, alertTtl: 5, fade: 0 }); + feed.push("short lived"); + this.assertSize(env.$("#game-feed .feed-line"), 1); + await tick(40); + this.assertSize(env.$("#game-feed .feed-line"), 0, "the line disappeared"); + } finally { + teardownDom(env); + } + } + + async test_clear_cancels_pending_retirements() { + const env = await setupDom(); + try { + const feed = new GameFeed(env.$("#game-feed"), { ttl: 5, fade: 0 }); + feed.push("one"); + feed.push("two"); + feed.clear(); + this.assertSize(env.$("#game-feed .feed-line"), 0); + await tick(30); + this.assertSize(env.$("#game-feed .feed-line"), 0, "nothing reappears"); + } finally { + teardownDom(env); + } + } +} diff --git a/tests/orders_test.js b/tests/orders_test.js new file mode 100644 index 0000000..9919fc1 --- /dev/null +++ b/tests/orders_test.js @@ -0,0 +1,140 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState } from "./framework/helpers.js"; + +// Right-click attack and Ctrl-right-click scheduled movement, exercised on the +// authoritative GameState. + +export class AttackOrderTest extends TestCase { + test_attack_is_rejected_without_a_target() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + const goal = neutralLine(state, 1)[0]; + this.assertFalse(state.requestAttack([unit.id], goal), "an empty tile is not an attack"); + this.assertFalse(state.requestAttack([99999], goal), "unknown units are rejected"); + } + + test_attack_marches_onto_the_enemy_and_joins_battle() { + const state = smallState(); + const pair = emptyLandPair(state); + const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]); + this.assertTrue(state.requestAttack([attacker.id], pair.b), "the enemy stack is a target"); + state.advanceMovement(1000); + this.assertEqual(attacker.coords, pair.b, "the attacker charges onto the enemy tile"); + this.assertEqual(state._combatTarget(attacker), defender, "the two stacks are fighting"); + } + + test_attack_targets_an_enemy_city() { + const state = smallState(); + const city = state.cities.find((c) => c.civ === 1); + const launch = emptyLandNeighbour(state, city.coords); + this.assertNotNull(launch, "found a staging tile by the enemy city"); + const attacker = state._spawnUnit(launch, 0, state.protoUnits[0]); + this.assertTrue(state.requestAttack([attacker.id], city.coords), "the enemy city is a target"); + } + + test_attack_becomes_a_move_when_the_enemy_has_left() { + const state = smallState(); + const pair = emptyLandPair(state); + const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]); + this.assertTrue(state.requestAttack([attacker.id], pair.b)); + // The defender slips away before the attacker arrives. + state._destroyUnit(defender); + state.advanceMovement(1000); + this.assertEqual(attacker.coords, pair.b, "the order is simply a move to the tile"); + this.assertNull(state._combatTarget(attacker), "there is no fight to join"); + } +} + +export class ScheduledOrderTest extends TestCase { + test_scheduled_move_visits_each_destination_in_turn() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + const [first, second] = neutralLine(state, 2); + this.assertTrue(state.requestScheduledMove([unit.id], [first, second])); + this.assertSize(unit.waypoints, 1, "the second destination stays queued"); + this.assertEqual(unit.path[unit.path.length - 1], first, "the first leg heads to the first stop"); + state.advanceMovement(1000); + this.assertEqual(unit.coords, second, "the unit carries on to the second stop"); + this.assertEmpty(unit.waypoints); + } + + test_scheduled_move_can_append_a_destination() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + const [first, second] = neutralLine(state, 2); + this.assertTrue(state.requestScheduledMove([unit.id], [first])); + this.assertTrue(state.requestScheduledMove([unit.id], [second], true)); + this.assertSize(unit.waypoints, 1, "the appended stop joins the queue"); + state.advanceMovement(1000); + this.assertEqual(unit.coords, second); + } + + test_each_leg_is_routed_from_the_previous_destination() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + const [first, second] = neutralLine(state, 2); + state.requestScheduledMove([unit.id], [first, second]); + // Place the unit on the first stop with no partial step; the next tick must + // plan a fresh route from there rather than reuse the original one. + unit.coords = first; + unit.path = [first]; + unit.pathIndex = 0; + unit.progressHours = 0; + state.advanceMovement(1000); + this.assertEqual(unit.coords, second, "a new path is computed from the stop"); + } + + test_replacing_a_schedule_drops_the_old_queue() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + const [first, second] = neutralLine(state, 2); + state.requestScheduledMove([unit.id], [first, second]); + this.assertTrue(state.requestScheduledMove([unit.id], [first])); + this.assertEmpty(unit.waypoints, "a plain scheduled move replaces the queue"); + } +} + +// `steps` empty land tiles in a line, returned without the start tile. +function neutralLine(state, steps) { + for (const start of state.landCells) { + if (state.unitAt(start) || state.cityAt(start)) continue; + const path = [start]; + let current = start; + for (let i = 0; i < steps; i++) { + const next = state.topology.neighbours(current.x, current.y).find((n) => + state._isLand(n) && + !state.unitAt(n) && + !state.cityAt(n) && + !path.some((p) => p.x === n.x && p.y === n.y) + ); + if (!next) break; + path.push(next); + current = next; + } + if (path.length === steps + 1) return path.slice(1); + } + throw new Error(`no empty line of ${steps} tiles`); +} + +function emptyLandPair(state) { + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; + } + } + throw new Error("no empty land pair"); +} + +function emptyLandNeighbour(state, coords) { + for (const neighbour of state.topology.neighbours(coords.x, coords.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + return { x: neighbour.x, y: neighbour.y }; + } + return null; +} diff --git a/tests/regression_test.js b/tests/regression_test.js new file mode 100644 index 0000000..1744e9d --- /dev/null +++ b/tests/regression_test.js @@ -0,0 +1,224 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState, cityOf, seaTile } from "./framework/helpers.js"; +import { + STATUS_ENCIRCLED, + BUILDINGS, + CIVILISATIONS, + PROTO_UNITS, + GOVERNMENTS, + TECHNOLOGIES, + TERRAIN_TILES, + MAP_CONFIG, + ECONOMY, + BATTLE, + effectValue, + pillageSteps, +} from "../shared/data.js"; +import { groupDigits } from "../shared/text_format.js"; + +// Regression and edge coverage for the order, economy, combat and catalogue +// code. These lock in the behavioural guarantees the modular refactor must +// preserve. + +function emptyLand(state, skip = 0) { + let seen = 0; + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + if (seen++ < skip) continue; + return cell; + } + throw new Error("no empty land tile"); +} + +// A land tile that is empty and has at least one empty land neighbour. +function boundaryLand(state) { + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + const neighbour = state.topology.neighbours(cell.x, cell.y).find( + (n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n) + ); + if (neighbour) return { a: cell, b: neighbour }; + } + throw new Error("no empty land pair"); +} + +export class OrderStateRegressionTest extends TestCase { + test_rejected_move_keeps_the_existing_route() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + this.assertTrue(state.requestGroupMove([unit.id], emptyLand(state)), "the first move is accepted"); + const before = unit.path.map((p) => ({ x: p.x, y: p.y })); + this.assertFalse(state.requestGroupMove([unit.id], seaTile(state)), "the sea is not a destination"); + this.assertEqual(unit.path, before, "a rejected order leaves the route alone"); + this.assertTrue(state._hasRoute(unit), "the unit keeps marching"); + } + + test_rejected_scheduled_move_keeps_the_existing_queue() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + const first = emptyLand(state, 0); + const second = emptyLand(state, 1); + this.assertTrue(state.requestScheduledMove([unit.id], [first, second])); + const queued = unit.waypoints.map((p) => ({ x: p.x, y: p.y })); + this.assertFalse(state.requestScheduledMove([unit.id], [seaTile(state)])); + this.assertEqual(unit.waypoints, queued, "the scheduled stops survive a rejected order"); + } + + test_accepted_move_replaces_a_previous_queue() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + state.requestScheduledMove([unit.id], [emptyLand(state, 0), emptyLand(state, 1)]); + this.assertNotEmpty(unit.waypoints, "a stop is queued"); + this.assertTrue(state.requestGroupMove([unit.id], emptyLand(state, 5))); + this.assertEmpty(unit.waypoints, "a fresh move drops the old schedule"); + } + + test_move_to_the_units_own_tile_is_rejected() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + this.assertFalse( + state.requestGroupMove([unit.id], { x: unit.coords.x, y: unit.coords.y }), + "staying put is not an order" + ); + } +} + +export class EconomyOrderEdgesTest extends TestCase { + test_a_free_unit_is_trained_immediately() { + const state = smallState(); + const city = cityOf(state, 0); + const worker = state.protoUnits.findIndex((p) => p.id === "worker"); + const before = state.units.length; + this.assertTrue(state.requestTrain(city.id, worker), "a free unit is accepted"); + this.assertGreater(state.units.length, before, "the unit appears at once"); + this.assertNull(state.getTraining(city.id), "nothing is left in the queue"); + } + + test_building_stops_at_the_maximum_level() { + const state = smallState(); + const city = cityOf(state, 0); + const proto = state.protoBuildings[0]; + state.budgets.set(0, 1e15); + for (let i = 0; i < proto.maxLevel; i++) state.requestBuild(city.id, 0); + this.assertEqual(state.getCityBuildingLevel(city, 0), proto.maxLevel); + this.assertFalse(state.requestBuild(city.id, 0), "cannot build past the cap"); + } + + test_demolishing_removes_one_level_and_refunds() { + const state = smallState(); + const city = cityOf(state, 0); + state.budgets.set(0, 1e15); + state.requestBuild(city.id, 0); + const budget = state.getBudget(0); + this.assertTrue(state.requestDemolish(city.id, 0)); + this.assertEqual(state.getCityBuildingLevel(city, 0), 0); + this.assertGreater(state.getBudget(0), budget, "the refund raises the budget"); + } + + test_cancelling_an_out_of_range_queue_entry_fails() { + const state = smallState(); + const city = cityOf(state, 0); + const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry"); + state.requestTrain(city.id, infantry); + this.assertFalse(state.cancelTrain(city.id, -1), "negative index"); + this.assertFalse(state.cancelTrain(city.id, 99), "past the end"); + this.assertTrue(state.cancelTrain(city.id, 0), "the real entry cancels"); + this.assertNull(state.getTraining(city.id), "the emptied queue is dropped"); + } + + test_tile_gdp_is_population_times_per_capita() { + const state = smallState(); + const owned = state._territoryByCiv.get(0)[0]; + const expected = state.getTilePopulation(owned) * state.getTileGdpPerCapita(owned); + this.assertApprox(state.getTileGdp(owned), expected, 1e-6); + } + + test_battle_triples_unit_upkeep() { + const state = smallState(); + const pair = boundaryLand(state); + const before = state.getPlayerUpkeep(0); + state._spawnUnit(pair.a, 0, state.protoUnits[0]); + state._spawnUnit(pair.b, 1, state.protoUnits[0]); + // The two hostile units are only "in battle" once they share a tile. + state.units[state.units.length - 1].coords = pair.a; + const after = state.getPlayerUpkeep(0); + this.assertGreater(after, before, "a unit in battle costs more to keep"); + } + + test_buildings_in_the_catalogue_have_a_cost_curve() { + for (const proto of BUILDINGS) { + this.assertTrue(proto.costGdpFraction > 0, `${proto.id} cost fraction`); + this.assertTrue(proto.costAlpha > 1, `${proto.id} cost growth`); + this.assertTrue(proto.maxLevel >= 1, `${proto.id} max level`); + } + } +} + +export class AttritionTest extends TestCase { + test_daily_attrition_destroys_a_unit_at_zero_hit_points() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + unit.statuses = [STATUS_ENCIRCLED]; + unit.hp = 1; + state._applyDailyStatusEffects(); + this.assertEqual(unit.hp, 0, "the hit points are floored at zero"); + this.assertFalse(state.units.includes(unit), "the unit is removed from the world"); + this.assertNull(state.findUnit(unit.id), "and from the id index"); + } + + test_a_healthy_encircled_unit_survives_one_day() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + unit.statuses = [STATUS_ENCIRCLED]; + unit.hp = unit.maxHp; + state._applyDailyStatusEffects(); + this.assertGreater(unit.hp, 0, "attrition is a fraction, not a wipe"); + this.assertTrue(state.units.includes(unit)); + } +} + +export class CasualtyRecordingTest extends TestCase { + test_a_lost_unit_counts_against_an_adjacent_enemy() { + const state = smallState(); + state._declareWar(0, 1); + const pair = boundaryLand(state); + const fallen = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + state._spawnUnit(pair.b, 1, state.protoUnits[0]); + state._recordMilitaryCasualties([fallen]); + const conflict = state.conflicts.get("0:1"); + this.assertNotNull(conflict, "the war is on record"); + this.assertEqual(conflict.military.get(0) || 0, 1, "the neighbour's kill is counted"); + } +} + +export class CatalogueSplitTest extends TestCase { + test_the_barrel_reexports_every_slice() { + this.assertNotEmpty(CIVILISATIONS); + this.assertNotEmpty(PROTO_UNITS); + this.assertNotEmpty(BUILDINGS); + this.assertNotEmpty(GOVERNMENTS); + this.assertNotEmpty(TECHNOLOGIES); + this.assertNotEmpty(TERRAIN_TILES); + this.assertEqual(typeof MAP_CONFIG.mapSize.x, "number"); + this.assertEqual(typeof ECONOMY.budgetGdpRate, "number"); + this.assertEqual(typeof BATTLE.recoveryPerDay, "number"); + } + + test_pillaging_with_no_units_changes_nothing() { + const result = pillageSteps(5000, 0); + this.assertEmpty(result.steps); + this.assertEqual(result.gdpMultiplier, 1); + } + + test_effect_value_is_zero_for_a_non_positive_level() { + this.assertEqual(effectValue({ baseValue: 5, exponent: 1 }, -3), 0); + } +} + +export class TextFormatRegressionTest extends TestCase { + test_negative_fraction_does_not_render_negative_zero() { + this.assertEqual(groupDigits(-0.4), "0"); + this.assertEqual(groupDigits(-0.999), "0"); + } +} + diff --git a/tests/siege_test.js b/tests/siege_test.js new file mode 100644 index 0000000..206e62e --- /dev/null +++ b/tests/siege_test.js @@ -0,0 +1,269 @@ +import { TestCase } from "./framework/test_case.js"; +import { setupDom, teardownDom } from "./framework/dom.js"; +import { smallState, defaultState, mapLayers, SEED } from "./framework/helpers.js"; +import { parseKey } from "../shared/hex.js"; +import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js"; +import { MapView } from "../client/js/map_view.js"; + +const DAY = 24; + +export class SiegeTest extends TestCase { + test_the_status_catalogue_defines_attrition() { + const encircled = statusById(STATUS_ENCIRCLED); + const besieged = statusById(STATUS_BESIEGED); + this.assertNotNull(encircled); + this.assertNotNull(besieged); + this.assertApprox(encircled.hpLossPerDay, 0.10); + this.assertApprox(encircled.cityHpLossPerDay, 0.02); + this.assertEqual(encircled.icon, "encirclement.svg"); + this.assertEqual(besieged.icon, "encirclement.svg"); + this.assertNull(statusById("nope")); + } + + test_a_supplied_unit_carries_no_status() { + const state = smallState(); + state._refreshStatuses(); + for (const unit of state.units) { + this.assertEmpty(unit.statuses, "the starting unit is supplied"); + } + } + + // The supply check only runs when a hostile is adjacent: the daily refresh + // never calls into the search otherwise. + test_the_check_only_triggers_with_an_adjacent_hostile() { + const state = smallState(); + const unit = state.units[0]; + const neighbours = state.topology.neighbours(unit.coords.x, unit.coords.y).filter( + (n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n) + ); + this.assertGreaterOrEqual(neighbours.length, 2, "the capital has open land around it"); + + let calls = 0; + const original = state._unitStatuses.bind(state); + state._unitStatuses = (...args) => { + calls += 1; + return original(...args); + }; + + state._refreshStatuses(); + this.assertEqual(calls, 0, "no hostile, no supply search"); + + state._spawnUnit(neighbours[0], 0, state.protoUnits[0]); + state._refreshStatuses(); + this.assertEqual(calls, 0, "a friendly neighbour does not trigger it"); + + state._spawnUnit(neighbours[1], 1, state.protoUnits[0]); + state._refreshStatuses(); + this.assertGreater(calls, 0, "a hostile neighbour triggers the check"); + this.assertEmpty(unit.statuses, "the unit is still supplied"); + } + + test_a_cut_off_unit_with_a_hostile_neighbour_is_encircled() { + const state = smallState(); + const tile = findSurroundableTile(state); + this.assertNotNull(tile, "found an interior tile to surround"); + const unit = state._spawnUnit(tile, 0, state.protoUnits[0]); + surroundTile(state, tile, 1); + state._refreshStatuses(); + this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]); + } + + test_a_friendly_neighbour_does_not_trigger_encirclement() { + const state = smallState(); + const unit = state.units[0]; + const neighbour = state.topology.neighbours(unit.coords.x, unit.coords.y).find( + (n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n) + ); + this.assertNotNull(neighbour, "found an open land neighbour"); + state._spawnUnit(neighbour, 0, state.protoUnits[0]); + this.assertFalse(state._hasAdjacentHostile(unit.coords, unit.civ)); + state._refreshStatuses(); + this.assertEmpty(unit.statuses); + } + + test_an_encircled_unit_loses_ten_percent_a_day() { + const state = smallState(); + const tile = findSurroundableTile(state); + const unit = state._spawnUnit(tile, 0, state.protoUnits[0]); + surroundTile(state, tile, 1); + state._refreshStatuses(); + const before = unit.hp; + state._applyDailyStatusEffects(); + this.assertApprox(unit.hp, before - unit.maxHp * 0.10); + } + + test_a_garrisoned_unit_loses_only_two_percent_a_day() { + const state = smallState(); + const city = findBesiegableCity(state, 0); + this.assertNotNull(city, "found a city to besiege"); + const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]); + surroundTile(state, city.coords, 1); + state._refreshStatuses(); + this.assertEqual(city.statuses, [STATUS_BESIEGED]); + this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]); + const before = unit.hp; + state._applyDailyStatusEffects(); + this.assertApprox(unit.hp, before - unit.maxHp * 0.02); + } + + test_a_lone_city_can_never_be_besieged() { + const state = smallState(["france"], SEED, { citiesPerCiv: 1 }); + const city = state.cities[0]; + this.assertNotNull(city); + surroundTile(state, city.coords, 1); + state._refreshStatuses(); + this.assertEmpty(city.statuses, "a city with no sibling is not besieged"); + } + + test_status_is_refreshed_once_a_day_not_every_snapshot() { + const state = smallState(); + const tile = findSurroundableTile(state); + const unit = state._spawnUnit(tile, 0, state.protoUnits[0]); + const blockers = surroundTile(state, tile, 1); + state._refreshStatuses(); + this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]); + + // Breaking the ring does not change the cached status until the next day. + state._destroyUnit(blockers[0]); + this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "still the cached status"); + this.assertEqual( + state.snapshot(0).units.find((u) => u.id === unit.id).statuses, + [STATUS_ENCIRCLED], + "the snapshot reads the cached status" + ); + + for (let hour = 0; hour < DAY; hour++) state.tickHour(); + this.assertEmpty(unit.statuses, "the daily refresh freed the unit"); + } + + test_an_allied_naval_unit_lifts_a_siege_across_water() { + const state = smallState(); + const spot = findCoastalPair(state); + this.assertNotNull(spot, "found a sea tile between two land tiles"); + const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]); + surroundTile(state, spot.l1, 1); + state._refreshStatuses(); + this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "cut off by land"); + + state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false }); + state._rebuildCityIndex(); + state._refreshStatuses(); + this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "no lane without a ship"); + + state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 0, navalProto()); + state._refreshStatuses(); + this.assertEmpty(unit.statuses, "the friendly navy reopens the lane"); + } + + test_an_enemy_naval_unit_does_not_open_a_lane() { + const state = smallState(); + const spot = findCoastalPair(state); + const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]); + surroundTile(state, spot.l1, 1); + state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false }); + state._rebuildCityIndex(); + state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 1, navalProto()); + state._refreshStatuses(); + this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "an enemy fleet is no supply line"); + } + + async test_the_client_draws_status_icons_over_units_and_cities() { + const state = defaultState(); + const city = findBesiegableCity(state, 0); + this.assertNotNull(city, "found a city to besiege"); + const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]); + surroundTile(state, city.coords, 1); + state._refreshStatuses(); + this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]); + + const env = await setupDom(); + try { + const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$)); + map.$viewport.width = () => 800; + map.$viewport.height = () => 600; + map.setCatalogue(CIVILISATIONS, PROTO_UNITS); + map.applySnapshot(state.snapshot(0)); + + const unitIcon = env.$("#layer-entities .unit .unit-statuses .status-icon"); + this.assertGreater(unitIcon.length, 0, "the encircled unit shows an icon"); + this.assertTrue(unitIcon.first().attr("src").endsWith("encirclement.svg")); + + const cityIcon = env.$("#layer-entities .city .city-statuses .status-icon"); + this.assertGreater(cityIcon.length, 0, "the besieged city shows an icon"); + } finally { + teardownDom(env); + } + } +} + +function navalProto() { + return { + id: "test_naval", + name: "Test naval", + maxHp: 100, + cost: 0, + upkeep: 0, + moveable: true, + speed: 0.5, + military: true, + attack: 10, + defense: 10, + range: 1, + traversableTerrains: ["Sea"], + icon: "person.png", + }; +} + +function findSurroundableTile(state) { + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + const neighbours = state.topology.neighbours(cell.x, cell.y); + const clear = neighbours.every( + (n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n) + ); + if (clear) return { x: cell.x, y: cell.y }; + } + return null; +} + +function surroundTile(state, coords, civ) { + const placed = []; + for (const neighbour of state.topology.neighbours(coords.x, coords.y)) { + if (!state._isLand(neighbour)) continue; + placed.push(state._spawnUnit(neighbour, civ, state.protoUnits[0])); + } + return placed; +} + +function findBesiegableCity(state, civ) { + for (const city of state.cities) { + if (city.civ !== civ) continue; + if (state.unitAt(city.coords)) continue; + const land = state.topology.neighbours(city.coords.x, city.coords.y).filter((n) => state._isLand(n)); + if (land.length === 0) continue; + if (land.some((n) => state.unitAt(n) || state.cityAt(n))) continue; + return city; + } + return null; +} + +// A sea tile with two non-adjacent land neighbours: l1 for the cut-off unit, +// l2 for the coastal destination city. +function findCoastalPair(state) { + for (const k in state.tiles) { + if (state.tiles[k].terrainClass !== "Sea") continue; + const s = parseKey(k); + const land = state.topology.neighbours(s.x, s.y).filter( + (n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n) + ); + for (let i = 0; i < land.length; i++) { + for (let j = i + 1; j < land.length; j++) { + if (state.topology.tileDistance(land[i], land[j]) <= 1) continue; + const neighbours = state.topology.neighbours(land[i].x, land[i].y).filter((n) => state._isLand(n)); + if (neighbours.some((n) => state.unitAt(n) || state.cityAt(n))) continue; + return { s: { x: s.x, y: s.y }, l1: { x: land[i].x, y: land[i].y }, l2: { x: land[j].x, y: land[j].y } }; + } + } + } + return null; +} diff --git a/tests/stacking_test.js b/tests/stacking_test.js new file mode 100644 index 0000000..9891bdf --- /dev/null +++ b/tests/stacking_test.js @@ -0,0 +1,141 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState } from "./framework/helpers.js"; + +function tileOwnedBy(state, owner) { + for (const [k, tileOwner] of state.territory) { + if (tileOwner !== owner) continue; + const i = k.indexOf(","); + const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; + if (state.unitAt(coords) || state.cityAt(coords)) continue; + return coords; + } + return null; +} + +function emptyLandPair(state) { + let fallback = null; + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + const pair = { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; + if (!fallback) fallback = pair; + const ownA = state.civAt(cell) < 0 || state.civAt(cell) === 0; + const ownB = state.civAt(neighbour) < 0 || state.civAt(neighbour) === 0; + if (ownA && ownB) return pair; + } + } + return fallback; +} + +export class StackingTest extends TestCase { + test_friendly_units_can_share_a_tile() { + const state = smallState(); + const pair = emptyLandPair(state); + const first = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + state._spawnUnit(pair.a, 0, state.protoUnits[0]); + this.assertEqual(state.unitsAt(pair.a).length, 2, "both units stack"); + this.assertTrue(state._canUnitEnter(first, pair.a), "a unit may join its own stack"); + } + + test_a_stack_moves_at_its_slowest_speed() { + const state = smallState(); + const pair = emptyLandPair(state); + const infantry = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]); + this.assertTrue(state.requestGroupMove([infantry.id, artillery.id], pair.b)); + this.assertApprox(infantry.moveSpeed, 0.25, 1e-9, "pinned to the slowest member"); + this.assertApprox(artillery.moveSpeed, 0.25, 1e-9); + state.advanceMovement(1000); + this.assertEqual(infantry.coords, pair.b); + this.assertEqual(artillery.coords, pair.b); + this.assertEqual(state.unitsAt(pair.b).length, 2, "the stack stays together"); + } + + test_a_lone_unit_keeps_its_own_speed() { + const state = smallState(); + const pair = emptyLandPair(state); + const infantry = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + state.requestMove(infantry.id, pair.b); + this.assertNull(infantry.moveSpeed, "a solo move is not slowed by neighbours"); + } + + test_garrisoned_units_heal_one_hp_per_hour() { + const state = smallState(); + const city = state.cities.find((c) => c.civ === 0); + const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]); + unit.hp = 100; + state.tickHour(); + this.assertEqual(unit.hp, 101, "heals inside a friendly city"); + for (let i = 0; i < 1000; i++) state.tickHour(); + this.assertEqual(unit.hp, unit.maxHp, "never exceeds maximum"); + } + + test_units_in_the_field_do_not_heal() { + const state = smallState(); + const pair = emptyLandPair(state); + const unit = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + unit.hp = 100; + state.tickHour(); + this.assertEqual(unit.hp, 100); + } +} + +export class PillageTest extends TestCase { + test_pillaging_removes_population_and_heals() { + const state = smallState(); + const coords = tileOwnedBy(state, 1); + this.assertNotNull(coords, "found an enemy tile"); + const k = `${coords.x},${coords.y}`; + state.tilePopulation.set(k, 100000); + const unit = state._spawnUnit(coords, 0, state.protoUnits[0]); + unit.hp = 100; + const gdpBefore = state.getTileGdpPerCapita(coords); + + this.assertTrue(state.requestPillage([unit.id])); + + this.assertApprox(state.tilePopulation.get(k), 90000, 1); + this.assertApprox(state.tileGdpPenalty.get(k), 0.7, 1e-9); + this.assertApprox(state.getTileGdpPerCapita(coords), gdpBefore * 0.7, 1e-6); + this.assertApprox(unit.hp, 110, 1e-9, "heals 1 HP per 1000 people removed"); + } + + test_a_stack_pillages_once_per_unit_weakest_first() { + const state = smallState(); + const coords = tileOwnedBy(state, 1); + const k = `${coords.x},${coords.y}`; + state.tilePopulation.set(k, 100000); + const strong = state._spawnUnit(coords, 0, state.protoUnits[0]); + strong.hp = strong.maxHp - 100; + const weak = state._spawnUnit(coords, 0, state.protoUnits[0]); + weak.hp = strong.maxHp / 2; + + this.assertTrue(state.requestPillage([strong.id, weak.id])); + + this.assertApprox(state.tilePopulation.get(k), 81000, 1, "90% of 90% remains"); + this.assertApprox(state.tileGdpPenalty.get(k), 0.49, 1e-9, "70% of 70% remains"); + this.assertApprox(weak.hp, strong.maxHp / 2 + 10, 1e-9, "the weakest pillages first"); + this.assertApprox(strong.hp, strong.maxHp - 100 + 9, 1e-9, "the second heals for the 9000 it removed"); + } + + test_pillaging_needs_enemy_territory() { + const state = smallState(); + const city = state.cities.find((c) => c.civ === 0); + const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]); + this.assertFalse(state.canPillage([unit.id]), "cannot pillage your own land"); + + const pair = emptyLandPair(state); + const neutral = state._spawnUnit(pair.a, 0, state.protoUnits[0]); + if (state.civAt(pair.a) < 0) { + this.assertFalse(state.canPillage([neutral.id]), "cannot pillage unclaimed land"); + } + } + + test_pillaging_rejects_an_enemy_unit() { + const state = smallState(); + const coords = tileOwnedBy(state, 1); + const enemy = state._spawnUnit(coords, 1, state.protoUnits[0]); + this.assertFalse(state.requestPillage([enemy.id])); + } +}