From 0d4418ef44ed4287d7a5d50bdf8fc17399d7f7f0 Mon Sep 17 00:00:00 2001 From: Ael Date: Tue, 21 Jul 2026 20:49:44 +0200 Subject: [PATCH] feat: add worker command center and active roster HUD --- .../record-art/manifest.json | 80 ++++ .../images/realm/hegemony-worker-record.webp | Bin 0 -> 86984 bytes src/components/WarpkeepExperience.tsx | 9 + src/components/realm/RealmHud.tsx | 78 +++- src/components/realm/RealmMapScreen.tsx | 16 + src/components/realm/RealmPlayerChrome.css | 1 + src/components/realm/WorkerCommandCenter.css | 1 + src/components/realm/WorkerCommandCenter.tsx | 60 +++ .../realm/WorkerInspectionPanel.css | 1 + .../realm/WorkerInspectionPanel.tsx | 66 ++++ src/components/realm/realmInteractionState.ts | 37 +- src/components/realm/realmPickArbitration.ts | 19 + .../realm/realmWorkerPresentation.ts | 345 ++++++++++++++++++ src/spacetime/WarpkeepSpacetimeProvider.tsx | 180 ++++++++- src/spacetime/canonicalGenesisSnapshot.ts | 10 + src/spacetime/playerModuleBindings.ts | 53 +++ src/spacetime/warpkeepBackendTypes.ts | 21 ++ src/spacetime/warpkeepConnection.ts | 176 ++++++++- tests/playerModuleBindings.test.ts | 15 + tests/realmPickArbitration.test.ts | 9 + tests/realmWorkerPresentation.test.ts | 75 ++++ 21 files changed, 1235 insertions(+), 17 deletions(-) create mode 100644 docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json create mode 100644 public/images/realm/hegemony-worker-record.webp create mode 100644 src/components/realm/WorkerCommandCenter.css create mode 100644 src/components/realm/WorkerCommandCenter.tsx create mode 100644 src/components/realm/WorkerInspectionPanel.css create mode 100644 src/components/realm/WorkerInspectionPanel.tsx create mode 100644 src/components/realm/realmWorkerPresentation.ts create mode 100644 tests/realmWorkerPresentation.test.ts diff --git a/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json new file mode 100644 index 0000000..02ce14d --- /dev/null +++ b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "id": "hegemony-worker-record-art-v1", + "recordedAt": "2026-07-19", + "purpose": "Transparent decorative Worker art for the Worker inspection panel", + "projectAuthorization": { + "authorizedBy": "Warpkeep project owner", + "instructionDate": "2026-07-19", + "scope": "Use the supplied transparent Worker illustration in the Warpkeep Worker UI slice stacked on the stable Realm PR. This authorizes repository/runtime integration only after review; it does not authorize merge, deployment, production activation, worker seeding, or generic worker authority.", + "notGranted": [ + "proof of underlying copyright ownership", + "a public open-content licence", + "general third-party derivative or redistribution permission", + "trademark or canonical-identity rights", + "worker ownership, status, route, cargo, reward, or settlement authority" + ] + }, + "sourceInputs": [ + { + "role": "owner-supplied transparent Worker illustration", + "repositoryRetained": false, + "originalName": "codex-clipboard-1312042e-175b-466f-adda-9bef7b12c1f0.png", + "bytes": 478174, + "sha256": "f6ae700affb5ce981074c7952bc81f90b60e2dab947b94867c5394d3e23b4d6d", + "image": { + "format": "png", + "width": 1024, + "height": 1024, + "channels": 4, + "alpha": true + } + } + ], + "processing": { + "tool": "sharp 0.35.3", + "operation": "lossy WebP derivative with alpha preserved", + "outputEncoding": "quality 92, alphaQuality 100, effort 6, smartSubsample true", + "runtimeAsset": { + "path": "public/images/realm/hegemony-worker-record.webp", + "format": "webp", + "width": 1024, + "height": 1024, + "bytes": 86984, + "sha256": "ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e", + "decodedRgbaSha256": "2e77492f76801576adcc9cfe660fa15494123bc43fcd767e005cd5ad95d8b047", + "alpha": { + "transparentPixels": 858605, + "partiallyTransparentPixels": 39551, + "opaquePixels": 150420 + }, + "visibleBoundsAlpha16": { + "minX": 256, + "minY": 50, + "maxX": 863, + "maxY": 934 + } + } + }, + "presentationBoundary": { + "component": "src/components/realm/WorkerInspectionPanel.tsx", + "runtimeUse": "same-origin decorative hero art in a focus-safe Worker inspector", + "forbiddenClaims": [ + "private FID", + "private cargo or account balance", + "browser-invented worker ownership", + "browser-derived dispatch or recall authority", + "reward or Marks linkage" + ] + }, + "licence": { + "spdx": "LicenseRef-Warpkeep-Provenance-Required", + "policy": "The exact project instruction authorizes this Warpkeep runtime use but does not establish a public relicensing grant. See ASSETS-LICENSE.md." + }, + "visualQa": { + "transparentSourceVerified": true, + "visibleBoundsRecorded": true, + "runtimeDerivativeViewedOnTransparentViewer": true, + "externalRuntimeUrl": false + } +} diff --git a/public/images/realm/hegemony-worker-record.webp b/public/images/realm/hegemony-worker-record.webp new file mode 100644 index 0000000000000000000000000000000000000000..7a8bfa3ab6f0d6411cc6ee53b9cb25e47a755fd6 GIT binary patch literal 86984 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 +*lsVZ=1k29F} zgxb(B0VzMaC287!Bi;bREP>YbXbVELDXXbLt$dGS=|1dRQ59xwRl14yB@-E84 zPY#ebm_Ge2sHLYh{qlMS9zjOrBc9d1TrK9R>}4a!*eQeB-zNh zweLncLL9bL@_oa0$Ny20XUS8dd63`-9S-E?&XtY*kgG;TP|$`b3VyLl zxgjIa%@BABT(o_i2(0hfg2EZ%eL4j&Sk7`x&`*(PL+_{dw5@agU*Ot!Bvnr&FPo(9 z2WbQ>CN$Q(pn&QZr?a;*OORXY!>b(s@N`O*(CMrI*2?js?9op$hgZn6^b~Epa13LSi`3Po?_?i`Kb*=7&|ZW{o8o2^$klZ#kBcZcXj^gJ2^KdY()kGD(9w zmCPM2VSNZ?V~*p($Dad12)^S8kXX3}%}1J3c_yGEPd=$hdefBmRO#dUy?7&sMI>KV z!j0tQ3ohs@K| z(D%G=4unpLM3jIE%WLujZA)J?=)M3U8Zbe~&?rKj&Pgygxk4ItR=fciL7Px+SGBdJ zc#<^Evy+wG+m{q=7RAgEr1i;FuVw^_IL3;y@(W5>BCMH>Ixg^`{5x&f69rQFmd&8B zGT(`J*T*W7fkWwmqvzJV^5|Y9(#Y`}#6zZR#KWb2xUWjQIQrx!noxFFYm(_&ap-kz zOTaIHlUFy6_Q8fq>la{Zqo=VvNqd=ncJf{xb z`vKRM0nZQ>ka|39oJh$5azw@9AJUS6N3LIX{q+~V`}K=wzDDJ$k!|IyBpeTFRqO77 za)9QB$*K4Y?HPv`2q3C9MTOD$V^`xs5lU|%L<;H`y;qph6%|36kz2*&F$7_;1O2b+ zX@d;{HE{-`y*0I9{`^Z*+fgHPXZ-6j8h#QfPkT5jojICNuvWA5OpDlu)) z*%KiCETB*7T`y?SlyJ+r-5ZSu(Xz1-yb< z7?X2}Sz#jbO%U!%9pvE;?L4E~;w=0LrBSctOPkNHmiR`PdNvIBo6^fTrnCbbuvw*i zaIVntSYzM>{ z_Yi5ZFxv^X+r-SdFH#6liw`qZdg2RS*qpNK!v|&rGsa}tqzt!A-fTK(fn^gBL)gK{ zznLsCq76aa-`Ir0DF5Z(1GyWx*Kt~1# znsK!P+aFS9utnwkNX3I3)S^n-S%c4g?4cfXK;lp#f&C$#107yOX4ERm6l8S-%zvL4MaIC-*`so>U`C5W6K-qaPL*QDayF+6 zh8|lVK(RShk(?AvCs(|*%91d}9}D$4idVCmz9nD2*=@81 zX*K>3%@426RFtl@&q)FJ1-CCC- z$;RPW3M?L22_D&~%w=Y3j!hn1CtOSY9rwk0-_vLk2r&>#V?VXXZ@P8{~yQjSKIVnpE8>CS3?}Lca z$sdmUDw4HcoV2V^2u#}h0r+Y5!qyhv*s|YvxFwr-CmdnQXqenaGn2rYbii15`<>5) zLl3moW*U?UzHrvaVOa{&Qbem4Z?r#L6}qQh3M}Y!U#p~*WaDFs8dggmR8AORcjF>q zgZKcz`zhi55ioI{&9&32bu77Z3ranwnEc!7K1}uJdko?cM!|?cOiumun?-riVN1q!azTT1v#X=f?KvP& zK$-zifrMCy)9K|&tIX9h4bi5QhxC7o1`6SvUrq#Lnq%YNzuzWpJpFb7O+~;~5rO3A zHOJ-5xPW9^$v6Qdm6;=hcuM%>QIk@g=VhA1DinI-*P~_W49O9t2Z!jGfTbXvUABB86F{)@5vOFH~E2*yB$Xlvu3Sdv-2z2~qeb{^Er?M=r+mzu*X(1!m(%3IO zPJvhE=}3>2VS`jQzNuUmJG#0Wv(9*QEJ&6FII_+pSP~8Wf;b!{-ZDlTzysCr2Ad|w zv^_na&L4f-#-6QBSK7GKwxTd6VIfV&`kIUHp65z3^BE?R34YVK+ky{fMrbV$TLS(v z*YKYzwFReVpLbqO$!iG$Sc73D`QNFK{T}ufzR8bvsY1h^$SFoEy*}=^C`OoB*SA+a zX;iLJn!vFaXjK1SvWOkQ*nWOp^Yf6IM~+!Oiz#OLOyv|fywSp7WWvx&y<(L~tqRX2 z=Ma+XO7|QsQhRmps^?eRC8wp0mRkj~GcO{~tvMW%)Q@Kq;bR*D(!+gb~_iW~#@Y zO-6%#!NyTk4hl|f5M(bKZ2~D6?tWJ48mx>%d3IDHxy%{)FS+@nQ36}cv z<_kqROMW~z{LcSAU@s|dh#-$vF>^{% ziKISxm5`+@n5+};PV;9Q=L*~;flDq*EXlSc`r!O2ziS7dGjrtYor z3!J>GhW`ylRST83K+_qZ%`7c~5Px z>PjWdSjR|Y-iImV-uJCLJP9R`S=m@{&!%#?94^lezy6aizYA`UY8q^(RZ@rhQ4!O5 ze&SRrG5gfi|May=2Zp+uevze*7&P&1>D1@;p6TS8KhqS-G>st-bk`iFmRSu1Q#1*} z!TGGxZOQ?@b6%1mw5nOM&VOO2RS&SIGI(B-dbemA1`ET4+@6#Ua|VR79U6!rrCg%u zrZx?YVWbpbUj9(K=3USt8fRt||B&?@7vt0)7s8MmIq#eObz`dd#DA4V&JRZd5iq4> zqSN%nB5n-iaf@8@icDFKinT~6y~EQ9Gh4PpUL{5JC8@LB%D8=O&;TVV8gR-Be1`3M z?znRevD}`RdHPWj^^qO?F-7eU1e9$5l2T0AmbkMgqxnXfa<#PbmaX z2a+diDTRI0Zb!-Ty957z4||7$w)9N)1zpV0==cFhx76{|_XF=$MTtptVwA0`x+%A0 zpWzu1y}%!*B#kM6>gEqzD;eZbL!3S$fLh%~PCppzM7=XiO5uePA9A}kc~- z`lR16+4*B7T+g{?3JL0vo4Y1oCdgUsq!Xc3cenceQXu32%rC}rYkCZThm&P41i&ym zSlh3vs3B%FGh0E+hx1KR;ez1cgGtz0PC9t9_J|zD4k@!OY*IEWOBjsoX;~c)+{+-W zL7IuRm&TZ?vREL)BQPZ7*Xi0-?_x~zPl#!a?=fQ>v2+|ItpV8(2UfPE72YqH*P6eF z_tYLv7m)Grbx93*+vl4#QL^eTf3ImN@{sxKW8JB+#2|nX62{fN!={5j;xoLxQe{}~|)M~)rLjfyS1`*uejny>#hcnEUcARfL? zFj(J2x`a}bW~vPT62(dl&Nld}Lb=ZD@Ou=o_&XCruJZtO+j5Mr*9Z9b2j0=I0~-8v z5jN8SoZ4{v3R{)WUtID@5X4^ZS!QN$a8lM2%v(p7QzcPi04BUUp2V7s7m&VaT*TZ7f;t+30pQ({D4klc zBn~VLSZa|uS`g|mAl~hw3dz6|{)4C$yIqi2J1jOZ%J;(ti~=8ZQjnC=kE)c$bGU|oOY@1s zlt5cvBOpo>H;;x=3&VIEAjZI^*()x}HZy;a&Tq{Ekvp^LY(UP&F)?P7#9n`#yoAjJHe8f_<|PCyCfIiPQV@?_ z4s%F(a6T>>ZqU?ta$~2|i>cfLUQirW_xe{Il{9BK)0nGzCk0 z0Ib}i7)3F*x=}DJ63HVL3r~REXG923ZH2y@!T_KQV*|+&8snv~_*pQu5bV>8{z*{Q z>K11De8 zd*eU~UK|bvgI%a_+Tymgg@D`I*V3Ols5q+jrQz^X1oo{gYrhVPJE9702V^veYgB8GhbhL-2?(9$#=gmSW@+#sOKl$(AsJ(Rvc*q?P~AVd7|^M z;KbSldGin#L5hkKbG{MFX?tbkeu_@9&ebJAFF|_t`5A2>+L@@Ug|-)xl;ITL%404o z!f#a8(-L`^r?(WfVlPu8@qpEoUgT8@5z29NqT%w=gq3y{$GbnngL1Drf3HQOlrAOM zzHo$K7>q2X?&ay;)c+pA`6W(N^ZH#cU$Q|xeV|xszzI;FIpz{#mos;o(F)t*Fs-qh z&cpiSsVHY^GD=QSiQGLbAu!2wlW7_PS zQ||IKUVU+A6WKWj(JO&scW7S<7KP~>S3?ZNPhaSuKDEDvY@i%niqe;AIJlKZ777lF zPdADSW?M+rS~xD3(?(&TqpDnaCDK7$ZzTDKIWn!7DH{@EpZk$z@%0Y+m=^V;GbdX}bUpiQ7=k7YQd%rTwb_Hq z4Fn;P4{34{J5!tHyH898LYSlL5E5GE_(w$5u4P`>lye=fa^~%f3Z*+>kjR#e>9Ugd z{OBw;cuN=!_k@G9vf$DE0O9xglrfy>!CqH=GL(cB4PcD{rC5udG0Q}0l`4HOrqQp7 zx$mJ#gBhh5OzpEhDT+rWj(Ki76c&;Ejmx|b9~Q@?QO4=_W9;mw?**yK55yn(`ZmpY z^@Z>o3mdicEN>MtBMOh7)-Ue7k(_Ay6I7J0nSgonnr7g%|@+l804op2jQzx*29 z!)CIpG~q9%y-crKf7iobGwO1M$$$OK>V1~X8D@5}VPqfBS!4zD);hPyc#)WNbTYiT zxt6sC47y;8$VtFeLDZGmUy zx%^=zx^LkLu%lnL{~4l-$P;Gk?A<(pvcigp5=-RGZOhG~_WaGw1#^-U&ttFA3YO||Xp9>H z>yIA-V84njepMUdde>UPyWO;dqMJomeqyd9N9IUV`lm+A@ejB+ijs&;Nm#FLf#!?Y zxPDE@gbz&;gNrKGe!UEoknb7!!)=&-M`blRAHdUoT?!+gt7^q@UfXqKhN1kEx-vSE z7`MguD>;q|*FJ4ZN8-yTJEJ$AG7A8(2l!u^FvB-?qf0KA$#^$@O@xW}(kQ&xoE7kB z5jGzFA#cq9;1@bI3ka75v7Wpp96FiKe3pSgE$9uxBImtoJvEMSqn7~n1Y&<|7CSXL zxM`7{WPcK-*IHxh^ngDVxjF!xEx%ETaw zITGu;YI&{t>ic7F6O_=GnyxXBb8;Brm}IQ!dDwVeg-Hc;wQIAfRyvpHyNuSSr=-&waw^$J0N)U z-K8e!Pb*Ytd5BD68;*SW9gv|69WZ3T!ndTzN%8u}m2_25V&0m$(?MN*^{OkqTpd41c{nR#k5w@kH(&j7++ zy09`HX(SI z;vI0ZL;y>P%H%uM8tOu$(eDL~wRS9qngub!qE~ahAWJ1s<%{`Q?tb&u;o2EMwbmtg z5(v5bd6sluc2>Xr9_kqzjllD$|8lc*yH13zACQz7`odha)PLNX9_Z{n(r&aggJ9gh zjW+xgp8g#lgu=rRp4knN{@Bjj%ql5ol3>3|4d|jZJ5iPmgT*WdU_f`M zU8IWw(&qi?Y)mQ$Gj{a21Xy3qTnjIRy64E&slE6Syf!9t5nt+Al65;{wyOxU&GVOw z0DVvI?JMfxcWakYJ+=qSGvRMCQT>}33}7-ZJqQFHQzoOyqU?VwjGM8QO(;41x`>AC z%@Ygc(|<0I5Z5fUH&r4g7I|pX*Xdf4Osbt^Oj{ka!e+oOb_JP-K=YWgqZa0lpek4n zI?~p*I6)I-gy1@U1s^|mk<8H{4cTuDiINxCEe(njc*6)Ite1MZeRM&yGcXbN+TdeG z3e*{&Bfz$VZxhm;dm)?Th9GSwblU7b?I$u-a#%jZ?L=y8i+!89&T#+0+otRLP)cPh z+xe_O^E+NQk7FSw;f~cfl6*i(R=P8!?fF7;o&2e|cO=Qf8aUlll2K^KfMudx&91|x zqwVF+=ot+;eA~oV8?h(tLVa=kggpR~JO+T<&f<6Nbk$hzv0+3YJ33JuLgXqqezQ!g z`(x~`c!u6gZ@ErwGwAod8ea0mU&)P!Z|cgH_9d|Nq3W; z(>MWchxRvL7DLVOt#1-A-b8Wr08JvYh1qPTRq3*rkaVKUh3;I1((tV=Bh(^U)N$o8 zD?b_(a?lJUMi7+@N|@Fr3d>hwzeP7QZImDXr~D|`(on!th>S#kW%e0jp62Ln)Al!6 zJQIWQeVWF=P^MF9pIw-w*YqfEZhK{R1+6+c;cwo?HimXrWU+)qW!@mlh4?soW05On z>8$vkXkS zK@H(42i{|zXEnrNlE5rRO!tJgIzZNfl@;cjSw`crysq}B!Gwob1^ITsf_TtnNlUWM@3 zsqdeq<8baB2w2jUh`CqQWR(TzzH8wcb-$b@O9&fp^PCvCzEa7G3#b$A9(24vO$Gfwyf<|{55M%6}zQ}^z0gY z=^P-Hk&xKF3TGirncHP-PptyfL&2;p!cQ*$ezT!98# zH=mh&H8*2AeM(?D&`-WMcei$swacmly)gTcWS%HC5k8H#-+9E;!`cqB^X2_u;qxO|*etF#IfzY_C*DsEO4KOc?s@utZU z?rCW=g&C3VDv6^^-VCyudtS!zzLs}q*>^gy)FNcaX|1d{v_ufWol*^)XmS zPRz1fUXDw3XS0tn94099!fS#NyXt{6jTkHIj{lGn|Muuo0INenDA|+OBFqd6JwZ49 zR+1+qS(YRFdq@Ls>>FuZ(1Bm8Mz4WE9(iDjVQy9_thG8RvO^(B=_Et{T_`ErsMB*K zPYt3VWXvasg)?$=m|OeGq}*7E{WLt+%O{LlDKI7fe$zC(EkF`6al9|TR6$TZV?bHq z_rCfn)i}s+){#DZ>hu&-Jp0I1iQ%%q>^f>k&O$7=9rFwGdEXCiHh+8`uq+>6cZyrQ&m*t}Fkum3)6|W_XH2!sF;5bnk#A=|4j&GAd6oRWU4SH%g1| zDS$5Iph9h!B0TGcZ^E+dk96qUP;Y1MlKl^X56?+Xhxy_C`Z&c1uJ36qo!{?Fx$}wY zvSHBgOVV&zzWLxxO@o{-Ncu~74vX|e)Z7b~7biUd;L{>Z-(bZFCfN^(&n1ad;9ir1 z#1G9P12~ z+AyUlBF8VxPfr@E9Aw3t(p`Zlx)h))zK=+7+xj(~G8a+l32i_xb^KB#dZpyP!I%>~ z7^!`ciu50Kqr}6v`e+paWp%a43ET5RIZ^|XmAB)20bw#t2s#1rdNH*;r^Z_f-{4=9 zMP3`-s@^x$pgH;5;*IID>p2(zB_9ftmU|Ou5t9No^{SG` zFUL@!Ix{kgIrj{#mK?Hb?`q91eA32iuNPb04JLr@4T*R;aPeD^4h-7?nS1~gh+Rg0 zn7W1;U9QTl=`A0wot@iAH4b#=;jXTk-O6h5T@#X5B(eWcAOwJg7YTO3`?=bz(Vd_e z{cob4d}^gde;)phZP%3Vh2&cwH^C?>ufxFOVGkJ3K7@}os-?1XozJzOqC<-ffnf-- zVL#I@VTVq5zP%sn^5|YXX(B?{ccVvV(`dyMH4<^{ECecZhy3;}sR}-6&)FP*v{_{R zaFO=}f8CDKgicXXvUw_OGmkbpKixGh81Ph)YCr5-DUE$|hE0L3D5bMjR0~V3vOsBr zJ$&5*u`;7v1cWGa9>R`sjXCdEGN?1a)3LAMt2zf` z6UqP&D~~~pdq)t3E}#*qMa}dYNe+d>SG>V{a08-VW8v?SgE~BGCAxZ_t^~fmg%<0% zH!2h>`qIrZz;HmjQSP_(w}8cFPlB#T?5ZM)a_x+_oxVQQet3b*Jm;1^osm;xJNhkk7&m-(sTz*JuRKP|q&Bm!B-YxU%3T&izpfTd-Or0TrnrbUV3D zT6|os>NvgL<8@M>&87>eg~>_I(JL@o)}VWFO6t9H`!F}-zeX!fWTy}f>Raho9Y)czheE)YKGdSCIgHzLV|i^!6seGBFPuDq zws1)-zUiNO(Cm5Sf~!J9gfrplG=$_KkPk921(oU9DExAg0hXjTqq0)0y7IEFI<0_+ zJ>222!MZY5#TXPRB@}A(*y-?*MKwa$OY3i-WmLj`DItuDOtHw5{PTj~&Urc;$tZ{w zCNU1TM;6FIpPRoRjn^CY*EC+8=7k_C*RgjildE9H37Ux1yGKqzBUK0#wZVDxvE3jwk2*Sdywz{*+sw zcZGZn4T{1k<|x$(LE3!@aGV3zG}P>k)sMY?Lj>Uf4s-7a=`E(xY=Kf8BLd ze>MFh{$@HGdp*h&UE{F&w6JN?>(#<`O2l`KXjrS#8N<1~zj%XNZ@s9$_8Ux14 zy=-=|Hob|5ApE?;`%hOg_UjktIAoe*|KP0N{yTdD^~+=hV1d+wFuepgo@dTT#HUsH z9M^;R{iqZ}kV*zjcSmggyX|gxO|78@-O!=-$3Db~DS!sD^3A9K}Ye`$h?&=jjXuuE)1a zsE?S1?>MS0fdweLe#`9Az+~&!Iv2V_zn?8xK1^p2OC2(~`dBdns)-VWQ+_Tq>UEQxewCh*Lvnnm#Z-)d{c5ik> zq)#!drmjM}eKy+$m@i*A+^-wBWL!UYuZo>VU+gRcRP_&p7w1f`+QfsVxbOO*Muabt zT2)*7;I1rH(`X?B)zT;7JVGVK+a^z$O6rY|0uuueE~uS)Z`yySb;%$nw0?K0wwaul zWsKvLo?Mgz8w`AUHtr2~3{f(EP!l@tbcJ$9wiR^DW%Zn_yoKF=_Y8A?dv&n1@r?)0X_y^TKth=K zzT-^18i-c1X~#?2UY_5;3x}eUI29e-Zfl#fZFqo?zn$By4w-t7SRCO5+YoP7m;Apt&adVv2gn-wcw_NS4`m)s{n!?xe@$BZXX z^G4v}Ys3%u^|JF{8igI~pNd?>daWQJO!(m0pfn&5AQ1f2=;0zH`6Oid32PwskgyiE zpHjaHDgf93_EaWkd2Jlv!Oqe{#*qM8cj~L1M$b9$>@u}R!0Jn?of6>oIt2fry`>GSwk=Zg=343G-QH56FsnFekERc=H87eMuo8E1hL zfhFJxkP~om2>!1A;`ip4OT@kN?DzJO3Je5B08apRy_^pM*F7&lK7P}lE5M=O*c&fk z3Wx>_`GAiQ2cBK0e}cb+trE59Z}*`2S@D~E z+Pw)p=BDKM1K6LDPJ8-&5B-u}$bpnoST%kwAL0qwg%fq(pLfMEc%FZIio1mv-}z6#|T&fa(+kF8~dw{^Jk1gQTb;t7y(7c`VvINlkl2p--yO^yzyIgpF z9oj^x?#y1s-H~4+s-yuGmz${`A4k$IC!v$ll{t?6V`tNcuYo_F% zowNl7;IaSDl95yQf4gFgOU(YC#Sc;ZO|?+cZ@NlL^e_Dc7OYxw7zHQ>exQ^7w^0fg z+(?*kV{}L$pg=xTC5Y#hKI6uu<*5>Phy6D#p9r~O%Jye%%U}PG`cA8Y^xTC3-*>W4J{`)32TTr)(4#_5dkl;~#jmrlP3 z`#ySx|9xIH8>9A9ZqN+OiuB6|!d$prqMd;!IG$&^`}2yyf@ld%r`Z8Bn&;N@^Mjxe zW)xYeE*#^Y@cDhvE})8pc2BS$sH2>?|24H*_v zw~$${L4e-#oUX>N^Z&Y%vvlN&?YIw4WjsV1ZlQdvCMuRj@_JfmPxvh_xM#E=gnKK` zyDtC{a|a~N4$tW~?X^ZZ&8{U7@85`=N+j-T=j6%c2hv6KeQnA1&l>F6Sd*3UXPV9XHWw(Ke7aVUKxZgmz0>3*7!M~ zSdSXoE*|Qg*0yp{Kc?)NH2?GpJs!+2jSo>Z1jzMrg?Ee;gPEOrh9wv1L3U_INqP#x zB{u>mAEH@tB<63!^4jlBr+&+KnZFou=@4x7UcOSEE!_#_42B%>G=; zVl0Km{u>&zwT*|c?)1E@#A}^K!hc6g#ubU*%)IUoHD1*xjyr+ znlrM~pTH^$=+j}1uwP*Fgr=naJKPSA?-ieyBFLA(_j_>iX>Z(Vd;VZ)I_ytDnho=F zZQ#(~7VU~LJ}74=zf^rr0QO$B3j?ue25Pk;Nq0q#qnhbH=1~(5z5H5zgLskVhwmlP ziLc9lzLa&v|89yfsmg&3fc;&A00i*ItmI+)9boL<19&|a-A;3#1dP6nx5ag-Rql4a z?k4^$7EZOa{i!-7bel7qNlpNH(0n9;*J$bU}+N9nuS$qR%hka z-DPH=zJRYxwjA(l@=1*k-_%StX_i)M;b{A-QS9%tS;a`iqL2*T@9;=hTi6$00&hRM z-hAJXTUWLGDDDa1Zv3cfP&xJ^=gBw>#pv~!d3-Wp?WWO*yN6II|KZIWu)9-8F2vAj zS!F6oFctuB{hu?)Se@42X@kSX5unx=oxly2JZcLOwHB@7l8p2ElYcX@nco-B=z|)@ zNt>%7(3JSW(Y|{%FzIMr05UD0ns9AVBx#x#gn5^UC<6~}*g}MIOMPp;He@HQp$&Ma z;g^(TI%O_!LKJ{cr&Bet+>k&ZGAceN>@)b6_MHy;JyzN5^ujOoU-iL?KO`Yqdc(%P z?mAQajP~z0-H>2hD}vWr+uHA}y&t+Pvm39TAHuDqdC&c>roH~thJ^X>h?lWmowQ0T zJzb+|k-eHo>shLfR7dkbvHeKgN(Rfh)8KYRLqgQv7=qwhfk{;Ee&B??Dm8fP>fNtT z@V9IxR1E};m%#C`8QK}9o||u-AtpW`3J2Hl%g!irmI%Wgo2Z#PmZ;17x`*8+BQ6yM zxe0h}oZbU`@!Pp!ZRvvU6D5QkMts2(u8uZcNG8dTeJOtOsF$-rKFfmCpxGSjqhh4r z+Q0@Mixk&`6Xm}0XLtwA86dR!ckf)}Rb>p(S*pzZz3vY+agG1@2nImZs+sM24Nrh5%3K17DrD)8gPa^_uCSi!{NUiMyw#X=5HvZT ze1I5yBU$oA2(nnshi1CwAeh01XM@E;GV?A4{RpQQoWp zy368+lE=W7jBFTW<5U2giQ3m+mk?XIT%ugr|FVKRX}NNSg`^rpw}Sh1N|b;A^FlT? z*X@`1qK9?Y^;>i4Vd)1ol2nV?(}r*>7BY<{k9T}dY0?Cd?BJ|o=~1%@%t$;;n?)x2 zS||srVJ;2yai^|M^Kr(9I(SZ5?Qf-08or>thP|SqEx5QXL5RWb&h?#Pm|ef%Mgeb6 zlI5R)h!dnMX`plOv>w}k1Qm&XG;tPDb4UQQWxp}UbY9Ga`^8y2r0^HGZNgQ|KZ4I- ztTo^k&>OBrV<=e@jOs{_hqm^2O4y67jPX4&_1Sf{Nww-1`nu3n_gG*vY)E#UW`$=S zRkI-xke$i*e7Z*S$s6kkl)JWf^xg-9tL$slX*#m#S_W*{6?RD zw_mg#0?T`g`q$NbfCHpflUPuiO=%w9KRy+YADPyN$vatnyl8k^OyhKqkg~GysqFkh z409$+jS|-4(8s`g*V*E#8mur1Ado5GuBbbl>^xUY`hb4LEkWRD2Y-Wm59Mjk>rmk6 z|9uwx(qFZ{d8tZ5Mm+|DuEi7bscZ0BwNN6}&eonh8wsK`S+{pI@Zb}X!OMTrm&{C9 z{^TVS#d{OR^qJXzM9Os@2v#!v%{u3ILExM4^g?ZI#RytVuuz~M!^=&PwV+kjP2GU# zoLs)^K+2v`<;Y$t!QKggz-XlnOBIy^FVjO(3m5+Yc-B&n+S$g!h2 z?C;12bpjLDX##CFhHgWN!Pf{kx$EtHX>{$x*Va1)ar;zyqf11(e=3qqzTwf&8Fa_-ODCehbeQw=mOqr#2F4{Q`LFsKkQWm zLkQ1NL#6-P*S`f!v^S1*`}YU1nrdsA3e0hlRrbuq@bPE*FdUCBh2}A&UrCyWxuoG- z==_U#J_i2BOon`kd#06a6X_pL6F97N?pclJ1JP<#I1}9==$jj1Pt@8m#gUJ9?X@on6coR+?_rLe+jQ*r z=P0rm*MGDGIyt>jx0iF<{RjZ!8h90Q&Ml{bS~|~SPQYx z*~~TC5n~^`L%sZqo{H0>#KPDqXLDk|)0oJ182h=VM=*v0_hu?FkGa}Hr@O%$+o8Ay+9ly)4+Vjo7nRLYbM{BX$Wpm8Ml_W)e{ZI z08_))Q42$e;ah$fzXcG|uyidFQs@EmA3?7fVh8UX5`|X6#=cUNP4CGRG*VzGMHB6VtceE`6CTHIU3^|r%&X*N4eC?j;+9c zNp!&aw+B+W@a1ltu_=NxMrBQCRhG}{SD{c+F@ir|;mQRfj!?G+NcQ-6dLP1_-jYFA zk^eMD-GWW!`ig%?j*0Y)$yCZxPq>dGlZJ8UuGW*>S= zQ3urQ7gLSjX_(! z>73hK#+UBw!aLK`lasdoI+n7dOKMBIW*F5F*|!geBz*@GAKm;=Vc)3quh!thrm%a^ zfL#p=Pp&Na)6rJ{3c%I+&x}$!bZR>qRSr6}dNB)-E1+>gOR#e_{e!)cS8lShvMv;D zscpm8Z@X>COXkK6HS8{B(m{5H2`ck8i%t7NR$QP-Oj%1$_)(t2s>wZ$G%c<#C{jXb z8ZItXVa=Z!s3fu2mN#@nD2J6_ha&K44sBEO? z80|;6fOMUv={uk}RteMNge)AxXsyj2o9CJ6`p@(84?1r1Y|#ju;7#SIH{ zwi61h()jh15l@TItRV>bZ69fd99HADw`#@6dCt#op*UK9$zRRjHz;j4>%FW(AU@*1 z8T@~t5B&n9dExxV44qeC{LZ_E@x$dmPv*c=QV5Dzh``)F(-vD|sd)fNOGATY z%SbmD;h(WC|AMH>F7fXYB9-D13Vp`X>tR@r7X&E(l;`?a6WYj^g#RbH`gdplpJ{}D z3)=rLQK=>hpXR7Zs!qAH|j2IZsm44#c{PVTCH zKroTxtsrcY|5<2e#=Aw=C~1Cknv*K4q&@0`>Zq9R5za-oeD9YW-L0SCqxkcCI`s$3 zv2_#!Dl)#FqfZ@e7^hKz;uILaX%4L-y+Z(>^Z9Lfmxn4L!xspMKYgx^r8kv5Wt0EV z@(m*a;?D>8QaN8~1aj=xV2)7sPI1nKZaDF{dI^aBq85xyWc9eHKEL=O_=7w9?2h1S z#p>Z}c<&y+nS{5wfE74Gj89bT7eTMi6dYsl9o>6b?SwL)iT7a~_bXfz5D zQD0RO%y);8YKwgQG1Wi7>HDat=wGx`p^F+&|uH7xg1_%J>nlE@kyV7X#s%7#y8 zc)9S?Q#)i@-Yu2U8LBPRNUDYXK|uJHYvV(PqTkb%{n+zPe&c&zKhI1=uNN&7Ky2a( zn>yLnHz-4NVis|LE(X9saZM1|(b?~dDW`JNb+ew!O5ktkM|W}_JbnkT>>HQLRh-F@ z3`~N699!?8wNiA7=rk?!ln}~`be|E^0Vu$b^KRS@2O|6E$!BI|sb+GzZa(5CGVt#y zlLaAZZ7oC!`4as>z&x|vqOb9?%1RY^2B>=fmH_U1SJ3;)l7({cQZt}6m4&O*`&(XM zPxIURd9t_O-FOspfhFm)ua7)G{k;%HSL4*4M*cm2=(XiCio1$UqUQRaA(V;`Y6uKJ zBmxmj`m};e0)G(7$AV+GdrtBJ%c3pGbDP)1$l<)P1FjAIjD%B*8-KF*GeZL9m>olQ%utw zB*os4ylMO~74&@~=mgYgExUX11-6dQnm!{q13GRCvuH0+_j%Dj9sW5jA~YBK^f}{Q zRv?@&YjUhm#hwI-cBMan_|Tzwqj!Ka*x)6&?x#?B|Kv?-1k{h%WuLL4@x*N`DU>q0 z9cuCJqVTAvA^vmUx(oSg;|(vO4mgB*U7rh*RQ?XDm~IuNl~SkCA4DIPG8fk)mK8}t z?_M5cOFR%>uVPzzv^Afu62n{1G}A(`kaO2OZ1EWdN{7Zc7&Szf6voJuo!`S~b*%WMzUy{Sa}$&lhxTO&@T<&TE_3VJJm}Uq=>uQgA@el}2hNlutDzkE>R$GTRePo04p*sWZI?vwrezE9C5~;1nK= z9zc*XLOH@dBo=8DaA>dOxxWTiE@4t|ohn>S*kwhI;55l+>TEX=E6s|>%CEYx(s6Pn zk>>?NevmNtUVG~VKnws$`ZX;d6ns-}XEtfgU}OAjRd9`2y`!vTS5+iGI5lP~Nu?QV zvMnf}_NqkV3Z~J@?F@+fqJ3t(tu6tz)46Y=@zUo0WzY}Wa&o-~w>iAwXnl-r$JdM_ zb^AJ(6lef#G^-#KszHiFV?*!B^L_Oj9*U8T=FhG`Yp$$7cAJiv%3{svfdI;vUs7Um zFa*vGIHj!EqW$V#;}BvAyw3HCd@o#Fnw+;g z1QYWJTuf@emB0Bo4`Ykw)_m?$-;y=BjEn2&hMWgB)jjEeK7`)4i?|b{ER{DBGAH2a z!`houkzZ|{FT6$XR>GxdGK=sOFD_gg^Nob8JN%qGOq|=;4O7G1MtEvZ^SQ?bz@%{i zZq185U=HmfL1!iGilbk1sQ^uixSdJ#-8)oQv-{oPed7e~*j#P^vr&h=b7IHczn-}> ziH|U8f`0UuN8}gM z7BStET_4IoFVZ*0Fz7}_JXYS7pE@^kBDiuJYn#vEdL=o($IeaX^$f9MYRZkt-J`Xn zNme*+X~iPb!1 z`H6C*3pDFUIZ2J|jJ!SA97OW#{F0aGLj_w}*x{6xF)4ZSKuCh2%$8#o+lb`5&d;th z`+VJ#LF< z$400_*nMz(SeD>7#Qi*V97ik8pjo$@ha5GgWE|!yD6&QRWY>gtR!}BwfVW4eBj?vt z1!P&3f9X#VAz~ZrHHFFDcJm8KAL;JubBOV)A6vfs1n{}$58ts8ni?c9VU4d%PI+g7 zTY~raB=4E4d-+C*F_&zEnTp=+qx$1u%=v!5z> zblX=TsZw>@?>C~LE}XX+(fjNi!cN|Ez--e#2uBTC6>7UL4sAq%uo_91qA!AN&>amJSX7u?27o?25@Ti&htqhi7nj(9 zx4{f9BW)$Fqgardyl_P^hh8Z5(&t*t$7(h42v94>1PQ{(zd z2|tcED{F8L)ecXskk#2;R%sqC>*MZ zQBhGX?~82(xU+;z0IBuPZD?Bjd+q7dUCkgf*mB+aG{KOdt{2ZG;bC2q3uLe6>SrtC zW?bKp;YzPxH_M(RR|>r#_%OXQLtiU3&|x-byA*ra8DhOh{4ozalk!khd72=uDEV4% zdeILH$lbAv--YB>J2=&LHup2$qS%KZ^B(q;Lv#9PDhY`#r6U$(DREMa8J^(bpaVu1 zc|e0W>sU^=O8U;0N!i4UTV2xoa={EnzA*yG34cOGCqaJqyf_(f;P;IcWwWlp`A}Ga zlFh8eyhLx$?}Uzfz_%SyJ}?L3N-rP8@ju|^2cS42TqLDhRHSLqJPJ0=5@+jH0_9B1 zFXeAorrBpkH}}4ONcth`M&=uTX}3p{#bT%Km&)YeU(t^tpPQFnG94Ifq!l)+(^y*# z(~ak%0>vEjvTSWg9I}j~$`v0p<6XxBGB%^Lcw(oyDswtDiO%(8Ryx7Pqn8|QU|FpG zyss(amjR*L@DiizzmYCzf(b#eBiUj>>_94>;ueW(ls2lQEbU*RnWgt>r9zyCZ8Qyt z{E3?%l2p!{NvfW@)oLx`jh@}qE)@TQmULyN|9`qtPZD`?a~lh z=o@v(qaQ|H*qHi(EM_BEkEE@F#2eN4t}S_rX&boag@FOmMqH@@M16u%N;=|NBtjKl z#ms2ix1QpiCqW9AJ|M+FCeBI$NMM45#?jQDbFj^O(HT^AGfkSa;LH{KD|5=_!lo!J&+u5&vhKXV7gnmenvX0#Q0N+naV(=*thbzr`AS zfmO2Qe(ktBr;#*Uc}^Ah2aCfm)rkGqEo`x^$Lvl46xQ4{_b@v9@?iqRov6cRn*E&a z3V&fjpfX~UZK2OJ3u3_tqHFzc@;>HT8?6ElYUZi_0~1&+Qr-Gv-n2q~!aqhaQ;R#5 z%FaOL-T|jjXlZpC_SbaQE#SQ1-R`IUh2_6XkoH7hVRE=g2woFdH1{ie-!KFM^Y zf#pzS{~dRrZ{I8JT&8I2G1~>k%hIeszh=v}v5{fB6z*x)3H*2T{I7|8W7btrN!>Vs zzQr!~UKdWukWfll^=$Jwc~umiW?J?Qb5C9CFNU}RX<}gy92iN@j@T2}SAoOZ+S)el zdW~1Zk_N^a5Omi`zRYY@qaq?s^vAj!>6_^%BQm$}%oeJ?7m_y{nBf>oMmrLUOpN@$ z1UI>wTH;$F&f6X!?Uk++Eb734kVy!}Bs#_G+(83$Em-rnSdDSAZ6o}8;tF-rB4t{f z*UGqzu+yQ;h3_Obiy@mn#>ybF3fBw+vOtF?8Z525Ogz{b3I8n!J93aEs+CJ%v*J#FQuRjTBE zi~0LfGD@fqDxPMG1?~wjCuKCD%L+ZCVhHcOD+u&+ABpDy7|VBz=W;5(?642b$cZ=@ zkyX=vpWx5M=Mv87R;3u2Ve?GFwuw~N258t{4gZ`G8n=mmGS%7&RDF-?7@zK=pRIn4 zx&EOM%rGNP66}XFYQ?;X)QG}ovo)win6)CUU}h6Pz(xOIJ}nn8Czk>2ZjYmK6Q{lk z3`zd+Vm<*sVWZw^5nAIuR(|$}|MO3KnkN>nBo5d6b~hl}y<8&a|Hbi%r_)9xBC>inhEt5*L>=>k;Ph z7kwMi!TzA)pFa(WL5{PCy|vx7so-Li=~)@%^HIfqwvV6GD{<(c&$>8ky8qxskeiBu z*4U|7#Aan)`(>foKvf6CQ9hTRyg3kkwnpKIDU{N1omsfy=wtwx_9P7up_rKV+bX)Y@3=0IRnMOtHc#CueY{;KrUIwd4hk;fuzftprAviz|1Fr-EqHR$YY#uKJ zsbZJNJi@xwrk96~-FO)`MO>uH+==tN;y;HR*O0;3uM8%Hw{geI(2zZkqw8-;bCyiO z#ME4a;}T_yT#gyjSx%1RqaDXK6%Nn3T*Bybq}KkGntn{87fw+J>{b#*FFStwN*hPe zFYLe>zF!gQAvdkUZ}Rd-bEg+H#tZ7+CaiB+yp4|S`j}$v4}V=bv{YXJW7L_y9QePw zQp*f2`H_#bo_~_XcCq1I_oZgx8hs0$?jpD#06cfhlU_2N1dPFt$?aLmS_6+bwQ=Ed ziA8p3qg}bUYUw@<@zU&hqUZb62^SS~gB&$3$Luem<%;;C^M)oY4A%gZ2zvTBJAhC04{kMLQ zqd{;_`OpW;w_7D<@wVk!iY(hphP+180eNX!_6&ZD{lg68bPeaki=pJb-SuzWD2D$2 z2IfR8)=;D(fUpxs=7K;O1edNb1ckX1X}BYZR%EH065s-8bmbEstOv-ZelJ-nNX{T| z>CAzb@3)YS@~#?+GT#p@sdv0~5@@gcMO`}H-l?W|6<=nQYcmY6Trp$M3QK~g=uYXW zG?~DPikE?OzYEe@;mh-o`k^Wx0n!&7#(!3J6b#W03t-U{LJ~vn?5`v{dX4W;(azJd z+DZH@ea?MPIARvhq}i`l+!(cNs(L_#rJojrs|Ff4;R>pwjoAS)mLcP2o&>GDEHTpj z2i3)*9wc#@BOv29cQoj9-v_lYeX|8sPD^9Lj;q}>I-tRmPo24i;y|A7UBw}D29Y*4 z_OcjI)Kr>)w&@P|Y49eCQLuVDRwW+hE+oXleOzU#|8MCKne~deGDuUbTO^ z6m4mxL&}^(*TUA^Xo^OYB>rjC-T}si%!(c9C@K6nB=DlIu9yY)wejSb>yQ9F1NDCoViJ2H*l}; zhE^*2&bx4PfmJ+XXrp_dUqYlDl>ugR49F~#>0Sq6l=u&W>BdEqv)_!+noou=B+XT{ z&~7zsLV0>q0!me&Ebw;`Iuez9QwJRf{^kt5VRy2g+i7X|Q-qWCU2TG?36=A`NEW

TrsRggXjP#HiPmIA*#!zaH^kbpb+5}p6qOMY=jol(l;$0v;4Yy|J9HsP6}{|KuYAeXy~X}r>0G>$V@Sba8TS&5PrroX zAhm{Pvb+u+4v|oyyuFT2BsYHJw=@>yKvGN{78i3!L@}R%x4QV*>~SP0`jnPGD!*tR zLD^`t8U4I5hKAF~0KQD-<;juaUIX4}u0^D>-p3XMhj}+yH&M4#3bv)hV(p>dqP@G* ziastW#N}2|v9kS*(RFLV&R2x|*{|?9;WdOt%D`MhhWLZz(@EkZJL) z^dp~KFJg)hU7(Q6AOD^uVTIoUX^=n%aZBSJ_MKyo4JE*u;Oai|BpB;|!nP+?ApHmY zK4-`gU8@v==Su@{?j4dBWol`^>!K zwTDx27ed&hP`MCNNildh!Eh&geuf0)cbs7B=PF>c9Xq6XPCSp`X>M={w8V%2i?DIk{5nQ$fEN_4ymA z-usmN)A}T~dn0bWn<_h_u{{c;9uOs=wJf02a@zQS>D|$VTj_Hs6u_Ff!Mc|8&pmt2 z;kXCTq#%|GB(DKAtpsQF&d+sEDXhSf`0sA(gVGII8V6d2{tX_CA_nnuxc9aYXeg0| z>1%UW?qv1SN5#G%a}hG*rWIMAcZ(AfIt%s2n?w_HaieXNVeip50zaN~^Ns#$vngLA zyM5L^J6mzUPPR^Jt{vSXkMGk;0sLyT_=ifJwS&gM84rUwl`=N|XCO=_OKIcz(y~7X zL!__}r&e>k>(|{$1q`}NBSynGHr$?c&9{_?&P1XSs{mbjctrj^1HX= z280crQYD6oolR+kA;$duTTM$SV{4R)hl_@1jvdGWw19qcH2Rq}h27TZ)QjlA@1ANS zlFtu59D)(RZ4Q3#|46o7xf4~W;?r|5*%`!uyeFHvx4%rk56M?Y*t#;bqqq+qq)mTMjaYdP>lL4F z6*=zJN1|CzAD9_gnbM9%COGyodpkX|jnNkW3x3pXobY?iWH9{F()n)J>Fzn)MN5z- z)aVy*n|3eNP6&F}RbKgd>MRQe|V+*?6Wz3KB*;6#M+8B0tqX?!mc%I;| zDMUbEZy1(vxdk>*RLMb@%L<@1tem2dGGpwg7nzLb%iy8m1aUZwn&Rwx&` zNw0)-ou=c`Qq7 z_6Oh_#1O+UC3`h22=!{V&&5#U)eF18(7qaxtA(zQ{;9vNUg?>DemCp-Yol{*o_1;E zJkA{2V9>6fr*(anMY^_9gs%NP%_rg&Euxwo2G@6kwuDy!^nh=IeSt@rn_z4g7=9=q zKntA%5EkgtbL2UvWa_oEMkM%?771?Ry$a>|vjwEO-M-Ql?p%ag;-4DWCYnn^&*z$8 zWob%XmrKXj;jTx?wZ|B8Pcae*dqMRC4lBU^gpH;8_5B%BxTDUmNY7aTe_K-B;ISh) z8X7y5e-|^$6YK@^<9jA!FEse~%=NYxU;Wpgbi7*@ea5)a)?y+P>7~wq)p?Q_KSUZ5 zn@h*lanx3`BNSRz{QT#|A4|y8L9t-Cp8gc}y{)5OOrWs2(A=h7*q}w$GQ7q=>YFd3 zahk%9F-neI?~2fWfH_0yy*lDoWg*Y{cl}XWZ;Gh1 zR@c`psJEYBjG|I14!GXLSMogfZGB2c%lY-~JmzKS6$cQ4r*Su=nm(?K3E+)52W9m- zi?0WZAv4vRr`CJl&O)Z~d^SVhn)>05dK`pJ@% zb883XmCrx3Wa_|HjNH|pfVAa5#}{nbf&kq>v3)a!SOb%POvn0qhp%DyNG-9q2&J7S7U1(xi#E#KefGn@|crk1p?P$rN zra(TMUDvjB#nxtDxDGot-bUizZ(6mEEc$*1uC*fq>pbtETY-s({alz>I5X3e{?R8ol~ z>H6^0mKL(~Q{_5KtO^ZOIpAV)6VW#ZrhZwiL561ZqUJD6m0Q#9Cw-INm<71l#;|@j z|9~l&l_CzH>`JL>mEEN1w85sHHdYSj0^*H{nHslfACAPK%HyX_@apW?GA~~ed)ikw z3?DC=To<4gM|S|F57D*c{{kdRJ*nmH*n$)Ni~_|i31q~~Z2$U~KhB6r1Gf6tL`TKS z=ux?G%%R2io1U;C zk|hA}a4_Xk72)UKJp`V&H-;Ev{(R54v!d7T<}yF51NqW&C2bh71p2MPPHX$B5wx8$ z<|y)y7mj9x|3qc?Xxrpqj>c=p>{S@)R9Bk{JUsIBWcf2gdu!9XR=8=blUHQ#>h+^T z@dCv6=|pxRDs7ZxUhf`Wg=D8Tlo9_M#@|voWrcLyC7N0>Y<5Q{1$uj<8N3qj)d0Ir z;!UHh@Bs{PGrVh{jY#gSWv+j2@}QHa?6}hvdf}r8@i|~0o*+b;$T4|FF&1Td46sj4 z5RKDF9OSrz)y?WP6Lk*d{;7{GCIF4%eP2ty*)Q#3{wzEulh7R#Rl5s1yTs3cRntuL znuAsA!Nz{5KlDNeowMr~izwX~wS(oRHFMXcZTTfo#0Q?#eM1j)9x!SBS>eOiaMpe6 zi+0%Z+2$ROl~S>B)qPp`*+e=vyr1=5CN-KZ#eq#yR244BxCOKUS`p458LvRYRC5W!F^OI4&j%5s7y`E>brhQT9 zPfdB+^PNJdD<7AN4TH=dgzEQ?O4gajvMW}$Ev0d)j=r!)#8&w zCIZF(|KMQ9z&wQ0k*io)G~fLt7auD$%0S|ciLb0c9;dp`_7nPwcFY`DHrFk3W!cF0 zq#9);?65>cUoJY9)FO_t=hc4yZHPAg&JyVJIigGHIZ5WqVl3mT-LN`FEsaHAcoz9S zXHTN>uWfJtW5VZ{?ama?&bXaiP~Z^NjN9U+u=C#wo@3&JSr|leom|Qhpb+ok;vwKC zr>Nwi?f*>r(2%>JzTzwi^IJxEm79>|I(va$MQ!G=2vQf>DNq?)(DJErfmQamX72&K zek=<=vQL7cO&o^U5kd9;OZ_;mEk_sGUw36Op+F5Cyd-XTJeKO(%hbb)XGNvAhTA{m zOH{80rw(?2{qpNDK4JUXW6Iu^ZMn8&mENl`XSQ)0xv94AZpo4iAs&~{Ys4LfSN*GR zvJbk^vn;%HIiNx%cjP!&ykg6evOEL3u}dC!Mi;Is0g%FEg+b4KA^lEU<~{f{X_F9QW89@| z=9L?7Nw&mX*EK(RmM{WNf`W+T{>eL2la_+IQZQ|_S?W;(ZqW?8nMNo0JC#Bh?*?jA z2OA;>$^!&V>dcd}FWVMIfdzrMG|(`Zwe|{G1@Fe57D9Q*C({^$!?#|6!Uq5X2XuAm zLgP?2nsme!>j(o)gkS_5eQw5MKY8vTa88qU*9KT=E z`0|4+v~--xzQ4LOB@D*&D(2H`mf>>$(4sxB_I)2OzV505WN9x3pAJYh=Brv&Y;|w<2uzFULc&3J4{d;}jzq5JrAbQ9fC`b^m;5 zR;|pRspORVoGYr8Z6h=YK1E)#t?)DRq&mD&f0+Lf(e)1}jf#S--gnvia?rEOY(r~= zHkf@E3Y1R1LXzB^b)ex;R;(&xBmYmx4!MFACSP4@_e0b$kl+;ljSV8BRfTN=zW9vbbC=8xu@1$XWzaqzuZG1y#bRviP>b`FIs zp>W*EkTt6bY3`AAXpxHIjNVRcKrPjhSD4G7rL|qv`_FKAZF?O4$aXy4 z>AJ2yi@Y(aQ(RF{lV!fu{Cwa}+d#QCAV!&NRMpNb2+uw;O$$Z&uhC^E008p~Q7!lZ zft`gDi|O9&+wFf%1(&qK{liC+zRXf8G%vIc_|^JbyLFb9CRxGY-7(1NWI3R$aak8_ zSsey0gWyK>={YV8!$Zo+XW*=9>}PI(Hp*rklS`=oI*ceDYli(!2U=r+5CPx>n!_en z0-4cRA_tHOu=t8Vu+J}m-=DPx@&d3`aG{q(t+3@k5bU$y-Yk$YjB zj2bqicn`?+FdHib^9X7xZn~4*y0_H3D~=b5>=1QH_Kgs_Knq#}Tl3!y*kIU6vjoEs ztGr?zW);c?#_ff5!ursx_$H4|yVR;06d+0Lmnn4ozUUd&a5Jum?$$LG7vj=I(_@*6 z-+W>pf{WciL(BeEy|EO>5rbaHgon82 zW*;4e+sXg$ct5K_hUwrlUa`-I*{?_6{ z&+HY0skZ)BiBIOL)!UEQExUad-RVPw>bo_io z+IysdQZDjVhcX&3W0|&8L`~&}jhTyTzRG`3uBs!r9Pe&P#55VoaGMhE&PJjA&f$Bg zp|@@t3K>f*y>P>Ep`gX|g@450CIeKMNyPWyiX-jcatrCVR^l7(!JsI2H?EGhz} zi7`$(DrYNxMPCY^pTn#SEa4LQkTm&P{~6N;KL&-&ijPx5bN8!6WwsAs4dJ94{hM`U z*Y~TYOcQ%%Qc|mvZ`HldG+pzQO4t>eZ+?TW~Da0&@K4 zSDM!OscTiup}@WnUiHbc8hWYNg!Q<2`1kCIkYm|T5f=_gLhA(vih6}fh|L)}VTc=xduE0o)k;F4s#Yaj;(!h+r-7+V_1vAU_)oNgC z%sy)LM+P4h>(ODD^pWUPWoV|KxkOQm^qo#kKmv}E7a-*{sPGO*r|75?eeaR{RUuq? zn^z|vgGkQnRAf%GA_5IY`;s;%xy{?LMuv2&^7U6c^?OQAd$Oc3l8iwNMdkhsNPC)D z<4-p0jt#qx9f|0KL`B6@N!@6}x3J}+k9idiMi!vsnhOXeGM*tB;~E|eI;!s*9!kRS6xVFZ@(*sKK-j;*K_2TD$FflG!ddw)KKTgm$U%>3= z11RK?rc=}@Cx!VlFRuh*R*Bx*#)J7PzRV-kPGw?>DuovlPUL|$*vVBK$(Oe$9!Zd@ ziXPHb^sABR;X5PXZdmj7@!d(_vFN{*=1$p|e>(dDA_?~CW5*78%<%fVF6y@KPAGTN zdwtJZO}u|Jfrg6)6>%xP`xUO(-jZo#O$&M0#{|8tj{Pf&P+qw?Nf+%cw=ld^f`1%B zZz+(k=`WNj^^8**WhXGp2=B=hYlzet%ne3w3!d1r>Z39EJqXs34q? zuPH3blQYg*>x0jC8F9S);41y8`VkeigNl1cjUUq?6Dk(u0`<~}`suuICzzG=84=`1Sx|=6nHB-M4^W-^%XQ2kjodp#-yJP?f?^Joyhr zOgoMFnAf$vleVh5w9z*X3*;@AkwzT9eCXCxaw)9szHR1o&+{FRsfy3BVl~iQey3qH zE>PdtQ(l;-w(xN#m2fe~IoLUondN<@{c5LE15av87l$T<;tQW~C>Llx%De8rgM08gHX2 zo^Hjl6WBM5wXP=x{*is$TUPkx^8`kf(sAK@B1+L^FHn+tq=R~zkg9-Ii3YPsg-UYU zFP3Ri?SHW*hsbC2vGx4Rrr^%&l~!bemd;IbnKgxPak`7iK59Y5#if7u3Tu)56Ruj;R#D}%RrzkyI zh>@EcAa$R0wSV#$uD!UdFjr=76;tx1 z?AWxta}&LCQN)f|SQ^TiB|Nd<INq(n*!k&@|9vj_*#UF0WjN09&q=*xw)kWp8X;@MX?SD)i$JxhfyTpR4%KDI9< zD8b62vHmL@6sZ0bf%0(C?gbaeQTCyve%FDbRD7uK0D+g>?!wd}Xd#zeZNd@VzKzt# z8MyrQgqFWWl@(V>zV#3nKs&~(H;V)P|3**WUASz9z2B00ahx+>%kL`eL zt*crm@5OBq(HRDQXTa2BNmu!xOJf8O(sZNtxI39@o#hmYJTfrN`BE#mXH*KWW004i zIdpu!qInVwu6TOU6K2U5G{DKl`yDb8pf3)>!wEr&-hF_f`Nb@lKL#}gA-W$zt*+=*m|vlOZ70M2Uru!qov;C@T%7q|4QMP)10o>IV$F_InnS;T|6 zQ4C8n)U|zb3%ydm+YS(fa*f$;(|vQlbOE!ruF3d!4f1IBvVR#UGc~T9TAX2nK}6`| zV&6NIYahcqxbqrp3-%*0c^FD#-wk^~ey!Iz0a!96lOEwZay%}aXpV4@JiIB@;0A+$ zig`@FciuH0u@batAP&Rdf}G^cvP^?;i^HT8t#Va&uV4rr3~Bs2t|;;q%RB`UmZsB} zkIe%ukb?N&OU_NR5~ZiiAH=%WEZ;}LQSp`cq^cWOROQLr>zbra+@5Wp0Y=Z{SmPCm z$nmq7@%Q;DHt+MwBBV1_wIMo#zRamdcYeI*FNXv zVfS^Up_;ymzAx#Mh8;#egCnNGxP-N&l_Mc)7nez^ju2^1_38)YD{~&~3ueC3+y$pb zSqRg{sfh7mBFe|U%6#+hHBYZ2s4azh>wEfyN}nyo2rK3=*j5EFeU%#u>Bh$4FV_is zARoZ{dzHv#rrCad7`D_pV!S57a%W3>Fp!qgi5H?9A$zUaj9wJ&#__`D2Pasejk`&k zW*eQJ-8Jf0qzpaLJWn}6YGRVe3dp~=3m?78ah^t|j2@2Cu^hJ8I~5v>1J@U%l9Z*2U+f{O5J#S> zWgX8n`c$4sL=>KKN|wjp@ff(k=L(h){MwTfEI)v!QX!wX;Sn?N9(LKc`}S%9$4YSE<3^TH2bomfylp2GbgJ z+7p&RvH%f!x|VIaK5+q9h|UAb>cc)G4o&B?gYiK4h_d{{gf15PvH2=X3g&!Lr)&96 z1%AKfI~mj%EBxl}tRYCoer`AMs_mI9q{>?KwtXNQX@C6F0vi3sn<;~=P^bG?;gH1S zV5<`TA4NRtAZ_@;RH(($6-wFAd*Tk}K{%3k)4PD1fI*_Hww&j^9AH|geFkeG3S4u6%{ z7JsW_Pu95d9U;N8q3gjCuw|j8$bL%ty(K=_6F?!VwkLM1y@8jElsyHBA(vGmJ&aZr z2`|&##WiY8I#mj6m1{aXHgmwh{xYW{`(I?JhPLbg3U;#doFD==f(hJu+4^3#j_e&R zlE>Cow5Uf{)9nK7Wxqw*6%g(C1UB4>6DbAJ^6A;oG$haHTP*I+VPCacWpXQiqW6W5 zZU^Sn1M(yMlD@aY>mqq&fRHqznj<*UCtHDKX~i5W?0dyNOEDi2U zP(jVeTA$d7_h-~7vF9i1er4gk5;^0Ha{sbHj({vxM(b}Gqi$6Er>9YwR)_p8*iu92 z-0O&vCtv?^cx5mS^59V)h`jdn|3N|5r~Gso<(Mo%JVEo2TGFB0rEPS=d~9XP*$m!b{2vH0 z7I#`WEG_csSp9-!*N7*|f6ainF!f@VQcxPlEHt*0d|wyGl+Egt5*L* zr=ywni_f7dr4WX+RvoHTLozsrfntwEvX=v#w` z_rOnUXro#DB#ibBZg+%Q{<5gZM8i3?+g=Ld1#ulsDxgI>4Uo%st!K;|v}I}xtB!Hb z+S&>%xI9WUic~AM8=H_OH(^J6QSmr4jk9PI2})WyW%_=De8+VU8c&gMc~`7OU!2>= z1mmcnAf7YBor^vhM`BnXbZ+VGpzQ9Qdd2T=jmwi?S;`|<_EC|xVVWqffjmPKE;g?3 z6ptD)#t`HE|6sA{mkvE?l3EnZY_=SBomtEqI+`qJT_{4?=bEc=MJqeB7&u?1tr=}o zO>WGxpKtnK#NOJnRWb$0_f-v-+VQ@jaqp@cJo1V(v1?YWm^ii5c@(8Ga0Xfc?%+>F zw{>C-<4+4mA}D!Gj;f@nF5v%b(0Mfs<#H9CE0#m zHu6E7aBv2F+F8kFgR?3;;)#1BwR|1;Jb@4~t_>|6j{aAwBT(~(lQrP^VgQsHCm&2S zb@~gBG>+0{OP4iB6i$}Hy5}R;sN;2}1{*J!sMG9d%s%OT<^TprmIamIEdS?4IB%1=uN_c zen0ty9Tlzit9)0QZQnnQjnN$3!G*n5D9V zikaHQV^y{{6OECds^|~2LU;0Pln_t*l>W3hXOdD6#D1<)`#F$cgbsP%M^V|>A%O$?~FN#MnZin2i3hi1!8!1Y5~=YCUk)+~=0|e*dgzHJc4zl%z7+&s72m$$j^E}?fl*kqt)B@}w7{i?R z>71yr336iQbWT}Q--dyIkinz>XcdD#x$-zYnQQ-gN`fd|TJ7Mhi2xnE!4SHvhQ#rB zlK2vSsbeu-IC_QB&FH=UEJ)hSZ$pJ%vJSYfjLDn7A#F!?W-ZqJmkImHIkloCOvqNH z9{6JQJ96{)oZK(ZqbG!JnU;yUmX$HiT&OR~xPVM(9BmhtcC(#F9P-bWl}C|iBgQva}@^5evk zxLmq>brq2kGaGt>D5*_G@H|~ z&d>GkFeK4N#XJv$Sr;bfx<|BNb~gz1v_NaX1qeqS9HsOfrW47-#?>vCdu_ujO4RlBeB1k#oneTfkLgaxH zRJAM>wn+;V)ku_dSC)g^@7zv$FJHvV>c;D5h|`Nh(e%*yr-y}(fentr9Q}E&OTX$@ znJBFLn0J1|vtuw6qYR+T+mHij{6t9`jO-bf^6h5s7i2xHI3Lo2%;^=fYOIQHZ^k!U z#9;4X0JRuA<-!!2NQVyZ2gXarvH>Q@f)i)t`7$m#?^vzLUjM2_mD!p$&N^AzmdA)HkrjGrnoJTe zsJ{E)&B9%#Vlxb}Eu^5k><*gG^Nr%U?HjsLpEV-<`1*~qp-jB}27Y=*fjL$7!#*?S zgYPz49O8=8P)KmtGMX9Z=8KS>R3-(1pp#Ru4{OwLoVvml%f~>Oza?}7y;M!kn7(c| zs}pO}r~l<*9@NS8VFi}!7lZi=mfDN!N6RJWB|6>n-$WWpi@ zc$On(Sc&ky>W%Lnqgg|8Sl2x< z+<)imWQH!uk>s<^3LutMi3q$0y?u}WuWwYgALZ8(N;3-;X3&t(wA+;U^^Jx~puL$+ zu@@rq_!9rbv2f?nJ0EC}lZXZF>x!d~=!Jns+#q**9YJj-Q}Sefls*nj=zi}>JS zpLuIx&6_^d#kiJ%8&iEq%n}bZnt@IrAmG!0_pt8?GYGZ*FIjw@NL4x?4dJyUqPa%?3)CNt+G+9w zu#*eUx~74wYW=O;snbZhF;2GC=h3KvA6pTX>qYLdUnRiYYfWWeOf%4%4#k$Z@^yZX z4~jm1dQ${&%I*xE{3=p9qEWKFIF+YZ&YZ-2kpxFEb?P^~L;hR^ck-tMc2#SaAgcS06TreMgC?f| z^J^6AMed#6#&T8M4r|S;Kc;p?z*)cs%dQtuJrhf#ZXH?Q&jKf`afxo+vCP89Twk0i zpA+6~7(5%x`aT%k^P%?08vjiHOOd1-8^W76SxR3jIw0U*sGVj{Gb_R#p0h>0l zVT^;|u)3a;Bj;7G#mINzfmDJ?=;FsoS5kwW?+h#j zxC)82js_L&XLaTh02L&Ps4Wwtw?CXX2jo*DX8p{M94w;53B>DzUgM*m7Y$FOlg$2_ zD!rY2S`7EVLzldh{JAD2$h>KjH56LSF{ygrG)2_niY?t{6&LP)`vKkerr96Z914%R zrwY~_F&6B50zlp|`WNs&3&O$YzTCwU?eLbYaO5j1i+M9@3iw3-1C8R^Eih=y;~D|1 zKW+hqCV0`kH0rMUSsDLLvIh(sH35{2)MEbSZA_EyT*mx)civ|zKZrqyt zqJCj4+xRDYv(FlgIhiR*p=&2U@WUa+A^cWZ)V-Iut;WH^4aBK8lPax;_a zfFDf|hJ|xo-@`}+LrkdZZl>6JpuPQ?ZCE3*587@-t%DL#`&~c(D-w1^7O>B$NpzZ7 zwG+3#Gh}V5-6Q>Vp};G%AHkm!gx(HLongIgo(oYTN>v$JuG_A4A)WoG|~&dSKl_Wq>^)LZhz4BAAPL#Vy!y4A>5IxObD7e@ZL>v;4;KZC!*N_FKM5)wY#oK~vPR!5%z# zWROfN#H|yNQcNfqukR!SUL70nD@li)9=2z)A!+z;@cKE-YJLy8E(uw-r3<$FI=M2q zQW!>#l0paLpbh)<;HWs7k_|SV4P5jW@?31vF)(G@J|(L%YAl*~)p0$P7&Bo6c?(#I}Gg7!&D@ zfJ!hn$Mu9zm0l=GqXl0X@dI_|~&+TERPF=mQn!ZH^HT z(Z47g?CWY(awG*9 z!t?_C|B2GO0tGFWhxREni=XV`zYh=7nr|qX6W45oXMamJipBL&i(_;QYF5twJgHzo z^gUGZ>ffl-8ap^hTUxq%L~<69x#m)AW)u|OLK5>L=gQvD>UwW-OpECrtBC)-0nG%h zjdLSW)9Z?GHu1hP3!;Ez648rQt2ESZBOh}~tz4H<{a{FV0%y#}-~b_o!hv&p4&(N4 z5PNLF!kWTQi|E{^#gqi92$y)2&SnBrD<(w)!0BnUvpsVGB3U*>@Q=;S!)+Drx-O3L z=7zF3oxHmmJ-qAVjj?d;~@g~5HPN|Hxd1C@{RYJiY$_3pk*87*ub438T#n9;WeN2>)RSFj|0(QgKB z4G27<@)-Azgt_#y{*Uvgn@E;nfB_jZw~Pb>2F3DN$}eZe@aC}?~7y$%!1=JfZWEyf)(7w%u& z&aFWF05bikOPw zIZ4Ve_{%+*0%l9?1&VF;rh-H)IlyfCor}mOTjb_ zARQPnji!Nlp0SOlh<{I@@42^b8W~!OLR?0~)Rmu6)A`?a7GN?9Q)VRR*iUAd=xZ+b zgU?`6Iv~>nS+?%xXvEAKnA}W3DuK4K?kKz;RNtnRq_3-TylaIiJI7Xx7-KbHLdo$R zq6eEQv58JR_z>X8&?LkBTcCIsrpmmosOHG*Z@V?UMTIiC?r;K5f!=y#PV0i|@rxd* zPh3nq68X<@)}T|MY=;fNT*)OHtn(rM;{R+k1GS3Uea5{gnMMSmu$LCw*Ms8;l?Vcy z4)cejjl?hU@pGY6D{|djt;&=Xz+ELh*#!GGAbitu6Q4SE$6Za{<@es9@UdN&VYX_PLQ=Y!+i-HWR*?8+kfHuhmTwfBS&lCTz%+-y zk1aL8*JndMwdACzgPl6NVJ)~CgqwG>tA&8iCZSvcuk>?2&a6gei!}EQlgBs-!>8_;gIgw-&qKUo=vf;tV%#s zu0xEvzLeez8kCwB4{-{9NPCu8KbnRHT9F`0o_MNzAW3*%YpbN;%()GU9H1Yj&@H1B zC5`m@cwyZzoV)O1K#L%!#}Lj>TT(zA3?BPX`thhUcQYSp=C7wuF`1s1FIzHVmLh@3 zI!?Sf55f8Hs8i@#>-F7;uB=K2#N8{4k?JPLy+5Y%BH_B+za^!X&iwV<+zHa$G)ZW% z+7z-%7#5@IxMweC8$C)@uSpk02^C!88lpl)QL9$>jt2tHDNjTIdGZI6e&H)L5nGoY zJ@nk4EY#kc!7dj|WdXLSqaI^jjFRXqE^*t#JasN+tXv=907*c$zdQ|e8KtqEuZ$}>mZ#I4}lF{}aR+FT6v<4T5sdlIFqWJa3fGVSZbLH^wv@-+>?5?rhGHR<8 z^iK2|{h;)!Cn`sWAx?4Ub|aHDa6Z?ZKVMmhz{!IZQnLM!2*Ll7Q$pSB+U&hr?7{Hz z2JSSspf_UXr-ET2lvrG%)~5?pS_m->{q&;P`QLI9lZy*QIcXOvHS0ne zESuXT%>yVX0Xp0y`V{lnB2`vOKdOq7n~~=WMs4l42@P~YMdz?4*TbCCHAwSV;ttBA zE1RSHV9nbN0GpbEbhHi!Hx3UdG$i>Ml8PYM201@>82aF;C68cHd~Fsxx~xH;SKsR1 z>>GI_8HZ~gvT-s1dFVPUh~%6oC+PYtEV7dEO!^fkZ2cr=2rwrABcEN6MkP0RL|xS? zfCS)m;~?;Aph_%q?7`CbvBt2dUghTHAqY@FOMjZNJ1c`d=?&Cp9;a%uu0h-*^rOi9 zmi4>ZOgFlNeI-7>mQ1ny}W}q-7#138xYSX zu0+zg_>bWqa+U779bvA6YV30{Q^Cn$f~0`kmYh(LW{eL)_LYF#&NDMb`uN5VuP~u% znSyr1>|v~$ff3Sciq(>{94y+V-Eaax3AU~+Jxt@yWl1zgC?&$vJx@Q|q#|S4sg>|B zvyGZPQ!tNjQ#7L~;CjqA*Q;UXcrGqcFJlxyMS4ukdw7W1g0gLo2MvxUPm>~hx%_Ip zOg%U-3917E@7EUFL1wmw`Cdj0&{INRlMG}~8^(h%H+e^PAjf!$Yn!z5a{<}y_b58* zyO+j@Zh`H6bLfO_?*)!g^9Dm(h19oqnU#Zl%P~MPUK=s~%PSH9tOgBF#B^_>iN>iq zOdVMPb~1SSw1Q1xz~r`o+vBZ)5v9toNSJRNajMXB^SQqcIf z;1(c#XnGhJ= zYeU6YITA-IXpS?-h$WhhgCW{ip zg&Hy{%njlssFV=ks;>hr*wYsB?XnHZQ=BjOb*`^98<(%YDW_42yehh>7<%KQ3(T7Z z&8>04%i75-spsRk%0`!mLv^>g#N6$}&#KyQFsJNeu7^ExAKaG0%rps=R^b7mqsD}Z zWiNdu`_&}J<>VFP2$BBH?BxJbY9IKW>R?Js24NXMEX6hYO%*t?!eQpCxMsa;8 z6>4MEdJSLwQ&SkZ5cl(<27`=}c{=hOt}4C8@lmWslvE#0jVn&oQ}lZTfP_Q}X|JU% zhIbc^2&*((#~3im4W%?*_MlyHlek40aYqOe*R=%|0Vpz!t0nw2$!_20VJ*1>!+RiE&@mGHRucKTS4}A6r7CeKCeDk~ENcEgG0>v+<8|fvBC{ql zZqw|W{M^M*%%tQb)`_>yuA3xFdFyCINoY%=gVWMO5^5_dwbK(yty~L|v0|}4bu$Ri zrg9!NzaSeXCA2k>;|niecc2~h(4}II8Vuz}20y*fH&}N2ImdkxDnTycRI0UR@@0<) zFFoO9(0Dyf%fQPZz`AsrY%j-sUuA5bYx1|Aoy*%-{;WXwcHPk4P-l+mv1OP@{LBg4 zsQf7P)lFBv0?g^2W$(ZyVkc0&xE|&4sV0rhc%YTnZ`{9w>ae}O2(2p+HNY7S2O^M2 z-^f4m-9l5k^Af(3XiY4oMKVcBBFz3^eu<3~G1;~ur3OJ+NYlsT=Fvl{jH0Y2MymFOsR_*b`nKMyzJDJ;np_0ilHl`^GqIcWl z4t_I8E22IGSW`u7O%=r!FoGydQi8`;>zE<8aH{2r6LmdE`d2QuX) z%~?@T0ScT$oYJ}h{E@qjuZapBJgbV3P;P}Jw#{JDf=|seb6e(btlXmk@6|h!;8}O> zw{svtTKH;zeRlu>vYQbsRJslWKtQpGZdYANQ3{SFtesE4ipZdI+(Ijz16gwdzQ*mJ zV$c`5!_qyz=*zXkM(Zrg8c{4daZHL2yxbE&vZNEg_kMArkXnUGo~Nj;oKbti#@yy- z9X9IS;5|R$02Z-5C9w4yIM2K~qoJi*_V^ncP~UxWVe$+;5l;AZ_zKRiDjHclC!vOUK0@2Hgrf+(YO7dX&^8z&LiXDxQ;GmoGI4gn zZ7MslL+->_EwaurHLJ&-v)EGqx=1G1(Q;K2Vuy0thKLwS!J~vZF3+k#0_&Q~3gwDj z&cK9|mjFeVo<5gLLaMC)eON44nZW%=NPl>UKGEt}sioU)^d0*4rlM1RYr+FM9kc6N6r+(@P7 zP_Txb`-2mU(EuWFE$`Djjz6ThvPINpI1*y>D7%WG{$VeF-rsiK&gV<8?x5QE+kQ1l z*+=nQrh9fB=WOJkYEL)<9$U4Sjb=a1OA_L1Rq!Gw0vZcZGC|LA8bwfQS+qT(6#b4k8Fq9PwE4zGS3H z`<<%8F)I#hPjHc`oZ`tJUN7XSIR6|7i&nA4bl`+pFHxJzUjU}zG3r|OHaL+EP`b&1 zhA*aBZTQI=O*dHKXDFQ?@e=!@Jd{R;uSxF3`N!7uqGo9w`i$20HH0;82dQ21$$7@4f5Y5N#oA{NiHd#4X8kcmxupHM+q4On$_v29CQC&kz@-`vedRam?Ir&##fA4#;#!=L&g1fhM6U8^d2WPmEo^nXZ`V#q<~Lwv?=i?MWcO7yU5gbCFVwx=TYpr1R6&L{+LNv(+9_hQerSttdIX zPh2D;F8(XeU~gUFKJQBFFz=dz-6A`^p)5H7wWeZF@#dl~hb z_dYkda7pEEizNj6f`VDE$Z`1DI;v~uHXxh1qFwGYUjYTMY$YBx@cwVb&Z{-pkC3dE zHf!;#>6`6!%+U>YO%2rEH*eIStxy-$M{D36Kr{o$D4#*l0gPoyWVNk0v*vzvVAbQC zT{~oJ3GfJJeqQzJlVi#?uO^wO1*e`WyRfg0>Ja+$FQnff`{ZWTa(xz^F>)+4HW*)% zA{*;2E~MVQ!N4Y%1sw>HZpI&^v&iJo!hHL|8|Cu?D#_c&6dy~U4q{_8Y^`S)xbNu> z$L6ia?4AQAsId&j4?^LZLQ7SbT#qI0n3g8c`$Sc<7~5$PDy`G0RF>Xej9mx^SDyZw~TewW1kIL0^W ziKASGD9;>|d_8c~Y*FgC>5EVjOv~(-Y0o z5%n~KTlOP|miOWndhq*VC+SePjdAYiqJY7{9q@A=_QepTzn)-m^y#DvsiDcL|(YL-MVxI>P8BO!xo{}-<-`s4lbhVPYwHWFc z7U6tu4&v*v?P>2Acd$_78yYZ_{=SB>8hTK-MWhw0WiP8Zjt#`m*`VyFhj1sG0Y{j@ z-oPJ)82ei@5mW_mfsk>qGS`eM*VW$peAPx(v>!Qub(fB|fxAaCn#5#0i*C_?l!@tx zZN0lVtM<@a8gjZseTx66O^4fbT=*H9dOh9n})F7;|1Z z;k>u!Re@_)`WaQ7jI{wUNI;rdYC+Tsq6}67{Y}h9Wvz@ZBB+1^^{&8AKe@y35@mH` zlB0G0Yp>2JO$J+#6~ZO$|k&^fQ!1c7Mhlp zziI}*{L6<)U-<+p_3GekO-4x|sjRrl9$}qb0lmRpqrPwEY#yGHIu2jXv!XJ)xBYfG zH7)GiQyoW;;?|o(LUPDQNgq_`Qdx2O@ShK3bAiZy?%~+$mV2vd= z`5XRQ*y@h39(ngTUWlVt;WmZ#_&~f3_oRF+OTa|}#sX1wM?U5JSE&KMq&*B1_O^Wt zLJV!G^yn6{bfs{!DXgLcK#1ebE?490F-J%qqZ}$Ek;m*oVxjkpu;x%R@G7xphe{AH z(aYkH>Kuz8M1OUrm!m?40h8*5esZ##-@?4qt%q9kB{cu{Dje=)l8Fp1ayF~aH@NNJ zgZ^R$8aJK{D&s{qWJb9&OmSa7Mv^GE_%=RrHd$#4$dIXOBpj0Iw&~AjUvKt;R6+Mq z7xTfxdKP4(l!tRjj_DjDyaXg(l{eT$rT9@FOB`U<{fK&E-*;ZlZvY{Cyn;~5EWvH5 z>Rmf9TTJ)m-Jiz}Vyt$^^Pe<58Gh`rwdup>&)>7V`Y<(haSOvJPUfZ`4 zuY(X#0Av%|wkbi@ps*;#L{RIU7c_2*iKd8>*Pd$p0!QroOP}^POBGUNWm6d72La64Tg_0=~IsFh$-*T1Yp|Baas%!}?Kpr)s zW1~3{8y8%R%7(C&ezGxa%ifGnH>C}0L8&=G?Ti_Cu~EnCKQOQx`3)IaGN9UH7FlA0 z>s|C$j>~7hqs!XVd>?ra5MfLw2b}>aIVflemiw^J??EKyxz7H7h3trw=@%z41)V=z zmWXlRSbCGK;=zR_q+FE|>h@i#uItD|YwV3*1fA4vzof+9U6!HxnBPTLfAf*mTEZ;F zIhUzF2u~ZP^nsa#c2)ojExTFxPC-)UKbL_6L4ha6fNTg`DfPzi4x%2|pKhvw5A zHhG_ZCpOwruXtE$E?sy1sp7S2XVAFWd#&>1As336R!Jvlc~hYP{bYhI3Xx+cYHfUQ zyp3DcPm_>?ISvKs_QpD!j>zOB?uxi34T3X1e~osx9R3<_B#bw*+%o7xVBUyAJV(TS zWbP(orrRQ4cQNKOu`isR=PTSpb67dssENgfIKyeD7=Xl`j>{+R(81`EYMZ7&49@OR zH>ZwyIA6I=`huYL+;V%+h?fE7yhTQF@+AH1+MU=Rfa@@Uyvt3^|Fx0mCWmRSV?VF^ zLg<3o#d9*j$$xC68kZAd8jtgx&oP_p;aZnHyG&ob2VS`e8mZdm*!W(bR!|tzxBYz}jn1ZB zXIM=D3s-M4Ws(iNf9nw-alHZ>WVrwVl+URdqK8B-xiwb^+tg}XCYR|Y?&}-JK!^Vt z{APp!O|mxC6vM=t-&v`=+sez(Zq?60JZ}xo3GPdxLVAw}{Dp3Yyzz^_;XARRAr#B% zy$L8-QwRI0`ra4oG47T!eZT~r%c2ROT9UBXEAWZjoZL;iTNmBo`9Gw0_kj87{H2R; z7~4BPZM2$R+~4Z+n7H{RRyA(wIfannbvSo_Yz@;w&c+^yeeI0=-UD%P04$UO-5#q@ zk+Rr;e}Rp2{e{Y{P(|JWN@>%)6WOyT^kQTF84{e z?^`3uL0Dwp9mS#g9WrdBD(scpQ@mR2n;c3E_ac(?&z)_NcH0@XJ@YR%CV>A`4|sL+ zev7)1P>@&u6L%8y2!gjfhYx^Bm2B)Xj3mVkRm_}H$0v-CJo__%3!4%N&jk|X(MKzo7jrfrfp zFHc_a0eJ2KPkkNvgibm9r6vWTP*hRe%^P^-A!hE zMI5SwYyM2smEO3U`>njt^@O13y#Iw}zXzDX^_`nye%^(m=aJ3WMvYtqF}1sOh>{bX zGIZKUc_U^(GqZSFD(PI8GaaaFlSDU|8A@?r@EeYB?k|cf7?*Oi z_=&bc0b=1DU2|#;$>G(TPJ}gR7S&CqF(ey;P>byNfansvTw~2c#MDrp0&7V%NA%|x zxsT&^&m-h(p)Sj6%cYE1!fik)iSW7yEp{0hc1gK+u@i&bzRbKX$HJjJaVf(mTzB?Rh zaJ)!Tkx1w6a^bn_K{k?VlN%LRG(d09J$nJ2RGyS~`h%0bqL)ADK1F}+O;0y^gLN7m zp0II~uRvCxC>y9xott)rhWx(=wfk&kf5r~RCZxlqnJE8kV;=!490CViq9Ne-PhS2dY*7Pu%>1fW9P`lh zMpk0)chL`W_ep->$Uf`z&^q++7v=XlKP=7HEA3oZKT0h`-8H^E7^^XO09>$V81dHV zsQ?C5Uw|Ygd|}xRUxg{sB;a_?ol0F{j#plk`^v_Y`9Fms1xAX|(*3xNh=IAur)yRY z8jNgPQo`+|0gjSf@rJt2oXKrTW}D{D&+IxX!liY;APH&|%~_tD-r^9p@@Hpc~HQwVbHq z{JHD>>aDiX*$hDzIZ^JX>Js@4E5})|C2%^7x&0#rj*`Lx4IGI+ZZB^#-*1dRURu8t^ zWJ%;O0+r|V`vutgpiFFawstOS09mWHRO*^9wy_RmNg_bCe*Na!7jb}aT>DR3k+qI~)qO@-Mh8K6qgo;(~{oX58Fe)WO#RV?p%G1Jz`O2xhK}u-`7xl@Voci1oboLMD(lmA_ts8Iy4v1%jIAF%kyDR}snq31LN` zR%|weeEsxw-M$r4clf)TreLYJt33K5<`AH0SA#-mA7+9DAgI>=Ey9&EN)z^9|23{vMoHUX>j{erAjchDHo?S?;PfpP+Rr-@ zb$07Mg=CLCd8b#wBe09Jrn!VL{eF@yXO&r|`619jwYIi|VO5%p$cG=H`~N+>G0Qwp zQ_7A}9LWkjB2Lk*@9uW23dVP>uw*cOrp94!)uZ4_Uzl78kT`N>0pd-%1=f&T1q$RT zF7v-T93$ndWYKS@2hwj-0o*r;EDHmCgRR#`4t4{@bA}ISMi?o-2x>=Y>ym%WAC4hvYD+$0k32yJckFJj@1ISQQOB_JRGs9~v zV)rTrb_u6oAo=)61W=~> zX+xTLs)0wC?Ovxo0#Fpn+OTTd5&%Rx5}(I|h`+2h7JwS2uz=ibFj1fs;J_U;_-@BQ zCo)~*gkJPjf#C-F1U}`L#CRN!N5-*}WxISa6c+8E!`!lY*|fkL{u-&CFk3)7ZdwCT zx9_kr@-iVn@^ky!WK9~if*MA}UFwu;K~nA#UQmhB5Sx8V8OZ7qa>04v{b4AfPmUezJch(gf(V{4Egzsnp*(<||Q?u%gqZ0ZNiZ0$~0}7y~ zV9p&Q5JpJ7YVKm4{=-&p?Ku;DH|*;}15>4IReAz)d1`t5tfB|Q?MOU#Kba@}KAyzX zJhI&&ALErgJ{&No?-|4jPIyU_s^gi2lV!4S=}+iXYQsJ-$nlM%{r2(CUD{;pGG@MT4$wNX7MC6Fe_z-TUC9! zkfXwT#D*O(gB7{4hZ{^ersa2?z^286ppeABSgPud0g|i0569T!rrcjT0F5wh>fvk` z^JYGaXrdTZ=K56zmXgxb#OG310*9{dbYgu!&DwVF#1~z=d&%W^7B@(r6}^U^mpRv7 z9+qWats-D*h(+Mk;B2L)<8HY`XMV*1w@V+Qz$-`*Qkp0ZiQ&QA+B?d#8HAZVVaiPN z1TwJ#x-a$LKK;Ob4|8swQ7LEy(VkWIza)wR6_}2E!RAB2A4(LN z#S_Q`Nr|O1;ty4k4WJnR5*x%I(k*S$#{c=pq1(ku+xY~kv(>d|;U3Vq+E2@Q`Yko=0E*2eS6lQqs@ za=0W3M=FGr-_V$BicM@`zZ(#uIQ4Y@+#%zGh2ve+K>7t@^ZL+9RwyX;GDq0;6>n5YmPNC@WkVXZpR9nidk$MeK`ys2^$|O zsHlwK9So~)KEIUbRJS@DBjMzx$?rStx;dHStlLN&m;dL=8$HYj8DcaA_-s(A24^x` zO*6T50T0e0JaUntGXB;X>%o>Wsgp`(jjJ`TOIGlPbZen@b9`B2%ICmrXj!m<$#KdXt2o_%uqcGyLC=jVqZ}CKBcUe?Kqg{2;Fma&|8y zn@%MaJRd@!o|^$x3TXNjfz*p}b94g4b;`ODYpp*gi(oY%DDs3n-HLS5!!G;Dhm?RI zs&nQgA*_AP)V>m2S7ID+58}=}P%;R%zz`GPNt)537CQ{`m}9Md+`bI$YG(ELl^Lm@_L&5fq7r7naIcX1 zOJ_&~Jg`f?0}OD>x)`63%Em1PR@g!r_$_pX>uQhD7&wHs-aSj^&~nEbN9xkJf)i&? zTZF7fQBuGeSUAbfvd#lMo1&TN)k0iC7FDZZWqtrcXrYO6ymt4HTp*?h$Le#F)l4f&ytkw*CJeg^Jyij1I&QsQFvn=DdD1;UYB7l>}yLsZa$* zN2}z}nuCR}`ay}Pohp+YXhj+~h?Pfj45l0IteNthD9Z1u^VU&8fWsTcU1T!aZQ!$s zyi|-H)%gDqUiA|yd_jw^e2;$6`d3**s-=}H`=LpY5N8ls;XTvB{pu&^vSx~Kzz$YZP&|A7t8t*RLFmEeLf)^3W7q^0R;}vYVKCQ1-{|ZF6ghxw?xI6WC>y~z)Ym<$p2rYRQNCUj$?$Z*d9oL}j-+_+p~B^ik~ zkF=rhhUm9oma8dOcwB8vQ?rY6GY14765&IpkGO)LmcImvBe*#s#6U=2BD~kXX!=6q zTnb!W5RMpO^>$4OM@$pycKVbI=RT*$6G8z^XmXM7lpWS&f9fxb1M-sOiUY<&JECA% z8T2$Ecrsfd;CrYNBTlr$OQWupxwiyXi(osY=Z zHAy`?4Tz;ydAMl60+Cb=qmFqP7z+J&#=PXTJATk!up{|8qSK0EhS!yhVXc#n+JdR- zTn7v%$*6apWiy(0?ogTMg}dy?e=J*I+mE#ZpWu^l-EHy&wIGA}P}XRqXQn|tbIpgK z&buc2Pb?@s`dfnWYmY!{AM&{JhXRy>Nq({ETYraXU;H*A*DkHwZg}d+n;Z*fiErZC z_aNGrlzJ^?|H$XqGEic=jn5s4tR$ut*E{+6u;)%3ql$p%V*hzWnyw)b#9;0C1pAUj zO-)>9Q>3{xf|fc$s7vwGJHQx*EM}^##BrG4Ln!k=Ka|S5cdMmPXLFnea)q`w)PK+)T8|ZA z03ullKQd2E9`_+IIddn9*E=X>nCU)^q{zsdehrR-PmD=65{34(RLXR)B=NPR$;VjT zeh?#>kisw`>cKX0VExzo0L+!=31;{FEqzE=u}@aQF)pJeZpwzvpEkL5>>@5>A~od8 zWrXr+52>yCdl4}S0wMMB9m(A@b9#2hnaUI<#B3ZzCuk0IYd|7>U1v*x#zQ!tj`xU6 zxV%?5^4>%OVPqCrt}CM4obo>nCL!0pI0S&h&-0r$Q1M2NY3vrki@k>SZL1=nu7de zux?70x^1>l(pmW-Eq*Xub)*aOdNFxmxI?6}TGhDi%b2M5*Y$0qN#Jx@(25Hh&6b=g zE2vv=>vqW|J;}|7s7HBJISD4w+RCjoHhYh|m8ws09m)EHMcRpNEa3Zx>u}>*7CG$m zo2;M}K3L}n*HVmkvp?<($>G)&%lf6#Z&60OL4Jl-z^k{uU-d#~^v|`T&(dTn5J5i@ z=DL@Kd7SQJyrJRPg5*i3nU4)C^-X%Z(cmfo@Dh|FVTzx=$G8$&y?R%@{(>J30!|?%|j5Q?j#E%z(u(7j|tczncxN^Cw#6bVoXX{hb?O;64?N10M;c8B_uI4 ztpJNzeD}IL#HJnHEg+8ibVWYl(IA|#*l2&Ax_m&cY}+DVnaysJ`6C6saeQhdf!C!jqk=o9kV$O~^vJvf2eYr9a*at}JsxlvX1&%>R z=3ypR?NVqCU}qE~8(Me1nbmab1{o2$)1@SriY5P*d5Lq4H+&;Ru+X6pZ=i0p7?Eh5 z!zn#(B5YL$k&Ap)y(rl<9*SE@GpW8g;tOXAxPL+7_wY_U!dhrzyXfYuiYY>D64d7j z${Sx0?JPWGYHWxw2W@9QYSvq7@ynFnhj( z(ignz)+~@ac_-n)nyZ1)NZKa0$?Qd2$!SjZFliur)4zBlr5&%RX2h^&kP_?jO7rR%|%FK4AMPcS1g z**=o+JW5h2h>TFs8YyoQf)Xrl?d~Va-&us{l8w!DG|Mc#ZLqC2TX`<(7XdB4uyxVr8TUkR$bhx%#9>BGQ$}l&3 zY0zR&Swzh5XZ5$&MOw#VVW6I3!}}nvV-kWKtV3JQNHjb6G;*6CvLS8l>@EA4@qJmNQ3ucRKp8HpoM9_&6T zQxg#ZvLT=0VLoTg+6)Dh+kEXUG$@mmjr14*>DF`YX!SCkYOzWM>%Nv4+vGDCA0v2j zSgT6M3Uy+v2e1P2XGZXp^`fGhNrBkwSrRe<$||X5({EY)W8K0> zWi>b59WNsQQ~n_n63$SMEjmC#h0a>m@dPDbgsjy`JNWzV;YbM(omD!@9o*50!~MpEhug3d4q zl0l4P4i&cp_p>7LcKHuur6qKFO>ctA7y^c11|{yMH?vkQY$LmBM=oj3cd-&tF_88eqz@{k>DRgR?AA40BB}kM_z1RkZl+=+xQN zaBRSU7W0W46k>zf5&aXJpDzd|6jgk3Qus6;%@KpMVT8H6OAs9Ozq^ zrEb~O3iQUPV(BWrp``z7J?R5Qq6~)~iiHSpOc;v#^a%ihe7nX#kXsm1zT)@Bbp#BV; z(uRcC?zML@UG>uSP7Iu>W9aHHC2#psB1w+yZw)P7U2xTJ3+IyK;B*8X;@`W0sYk zQK)<$Gq-PAv&xOWB%jmD7h4{r(W)Yf;@EIAr)g={-V%UjM%_J z##v3E*wY7}wGU)t9k&lMO|eXL+&$}Txw9SZ%5o8hbnF2~inE|D#KWqvZ%(MWWm zZ-%?b}m|w^xz-ck^)D)7O|H~TspMM&Y;=Un=>g_*nJ(8Ej0tT?E4qs6~0007FD;VOf zU;_kmd3FYQ5|>~h{OQ)$fA-wTT1mZ3jp$eOSy`_+WMNb4`uVu9yW~fI_TofFjIM(F zGJUP2Ab?E?w12;#xPEi>8(rY|nlHai68lk`cxCg*!CzEmC zOr=3oa19WzB^jP1D{pe(&~1P2v=Q}mQ(ve{vA>xEVm);eiGlZiQy(kThymP+WE)r$ zFHYi}Miq90?W<~y&X%70ffb!y&ANiT4BLFyy3+<)^_a-|djHqUCW};6W-55^6G8H; z8QPt7mcpnX&uAGlf^C%iYS?jmIGi@F6cH^&@{Il-icRcv`N>ZyK?~+JY$b0G_aC;E z9RBPSU23eH8KI-=e&_@%pq&O#DAxMu$}scJ8HB zSHxZKs=Iixct`+7=ng){AaI6&%WrYYR=KLNO&S5H|E+9g{+=VXmHowml$}AslX&`8Dm6}I5rr+) z6TYm4mU|tw{9kzO(+OIOTsIQ>B4!W{JHPTMb*uR>k!x4FJB1LF)k4V(Q2gF6F2Wvm z>5MU%<^p$QXske)M9{)@5T%iHWROQBJn2qtXuGHE5TCYDjS^O^k;O|( z-%f#!@rk~8Z^?n-+TosqPx zE305I9S$Wbr(vIL+aO+Pz%2PQ`|3})y3olCU4Uo!vr1u3xZ;0hOxtIPEAbsfKMF}X z3T2Z4&2p56l>(oWJHe-5GF*(HhVPRt39ksmBBms`x1jJ)pR6JaCf=!&lAZ+&ufwz> zD?p*gV|=VMPjCkKnwueUf+TR{G#B48-M<^w5cuneDF?{+tY_BChD6+E3Del&28IXZgDa40IiOl14#D zv+WM-J%Evz_6ATQ|6b{m*McbK;x8gD=;pL$VOkQNJlaF|By98SFSDyq{3Q*p5$Muc z2)lh2bo$M+dLa85p!Rur`&8b+QA7L~rT*02Ncte(PMZIZ_MiL*KS#MxtW_e+#S5~# zXKSyMm4baa4A5SwY+O#Q0;f_uaPR-(Ng5rg9aBn8!42se16V_`w_m}uoQ~3-lWo;% z?{&)peQe}Oebhd7ZaInr?|<+7)FE4%n$)Z<&3bSZ>kbe=D4^S2{ycFC$c2jyi4B}a zbL5pyTSi$IZSHHKbo(Qb6(Tqyv}_S7c<;l0y1QOt`YWRSe*@pg^wMuHv+DwQ)Lh36wZxM5tZVjfg#a4?cBLzmMg1D8=;Q2M z?~#PjiS4Npd^Phuxgi)1L3=vCh?Am(`5XWl+^ltDDkN8kXMR91U1V~#%g0^1G||w> z_SUfVOkEjr^kzx*ehmoxVIephwsMBeSFOA`$#x7*^^@0$PQ5Y?#h9)3ZYsFt^q}I> zP7aodJolY){O%@^=81efNt9t9sO2)sP>w>g8h5#Vhlh=k=?v<>Zo}>4QkGvcM65R^ zrKG@>SCtmlmmgVvDKRAmkbrzV1RXl~9(iiZlua*V{;&BS_FL3Yb-CE5)fQRLTP4xS z7%;e4xV-3Gv|#}}!9H6lG#^>|95M#PD=cJgHiJ2~z^#cv+ zj!>%)5QLeJACNyzyIDZr>{xs&LFM^pv+c_)8EyQnafAe&X~|AEw0-zofLu1e9_{xv zhcA0Bbpi(GkekKH;+M z2Mvc>XRdhTNiNO_r4+nJS@@5!L3DS}B=(~&i`U-3@vfq~MFhQ+f^XL+uO}@QaEnBl zb+P9s6z-9!O6sWqp&p#eVFbCYV53Q4eYfhp84QhN(-Oewhv8Y<%r;^3RKBYbE;Jjf zq5t1GM&kwa8c0;Z!qzgGZZxcbSB)AwWeorFV9pXi0o<13T*J$CNbg50n zHr7-{P5Kqj+G1PtZWz_TrH)qE8hs6&toUbm6ja1W%CakB78X_ED$nlsb&-pcc^ody z){gpApqjLlS5H3-7bV&irVQdR^4JasmrAg~r%g6SA;KSxumUk8pAa=PKzAC#%wfw5 z0UOjIa@RNKS#@{D6_2NjKNxV^*1_?e%nfMub`Hoc2X#cDHA$m+oL#+gU`6}xsBm>A zBYi@N z=^17$BfSPFt|f28Q;i_}drhMjcgyYbr{`-DH#b?ZqrGx*0U>C14MdtkuH3_gUZHx1 zBnXQ%j*(4u?ZziEvKQ^ibr$p0@8D*<_R*g7~inPs@>?PkaNaMqT+|=~F3@g9cjX6n4 zo`$2*cc~ohuRy;42|RY#@0av{ne(Usxmjg9eOoS|^(~-2Qq0L1C%=j>BGYgA z%#H9?ftp!JJztgC`Q$)#+lY~2V_Dx6!*u)z1!@0ny9ZVi`O{7;{Z^H}!l7vQZg7Qm ztK0^Zx#sxJ+~XL}KR~s7@cNFvI$8;xg0*$fq@op1w&--Faj%y;4HfMCmh&5ulOZ3V z>4_BJO%z`$J_4*4nm{Sii|J_6aoqSfH3P_-hK(S&65PHPzH1qy(4U^_`riCNO;Dwa z(;}s40A|QC7k@^ZPnO6-W*8#EhB!@1R@hZUUc}tgJLFp#b%AOLxzW3*?BV4MgC$(* z8fi3(Kof~)e!A3pv6A#}`|{Db0~|5^EznFBsW$QkcRAjT6h zx>q7M3C>nJiUrSoB~>>yAL|{#U7JqIf!5)}C29XJ;_O4n2X&Z9Bx6yA_sRi&<8b|W z3JDlNzYx-C%h1a}xMbpnOeab@^*hW3)XrEA$>9#)Qye3zYxM+OHd5FUV)FZWv=5~k z=X5x$v^=IT1cG1wGBVfALiG-%j zvJ*4QTH}gY2wAJCc<_&YPy6Uc)I*53q)IDICCKbhmEXCyl@r}DzzG$#V=Z;q$dH?7 z(e9Qc37lfm0KN|@xFeG@xS?XHl1RviyX+K{cowWVwidakwS{|cPDYUWYfCSj##8l1 zuCFvR>_c^o(KVoy(M6LXxnM&H?EdRJU_}9LHTf&jMfE118)HlYj+17}_c1*7g26YG zSEgeW@~&4v2P98xe)Ck1Y@fDO!Nonp$cdvWn*J>V^BI(e$GLnL<3-6cLbwHg4sA{q z*8{WGJ%Z5i~~ zy2Tut(<$vO_;!8qVyJZ%N3Aa?RB7dTf1{6e!5F?0U?dgZIEprJs&7`LCONdKXI|cB zloh=KfK&V3gTTL%#z>Yf{$1ScH9xDy1$zrkbH6}iPbeoqZ18$K?yE_{{Xj8WoxwrS zGuz@ZY1p0HMS;od$3MS)t8;-l+Ra~tnP5+X#2xkI>{`m$%ArDpgKYJoILP$=FS|M% z5dCnteH~#ecy~yYoMN{5a zJt=bETffe9<>!!j6jMB3F5Q4GFrKt{?9$Q)sM(R}$rRbwG}x)JQDjRH9z+w>-Hayz zWnya?Clc42wQ+sImozO@^}tecZ16OPQ+WO#^=kN%@K?{ma~OR|LjpQ(8zc=ZdLqc; zcuVYFu|sF5P59xg3_6d&E*?P!=QZ>jzX1!5pXufLJMx+!hc^IXcS89=$JdhEk*C;= zCseQ=;0A`6+??Z}EAnLN5ENde`A2pN1`0|SGA3?N8k7xDEk;bH6?PupMbqH?j)tpPRs;kQp-5XCt8^luvoJd!!l!?Ph!&?JZLRF+QB3RP`PeSJ6YSM{E7jPT5JRqoXeK3HK4k=; z#I94L;3okhrC+$s!*8qghK<1{X5~~<8WPC{F(^ntaH_Y%aWh%uo@X#K2K!mBo*SLE zwI`-rrtQ+iCSxaReRV%lTY(bwO756~)G)i20%GOBbTkUE6Jn62lZQ~UmX;`p^gRu# z(HCpiMeEu_Kw$dA&jDfI+S~vD0tf6+Dbsv#sGTw^0OK5%ZgVi&2+_U*jSpjyvpVDpn~ts*&gV#Q4f7}W_yRkWH0k~rmbTzUu`etOl>X`2_1 zz5f6>TQ9cZ_gC7tm%zh|;9GTQBTGZG+g_ir$I~~Edc{dmC-;DXMOU`MYVd$nBP9mP zar<7D<6r;HR`?Nom-BrlN=8;zpk{*FD17~s8(6@-n7-Yz` zP4!gu-{b=bgIkU#)nGD1sru3q)iasd&i?>z%ii(vJow=F}7-|j!} zI(i5Rg>`X-x{UPBKlE5UDu+b*s50H)AjMC1jak2vr>HzFn6tiIsyAC~xXYN7XE8}2 zridBZiC)Teenx@Uuy8j~RW)^RUGV@DZCYs}#|~Flpd zcRUM>n}8+UOeL~Lm7}k)Z}8TPtv$R7lC!ClDPtH3%%99V7?jFswRR&R&{FJ4$PyO# z2y$&5*Rf7%xBvhGrI6m_cOpZg;3G|W_`ARn{XF^t91vlXv3;=3ykZpbm4Z^zfA{+o zyRz`N8I(iL*a`|Yn-3QAWj)69&%9_~2nq4fpw4E<^n)?}ZC6C#U>xhUf4Xo8YmSYF zB4DRMXf#J;PCqiQ2tg6gqmzKW_$B-{?hw|lTJxwW>)^uTZv$7}V};L;z7U!j+)GPi zg(P#!u_(7;lZ2!TYy*ip5X|P4(+e)u3;!7wMIpLRG_gxkpMt(M*2W`mnSafc*GMaf z09)ci%El0Z=kZn}WbAZ~IN%N95V%U}JzsFcq8H?`g3+*Lt9gpr`D{Cykvu;(1uj8< zB9x_HxHFUy7a!I1Xkum06NEo z&T2GmY+Q*3fTp#Ri@-hIF?BxBeHN@4laqia0fW1zQqs4;3}~GdU&d`jAN2*-1e^_ug8C5mq@8bf{L;edX!q=1V{RK@nd!o65PNg| z*-@~KJbeZgf|5%IJZ%mt{p7^XL77-lstpF5CjO+!sf!>QIMoNr18!cP_=r7+8K;JV zc4d;sK?GBow<7h~{0F(2o(*P^mRX}1<8#rb5J!eFvd{$Io&WexF9o?8kJT&xaIl%o z7Xa8oih#jMBL>`KcqM1w+Sjb!e|s_kW_lZIEsuhs2Hg|#bIA8IautY6ttzHzO?(~< zv7*0kseewT9L7q(PI1A}Fih_awpd0cVdC<6VcXj==HLlHJfolX;kOgD)yQjhNjuW^ zw_-zHQFkq(GFO*YyEg}ebQPfAR}_htv)4X+U<)}7p|0Z4@jNxu5&%XI`CSCFE(H;K zS0}?DUeX=NGNqF^u^9?930$b+&c5^d1O`=`P-p*4eUgGJtT8QwW{V0;ReY^3YGqa# z$j=F*cQYPP9)9;RQ?Nok8%C4kfakGwuQ6~d1r|-Ci1tYIsXV62p2U;q#bs4OciXdiJ`hP24Xj!kRe(|gD%!a!PV-4eF~ zf>5H=#ZCvj)=7fYGqUxMgVXCF>x;+mnBrcgt0M{#-=)!y0KVA5g2GX6oB3&xF7u7j zlJsJ7-B-Ki1pRFka22pIOw#nBGA^5xHsk7W+;a(R%gk+l2N%V2u-QxdJve6yjW{E# zn=@ZxcpNm#7;1FEL%BC!gJ}XrutD=~slu>9p|)iz*hgW8vbkSiXsp zw+-7~I4y$;zEOC`&4pcy1 zirede;XE-3*R*Rn(OU=VXV}sP=l}uw@L=N2$^qpWz>ww~xbrsh&>%QTQ|hOs1C(`U%#O#nxr6aI-+> z@NU-MAmd*p(KMU}d8dtE z@Pa7h1=^elNzPD8p8y>Nia4)K2@Ym-TcL-8;3*swZ8u{Nym)cGFz#HTJXU}MoZ76+ z^F~=5(4+tW1JX*s-~s+MuC_9O+s(bdXWhl void; activeWagons?: readonly RealmActiveWagonMenuItem[]; onOpenActiveWagon?: (wagon: RealmActiveWagonMenuItem) => void; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; onRecenterKeep: () => void; onRequestReturn: () => void; }>; @@ -142,8 +155,9 @@ function assignRef(ref: Ref | undefined, value: T | null) { } function RealmResourceRail({ - resources -}: Readonly<{ resources: ReadyRealmResourcePresentation }>) { + resources, + workerResourceState +}: Readonly<{ resources: ReadyRealmResourcePresentation; workerResourceState?: ReadyWorkerResourceState }>) { const tooltipIdPrefix = `realm-resource-tooltip-${useId().replace(/:/g, '')}`; const railRef = useRef(null); const [activeTooltip, setActiveTooltip] = useState(null); @@ -185,7 +199,9 @@ function RealmResourceRail({ } return { label: RESOURCE_LABELS[resource], - status: `${formatExactRealmResourceQuantity(resources.balances[resource]) ?? '0'} stored · ${formatExactRealmResourceQuantity(resources.pendingBalances[resource]) ?? '0'} ready to collect` + status: workerResourceState + ? `${formatExactRealmResourceQuantity(workerResourceState.available[resource]) ?? '0'} available` + : `${formatExactRealmResourceQuantity(resources.balances[resource]) ?? '0'} stored · ${formatExactRealmResourceQuantity(resources.pendingBalances[resource]) ?? '0'} ready to collect` }; }; @@ -219,14 +235,17 @@ function RealmResourceRail({ >

    {REALM_ECONOMIC_RESOURCE_ORDER.map((resource) => { - const compact = formatCompactRealmResourceQuantity(resources.balances[resource])!; - const exact = formatExactRealmResourceQuantity(resources.balances[resource])!; + const railValue = workerResourceState?.available[resource] ?? resources.balances[resource]; + const compact = formatCompactRealmResourceQuantity(railValue)!; + const exact = formatExactRealmResourceQuantity(railValue)!; const pending = formatExactRealmResourceQuantity(resources.pendingBalances[resource])!; return (
  • - {resources ? : null} + {genericWorkersActive ? ( + + ) : null} + + {resources ? : null}

    setSurface('closed')} onCollect={() => void collect()} onExplore={() => closeThen(() => onRequestExplore?.())} @@ -559,6 +599,26 @@ export function RealmHud({ /> ) : null} + {genericWorkersActive && workerProjection && workerSurface === 'center' ? ( + setWorkerSurface('closed')} + onRecallAllWorkers={onRecallAllWorkers} + onRecallWorker={onRecallWorker} + onSelectWorker={(worker) => { setSelectedWorker(worker); setWorkerSurface('inspection'); }} + roster={workerRoster} + workers={workerProjection.workers} + /> + ) : null} + {genericWorkersActive && selectedWorker && workerSurface === 'inspection' ? ( + { setSelectedWorker(undefined); setWorkerSurface('center'); }} + worker={selectedWorker} + /> + ) : null} + {surface === 'settings' && onGraphicsPreferenceChange ? ( Promise; /** Guarded owner-only Stone settlement reducer; never supplied to observers. */ onClaimStoneExpedition?: () => Promise; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; graphicsPreference?: GraphicsPreference; resolvedGraphicsQuality?: GraphicsQualityTier; audioMuted?: boolean; @@ -282,6 +288,11 @@ function CanonicalRealmMapScreen({ stoneExpedition, onDispatchStoneExpedition, onClaimStoneExpedition, + workerProjection, + workerRoster, + workerResourceState, + onRecallWorker, + onRecallAllWorkers, graphicsPreference, resolvedGraphicsQuality, audioMuted, @@ -1955,6 +1966,11 @@ function CanonicalRealmMapScreen({ onRequestExplore={() => dispatchInteraction({ type: 'open-navigator' })} activeWagons={activeWagons} onOpenActiveWagon={openActiveWagon} + workerProjection={observerMode ? undefined : workerProjection} + workerRoster={observerMode ? undefined : workerRoster} + workerResourceState={observerMode ? undefined : workerResourceState} + onRecallWorker={observerMode ? undefined : onRecallWorker} + onRecallAllWorkers={observerMode ? undefined : onRecallAllWorkers} keepCoord={keepCoord} selectedCell={selectedCell} selectedTerrainKind={selectedTerrainKind} diff --git a/src/components/realm/RealmPlayerChrome.css b/src/components/realm/RealmPlayerChrome.css index 404e954..25c3945 100644 --- a/src/components/realm/RealmPlayerChrome.css +++ b/src/components/realm/RealmPlayerChrome.css @@ -436,3 +436,4 @@ transition: none; } } +.realm-worker-control{position:fixed;top:clamp(.75rem,2.2vw,1.5rem);right:clamp(4.8rem,9vw,7rem);z-index:8;display:flex;align-items:center;gap:.3rem;min-width:3.15rem;min-height:2.8rem;padding:.35rem .55rem;border:1px solid rgb(240 207 130 / 58%);border-radius:999px;background:linear-gradient(180deg,rgb(49 34 63 / 94%),rgb(16 16 24 / 96%));color:#f7dda0;box-shadow:0 .35rem 1rem rgb(0 0 0 / 28%);font-size:.7rem;cursor:pointer}.realm-worker-control span{display:grid;width:1.35rem;height:1.35rem;place-items:center;border:1px solid rgb(205 158 240 / 56%);border-radius:50%;color:#d7b7ee;font-size:.63rem;font-weight:900}.realm-worker-control strong{font-size:.68rem;letter-spacing:.04em}.realm-worker-control:focus-visible{outline:2px solid #f5d994;outline-offset:2px} diff --git a/src/components/realm/WorkerCommandCenter.css b/src/components/realm/WorkerCommandCenter.css new file mode 100644 index 0000000..3167906 --- /dev/null +++ b/src/components/realm/WorkerCommandCenter.css @@ -0,0 +1 @@ +.worker-command-center__scrim{position:fixed;z-index:13;inset:0;display:grid;place-items:start end;padding:clamp(4.5rem,12vh,7rem) clamp(.75rem,3vw,2rem);background:rgb(2 3 8 / 28%);overscroll-behavior:contain;touch-action:pan-y}.worker-command-center{width:min(29rem,100%);max-height:min(75vh,42rem);overflow:auto;border:1px solid rgb(240 207 130 / 42%);border-radius:.75rem;background:linear-gradient(180deg,#15111d,#08090f);box-shadow:0 1.25rem 3rem rgb(0 0 0 / 48%);color:#f9f0df;padding:.85rem;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}.worker-command-center__header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.worker-command-center__header p{margin:0;color:#caa6ec;font-size:.62rem;font-weight:800;letter-spacing:.18em}.worker-command-center__header h2{margin:.16rem 0;color:#f6d98e;font:500 1.65rem/1 Georgia,serif;letter-spacing:.08em}.worker-command-center__header span{color:#bbb1c3;font-size:.75rem}.worker-command-center__header button{display:grid;width:2.6rem;height:2.6rem;place-items:center;border:1px solid rgb(240 207 130 / 40%);border-radius:50%;background:#090a10;color:#fff;font-size:1.55rem;cursor:pointer}.worker-command-center__header button:focus-visible,.worker-command-center__worker:focus-visible,.worker-command-center__recall:focus-visible,.worker-command-center__footer button:focus-visible{outline:2px solid #f6d98e;outline-offset:2px}.worker-command-center__summary{margin:.75rem 0;padding:.65rem .75rem;border-left:2px solid #a477c1;color:#d4c9d8;font-size:.76rem;line-height:1.45}.worker-command-center__roster{display:grid;gap:.5rem;margin:.8rem 0;padding:0;list-style:none}.worker-command-center__roster li{display:flex;gap:.4rem;align-items:stretch}.worker-command-center__worker{display:flex;flex:1;align-items:center;gap:.65rem;min-width:0;padding:.55rem .6rem;border:1px solid rgb(242 213 143 / 22%);border-radius:.45rem;background:#11121a;color:#fff;cursor:pointer;text-align:left}.worker-command-center__ordinal{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:50%;background:#4a2f5d;color:#f7dda0;font-weight:800}.worker-command-center__identity{display:grid;min-width:0;gap:.18rem}.worker-command-center__identity strong{font-size:.8rem}.worker-command-center__identity small{overflow:hidden;color:#bdb4c5;font-size:.65rem;text-overflow:ellipsis;white-space:nowrap}.worker-command-center__amount{margin-left:auto;color:#f2d48a;font-size:.68rem}.worker-command-center__recall{min-width:4.2rem;padding:.3rem;border:1px solid rgb(240 207 130 / 38%);border-radius:.4rem;background:#2b1c2f;color:#f9df9c;font-size:.62rem;font-weight:800;cursor:pointer}.worker-command-center__footer{display:flex;justify-content:flex-end;border-top:1px solid rgb(240 207 130 / 16%);padding-top:.75rem}.worker-command-center__footer button{min-height:2.6rem;padding:.5rem .8rem;border:1px solid rgb(240 207 130 / 55%);border-radius:.4rem;background:linear-gradient(180deg,#c08c42,#704029);color:#171016;font-size:.67rem;font-weight:850;letter-spacing:.08em;cursor:pointer}.worker-command-center__footer button:disabled{opacity:.5;cursor:default}@media (max-width:40rem){.worker-command-center__scrim{place-items:stretch;padding:4.1rem .5rem .5rem}.worker-command-center{width:100%;max-height:calc(100dvh - 4.6rem)}} diff --git a/src/components/realm/WorkerCommandCenter.tsx b/src/components/realm/WorkerCommandCenter.tsx new file mode 100644 index 0000000..df6ab86 --- /dev/null +++ b/src/components/realm/WorkerCommandCenter.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react'; +import { + realmWorkerCanRecall, + realmWorkerLabel, + realmWorkerStatusLabel, + workerAvailabilityCount, + type RealmWorkerPublicPresentation, + type WorkerRosterPresentation +} from './realmWorkerPresentation'; +import './WorkerCommandCenter.css'; + +export type WorkerCommandCenterProps = Readonly<{ + id: string; + workers: readonly RealmWorkerPublicPresentation[]; + roster?: WorkerRosterPresentation; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; + onSelectWorker: (worker: RealmWorkerPublicPresentation) => void; + onClose: () => void; +}>; + +export function WorkerCommandCenter({ id, workers, roster, onRecallWorker, onRecallAllWorkers, onSelectWorker, onClose }: WorkerCommandCenterProps) { + const headingRef = useRef(null); + const [recallingAll, setRecallingAll] = useState(false); + const available = workerAvailabilityCount(workers); + useEffect(() => { headingRef.current?.focus({ preventScroll: true }); }, [id]); + const recallAll = async () => { + if (!onRecallAllWorkers || recallingAll) return; + setRecallingAll(true); + try { await onRecallAllWorkers(); } finally { setRecallingAll(false); } + }; + return ( +

    { if (event.target === event.currentTarget) onClose(); }}> + +
    + ); +} diff --git a/src/components/realm/WorkerInspectionPanel.css b/src/components/realm/WorkerInspectionPanel.css new file mode 100644 index 0000000..1375fb5 --- /dev/null +++ b/src/components/realm/WorkerInspectionPanel.css @@ -0,0 +1 @@ +.worker-inspection{position:fixed;z-index:14;inset:0 0 0 auto;width:min(25rem,100vw);overflow:hidden;color:#f7f0e2;background:radial-gradient(circle at 50% 18%,rgb(118 75 156 / 22%),transparent 42%),linear-gradient(180deg,#0d0d14,#08080d 72%);box-shadow:-1rem 0 3rem rgb(0 0 0 / 42%);font-family:Inter,ui-sans-serif,system-ui,sans-serif}.worker-inspection__art-stage{position:absolute;z-index:0;top:-1.65rem;left:50%;width:min(30rem,124%);height:clamp(16rem,43vw,20.5rem);transform:translateX(-50%);pointer-events:none;background:radial-gradient(circle at 50% 56%,rgb(149 90 196 / 24%),transparent 54%),radial-gradient(circle at 50% 18%,rgb(242 207 136 / 13%),transparent 56%)}.worker-inspection__hero-art{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 .14rem .18rem rgb(10 8 16 / 72%)) drop-shadow(0 1rem 1.3rem rgb(17 8 26 / 64%));user-select:none}.worker-inspection__drawer{position:relative;z-index:1;display:flex;height:100%;flex-direction:column;padding-top:clamp(7.25rem,21vw,9rem)}.worker-inspection__hero{position:relative;min-height:8.2rem;padding:1.25rem 1rem .8rem;background:linear-gradient(180deg,rgb(22 17 31 / 25%),rgb(8 8 13 / 96%) 84%),radial-gradient(circle at 50% 6%,rgb(175 127 218 / 21%),transparent 58%)}.worker-inspection__title-lockup{padding:0 2.8rem;text-align:center}.worker-inspection__title-lockup p{margin:0;color:#d4b8ef;font-size:.64rem;font-weight:800;letter-spacing:.17em}.worker-inspection__title-lockup h2{margin:.24rem 0 0;color:#f7dda0;font:500 2.1rem/1.05 Georgia,serif;letter-spacing:.065em}.worker-inspection__dismiss{position:absolute;z-index:2;top:.68rem;right:.7rem;display:grid;width:2.8rem;height:2.8rem;place-items:center;border:1px solid rgb(243 213 142 / 42%);border-radius:50%;background:rgb(8 8 13 / 82%);color:#fff8e8;font:400 1.8rem/1 Georgia,serif;cursor:pointer}.worker-inspection__dismiss:focus-visible,.worker-inspection__recall:focus-visible{outline:2px solid #fff0bc;outline-offset:2px}.worker-inspection__body{flex:1;min-height:0;padding:.72rem 1rem 1.15rem;overflow:auto}.worker-inspection__description,.worker-inspection__read-only{margin:0;padding:.72rem .78rem;border-left:2px solid rgb(188 146 228 / 66%);background:linear-gradient(90deg,rgb(91 51 119 / 25%),transparent);color:#d8cede;font-size:.76rem;line-height:1.5}.worker-inspection__fields{display:grid;margin:.9rem 0 0;border:1px solid rgb(231 199 121 / 23%);border-radius:.56rem;overflow:hidden}.worker-inspection__fields>div{display:grid;grid-template-columns:minmax(7.1rem,.82fr) minmax(0,1.18fr);gap:.75rem;padding:.66rem .74rem;border-bottom:1px solid rgb(231 199 121 / 12%)}.worker-inspection__fields>div:last-child{border-bottom:0}.worker-inspection__fields dt,.worker-inspection__fields dd{margin:0}.worker-inspection__fields dt{color:#c8bdca;font-size:.72rem}.worker-inspection__fields dd{color:#f7dda0;font-size:.74rem;font-weight:760;text-align:right;overflow-wrap:anywhere}.worker-inspection__read-only{margin-top:.82rem;border-left-color:rgb(218 177 246 / 56%)}.worker-inspection__recall{width:100%;min-height:2.8rem;margin-top:.9rem;padding:.56rem .7rem;border:1px solid rgb(246 215 145 / 72%);border-radius:.44rem;background:linear-gradient(180deg,rgb(229 186 93 / 97%),rgb(121 76 37 / 97%));color:#171016;font-size:.7rem;font-weight:840;letter-spacing:.12em;cursor:pointer}@media (max-width:40rem){.worker-inspection{width:100%;}.worker-inspection__drawer{padding-top:clamp(6.5rem,24vw,8rem)}} diff --git a/src/components/realm/WorkerInspectionPanel.tsx b/src/components/realm/WorkerInspectionPanel.tsx new file mode 100644 index 0000000..a1404b7 --- /dev/null +++ b/src/components/realm/WorkerInspectionPanel.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState, type Ref } from 'react'; +import { + realmWorkerCanRecall, + realmWorkerLabel, + realmWorkerStatusLabel, + type RealmWorkerPublicPresentation +} from './realmWorkerPresentation'; +import './WorkerInspectionPanel.css'; + +function publicAssetUrl(path: string) { + const base = import.meta.env.BASE_URL || '/'; + return `${base.endsWith('/') ? base : `${base}/`}${path.replace(/^\/+/, '')}`; +} + +export type WorkerInspectionPanelProps = Readonly<{ + id: string; + worker: RealmWorkerPublicPresentation; + onRecallWorker?: (workerId: string) => Promise; + onRequestClose: () => void; + focusTargetRef?: Ref; +}>; + +export function WorkerInspectionPanel({ id, worker, onRecallWorker, onRequestClose, focusTargetRef }: WorkerInspectionPanelProps) { + const closeRef = useRef(null); + const [state, setState] = useState<'idle' | 'submitting' | 'failed'>('idle'); + const canRecall = realmWorkerCanRecall(worker) && onRecallWorker !== undefined; + useEffect(() => { + closeRef.current?.focus({ preventScroll: true }); + setState('idle'); + }, [id, worker.workerId, worker.status]); + const recall = async () => { + if (!canRecall || !onRecallWorker) return; + setState('submitting'); + try { await onRecallWorker(worker.workerId); setState('idle'); } + catch { setState('failed'); } + }; + const ref = (element: HTMLButtonElement | null) => { + closeRef.current = element; + if (typeof focusTargetRef === 'function') focusTargetRef(element); + else if (focusTargetRef) focusTargetRef.current = element; + }; + const title = realmWorkerLabel(worker.ordinal); + return ( + + ); +} diff --git a/src/components/realm/realmInteractionState.ts b/src/components/realm/realmInteractionState.ts index d6ca352..2796d99 100644 --- a/src/components/realm/realmInteractionState.ts +++ b/src/components/realm/realmInteractionState.ts @@ -5,6 +5,13 @@ export type RealmCastleTarget = Readonly<{ coord: HexCoord; }>; +export type RealmWorkerTarget = Readonly<{ + workerId: string; + workerOrdinal: number; + originCastleId: number; + coord: HexCoord; +}>; + /** A public world-site target; it contains no economy or authorization data. */ export type RealmGoldSiteTarget = Readonly<{ siteId: string; @@ -30,6 +37,7 @@ export type RealmStoneSiteTarget = Readonly<{ }>; export type RealmInspectorTarget = + | RealmWorkerTarget | RealmCastleTarget | RealmGoldSiteTarget | RealmFoodSiteTarget @@ -41,11 +49,13 @@ export type RealmCameraTarget = | Readonly<{ kind: 'founding-district' }> | Readonly<{ kind: 'keep' }> | Readonly<{ kind: 'cell'; coord: HexCoord }> - | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }>; + | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> + | Readonly<{ kind: 'worker'; workerId: string; coord: HexCoord }>; export type RealmKeyboardTarget = | Readonly<{ kind: 'map' }> | Readonly<{ kind: 'inspector'; castleId: number }> + | Readonly<{ kind: 'worker-inspector'; workerId: string }> | Readonly<{ kind: 'gold-mine-inspector'; siteId: string }> | Readonly<{ kind: 'food-farm-inspector'; siteId: string }> | Readonly<{ kind: 'logging-camp-inspector'; siteId: string }> @@ -76,6 +86,7 @@ export type RealmInteractionState = Readonly<{ export type RealmInteractionAction = | Readonly<{ type: 'select-cell'; coord: HexCoord }> | Readonly<{ type: 'activate-castle'; castleId: number; coord: HexCoord }> + | Readonly<{ type: 'activate-worker'; workerId: string; workerOrdinal: number; originCastleId: number; coord: HexCoord }> | Readonly<{ type: 'activate-gold-site'; siteId: string; @@ -123,6 +134,15 @@ function copyCastleTarget(target: RealmCastleTarget): RealmCastleTarget { return { castleId: target.castleId, coord: copyCoord(target.coord) }; } +function copyWorkerTarget(target: RealmWorkerTarget): RealmWorkerTarget { + return { + workerId: target.workerId, + workerOrdinal: target.workerOrdinal, + originCastleId: target.originCastleId, + coord: copyCoord(target.coord) + }; +} + function copyGoldSiteTarget(target: RealmGoldSiteTarget): RealmGoldSiteTarget { return { siteId: target.siteId, coord: copyCoord(target.coord) }; } @@ -148,6 +168,7 @@ function copyCameraTarget(target: RealmCameraTarget): RealmCameraTarget { if (target.kind === 'founding-district') return { kind: 'founding-district' }; if (target.kind === 'keep') return { kind: 'keep' }; if (target.kind === 'cell') return { kind: 'cell', coord: copyCoord(target.coord) }; + if (target.kind === 'worker') return { kind: 'worker', workerId: target.workerId, coord: copyCoord(target.coord) }; return { kind: 'castle', castleId: target.castleId, coord: copyCoord(target.coord) }; } @@ -212,6 +233,20 @@ export function realmInteractionReducer( }; } + case 'activate-worker': { + const target = copyWorkerTarget(action); + return { + ...state, + selectedCell: copyCoord(target.coord), + selectedCastle: null, + inspectorTarget: target, + inspectorOpen: true, + cameraTarget: { kind: 'worker', workerId: target.workerId, coord: copyCoord(target.coord) }, + navigatorOpen: false, + keyboardIntent: withKeyboardIntent(state, { kind: 'worker-inspector', workerId: target.workerId }) + }; + } + case 'activate-gold-site': { const target = copyGoldSiteTarget({ siteId: action.siteId, coord: action.coord }); return { diff --git a/src/components/realm/realmPickArbitration.ts b/src/components/realm/realmPickArbitration.ts index 7e35355..58e16ab 100644 --- a/src/components/realm/realmPickArbitration.ts +++ b/src/components/realm/realmPickArbitration.ts @@ -13,8 +13,16 @@ export type RealmResourcePickHit = Readonly<{ source: 'site' | 'wagon'; distance: number; }>; +export type RealmWorkerPickHit = Readonly<{ + workerId: string; + workerOrdinal: number; + originCastleId: number; + coord: HexCoord; + distance: number; +}>; export type RealmInteractionTarget = + | Readonly<{ kind: 'worker'; workerId: string; workerOrdinal: number; originCastleId: number; coord: HexCoord }> | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> | Readonly<{ kind: RealmResourcePickKind; @@ -47,9 +55,20 @@ function nearestValidHit( */ export function arbitrateRealmPick(input: Readonly<{ resourceHits: readonly RealmResourcePickHit[]; + workerHits?: readonly RealmWorkerPickHit[]; castleHit?: Readonly<{ castleId: number; coord: HexCoord }> | null; terrainHit?: Readonly<{ coord: HexCoord }> | null; }>): RealmInteractionTarget | null { + const worker = (input.workerHits ?? []).find((hit) => Number.isFinite(hit.distance) && hit.distance >= 0); + if (worker) { + return Object.freeze({ + kind: 'worker', + workerId: worker.workerId, + workerOrdinal: worker.workerOrdinal, + originCastleId: worker.originCastleId, + coord: worker.coord + }); + } const wagon = nearestValidHit(input.resourceHits, 'wagon'); if (wagon) { return Object.freeze({ diff --git a/src/components/realm/realmWorkerPresentation.ts b/src/components/realm/realmWorkerPresentation.ts new file mode 100644 index 0000000..e450594 --- /dev/null +++ b/src/components/realm/realmWorkerPresentation.ts @@ -0,0 +1,345 @@ +import type { RealmEconomicResourceKey } from './realmResourcePresentation'; + +export const CASTLE_WORKER_ORDINALS = Object.freeze([1, 2, 3, 4] as const); +export type RealmWorkerOrdinal = typeof CASTLE_WORKER_ORDINALS[number]; +export type RealmWorkerStatus = 'idle' | 'outbound' | 'gathering' | 'returning'; +export type RealmWorkerSystemMode = 'staged' | 'active'; + +export type RealmWorkerSystemPresentation = Readonly<{ + realmId: string; + policyVersion: string; + workersPerCastle: 4; + expectedCastleCount: number; + expectedWorkerCount: number; + rosterDigest: string; + mode: RealmWorkerSystemMode; + legacyDrainRequired: boolean; +}>; + +export type RealmWorkerPublicPresentation = Readonly<{ + workerId: string; + ordinal: RealmWorkerOrdinal; + originCastleId: number; + originCastleName: string; + status: RealmWorkerStatus; + resourceKind?: RealmEconomicResourceKey; + destinationLabel?: string; + startedAtMicros?: bigint; + arrivesAtMicros?: bigint; + gatheringEndsAtMicros?: bigint; + returnStartedAtMicros?: bigint; + returnsAtMicros?: bigint; + routeSteps?: number; + returnStartProgressBasisPoints?: number; + timelineRevision: number; + revision: bigint; + ownedByViewer: boolean; + claimableAmount?: bigint; +}>; + +export type RealmWorkerNodeOccupation = Readonly<{ + nodeKey: string; + resourceKind: RealmEconomicResourceKey; + siteId: string; + workerId: string; + workerOrdinal: RealmWorkerOrdinal; + originCastleId: number; + assignmentId: string; + phase: Exclude; + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + timelineRevision: number; +}>; + +export type WorkerRosterPresentation = Readonly<{ + castleId: number; + observedAtMicros: bigint; + workers: readonly Readonly<{ + workerId: string; + ordinal: RealmWorkerOrdinal; + status: RealmWorkerStatus; + resourceKind?: RealmEconomicResourceKey; + siteId?: string; + accruedAmount: bigint; + materializedAmount: bigint; + availableAmount: bigint; + observedAtMicros: bigint; + revision: bigint; + }>[]; +}>; + +export type ReadyWorkerResourceState = Readonly<{ + status: 'ready'; + fid: bigint; + available: Readonly>; + pending: Readonly>; + observedAtMicros: bigint; + settledThroughMicros: bigint; + revision: bigint; + workerPolicyVersion: string; + workerSystemMode: RealmWorkerSystemMode; +}>; + +export type ReadyWorkerProjection = Readonly<{ + mode: RealmWorkerSystemMode; + system: RealmWorkerSystemPresentation; + workers: readonly RealmWorkerPublicPresentation[]; + occupations: readonly RealmWorkerNodeOccupation[]; +}>; + +const resourceKinds = new Set(['food', 'wood', 'stone', 'gold']); +const workerStatuses = new Set(['idle', 'outbound', 'gathering', 'returning']); +const occupationPhases = new Set(['outbound', 'gathering', 'returning']); + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function safeNumber(value: unknown, positive = false) { + const number = typeof value === 'bigint' + && value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : value; + return typeof number === 'number' + && Number.isSafeInteger(number) + && (!positive || number > 0) + ? number + : undefined; +} + +function safeBigInt(value: unknown) { + return typeof value === 'bigint' && value >= 0n ? value : undefined; +} + +function optionalBigInt(value: unknown) { + return value === undefined || value === null ? undefined : safeBigInt(value); +} + +function optionalResource(value: unknown) { + return typeof value === 'string' && resourceKinds.has(value as RealmEconomicResourceKey) + ? value as RealmEconomicResourceKey + : value === undefined || value === null ? undefined : null; +} + +function optionalString(value: unknown) { + return value === undefined || value === null ? undefined : typeof value === 'string' ? value : null; +} + +export function decodeRealmWorkerSystem(value: unknown): RealmWorkerSystemPresentation | undefined { + if (!record(value)) return undefined; + const workersPerCastle = safeNumber(value.workersPerCastle); + const expectedCastleCount = safeNumber(value.expectedCastleCount); + const expectedWorkerCount = safeNumber(value.expectedWorkerCount); + const mode = value.mode === 'active' || value.mode === 'staged' ? value.mode : undefined; + if ( + typeof value.realmId !== 'string' || value.realmId.length === 0 + || typeof value.policyVersion !== 'string' || value.policyVersion.length === 0 + || workersPerCastle !== 4 || expectedCastleCount === undefined || expectedWorkerCount === undefined + || expectedWorkerCount !== expectedCastleCount * 4 || typeof value.rosterDigest !== 'string' + || mode === undefined || typeof value.legacyDrainRequired !== 'boolean' + ) return undefined; + return Object.freeze({ + realmId: value.realmId, + policyVersion: value.policyVersion, + workersPerCastle: 4 as const, + expectedCastleCount, + expectedWorkerCount, + rosterDigest: value.rosterDigest, + mode, + legacyDrainRequired: value.legacyDrainRequired + }); +} + +export function decodeRealmWorkerPublicRows( + rows: readonly unknown[], + castleNames: ReadonlyMap, + ownCastleId: number +): readonly RealmWorkerPublicPresentation[] | undefined { + const decoded: RealmWorkerPublicPresentation[] = []; + const ids = new Set(); + for (const value of rows) { + if (!record(value)) return undefined; + const ordinal = safeNumber(value.ordinal); + const originCastleId = safeNumber(value.originCastleId, true); + const timelineRevision = safeNumber(value.timelineRevision); + const revision = safeBigInt(value.revision); + const status = workerStatuses.has(value.status as RealmWorkerStatus) + ? value.status as RealmWorkerStatus + : undefined; + const resourceKind = optionalResource(value.resourceKind); + const siteId = optionalString(value.siteId); + if ( + typeof value.workerId !== 'string' || value.workerId.length === 0 || ids.has(value.workerId) + || ordinal === undefined || !CASTLE_WORKER_ORDINALS.includes(ordinal as RealmWorkerOrdinal) + || originCastleId === undefined || status === undefined || resourceKind === null + || siteId === null || timelineRevision === undefined || revision === undefined + || !castleNames.has(originCastleId) + ) return undefined; + ids.add(value.workerId); + const optional = (key: string) => optionalBigInt(value[key]); + const worker: RealmWorkerPublicPresentation = { + workerId: value.workerId, + ordinal: ordinal as RealmWorkerOrdinal, + originCastleId, + originCastleName: castleNames.get(originCastleId)!, + status, + ...(resourceKind === undefined ? {} : { resourceKind }), + ...(siteId === undefined ? {} : { destinationLabel: siteId }), + ...(optional('startedAtMicros') === undefined ? {} : { startedAtMicros: optional('startedAtMicros') }), + ...(optional('arrivesAtMicros') === undefined ? {} : { arrivesAtMicros: optional('arrivesAtMicros') }), + ...(optional('gatheringEndsAtMicros') === undefined ? {} : { gatheringEndsAtMicros: optional('gatheringEndsAtMicros') }), + ...(optional('returnStartedAtMicros') === undefined ? {} : { returnStartedAtMicros: optional('returnStartedAtMicros') }), + ...(optional('returnsAtMicros') === undefined ? {} : { returnsAtMicros: optional('returnsAtMicros') }), + ...(safeNumber(value.routeSteps) === undefined ? {} : { routeSteps: safeNumber(value.routeSteps) }), + ...(safeNumber(value.returnStartProgressBasisPoints) === undefined ? {} : { returnStartProgressBasisPoints: safeNumber(value.returnStartProgressBasisPoints) }), + timelineRevision, + revision, + ownedByViewer: originCastleId === ownCastleId + }; + decoded.push(Object.freeze(worker)); + } + const byCastle = new Map(); + for (const worker of decoded) byCastle.set(worker.originCastleId, (byCastle.get(worker.originCastleId) ?? 0) + 1); + if (decoded.length > 0 && [...byCastle.values()].some((count) => count !== 4)) return undefined; + return Object.freeze(decoded); +} + +export function decodeRealmWorkerOccupations(rows: readonly unknown[]): readonly RealmWorkerNodeOccupation[] | undefined { + const decoded: RealmWorkerNodeOccupation[] = []; + const keys = new Set(); + for (const value of rows) { + if (!record(value)) return undefined; + const workerOrdinal = safeNumber(value.workerOrdinal); + const originCastleId = safeNumber(value.originCastleId, true); + const timelineRevision = safeNumber(value.timelineRevision); + const resourceKind = optionalResource(value.resourceKind); + const phase = occupationPhases.has(value.phase as RealmWorkerNodeOccupation['phase']) + ? value.phase as RealmWorkerNodeOccupation['phase'] : undefined; + const startedAtMicros = safeBigInt(value.startedAtMicros); + const arrivesAtMicros = safeBigInt(value.arrivesAtMicros); + const gatheringEndsAtMicros = safeBigInt(value.gatheringEndsAtMicros); + if ( + typeof value.nodeKey !== 'string' || keys.has(value.nodeKey) + || typeof value.siteId !== 'string' || typeof value.workerId !== 'string' + || typeof value.assignmentId !== 'string' || resourceKind === undefined || phase === undefined + || workerOrdinal === undefined || !CASTLE_WORKER_ORDINALS.includes(workerOrdinal as RealmWorkerOrdinal) + || originCastleId === undefined || timelineRevision === undefined + || startedAtMicros === undefined || arrivesAtMicros === undefined || gatheringEndsAtMicros === undefined + ) return undefined; + const resourceKindValue = resourceKind as RealmEconomicResourceKey; + keys.add(value.nodeKey); + decoded.push(Object.freeze({ + nodeKey: value.nodeKey, + resourceKind: resourceKindValue, + siteId: value.siteId, + workerId: value.workerId, + workerOrdinal: workerOrdinal as RealmWorkerOrdinal, + originCastleId, + assignmentId: value.assignmentId, + phase, + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + timelineRevision + })); + } + return Object.freeze(decoded); +} + +export function decodeWorkerRoster(value: unknown, expectedFid: bigint): WorkerRosterPresentation | undefined { + if (!record(value) || value.fid !== expectedFid || safeNumber(value.castleId, true) === undefined + || safeBigInt(value.observedAtMicros) === undefined || !Array.isArray(value.workers)) return undefined; + const workers: WorkerRosterPresentation['workers'][number][] = []; + const ids = new Set(); + for (const workerValue of value.workers) { + if (!record(workerValue)) return undefined; + const ordinal = safeNumber(workerValue.ordinal); + const resourceKind = optionalResource(workerValue.resourceKind); + const siteId = optionalString(workerValue.siteId); + if ( + typeof workerValue.workerId !== 'string' || ids.has(workerValue.workerId) + || ordinal === undefined || !CASTLE_WORKER_ORDINALS.includes(ordinal as RealmWorkerOrdinal) + || !workerStatuses.has(workerValue.status as RealmWorkerStatus) || resourceKind === null || siteId === null + || safeBigInt(workerValue.accruedAmount) === undefined || safeBigInt(workerValue.materializedAmount) === undefined + || safeBigInt(workerValue.availableAmount) === undefined || safeBigInt(workerValue.observedAtMicros) === undefined + || safeBigInt(workerValue.revision) === undefined + ) return undefined; + const accruedAmount = safeBigInt(workerValue.accruedAmount)!; + const materializedAmount = safeBigInt(workerValue.materializedAmount)!; + const availableAmount = safeBigInt(workerValue.availableAmount)!; + const observedAtMicros = safeBigInt(workerValue.observedAtMicros)!; + const revision = safeBigInt(workerValue.revision)!; + ids.add(workerValue.workerId); + workers.push(Object.freeze({ + workerId: workerValue.workerId, + ordinal: ordinal as RealmWorkerOrdinal, + status: workerValue.status as RealmWorkerStatus, + ...(resourceKind === undefined ? {} : { resourceKind }), + ...(siteId === undefined ? {} : { siteId }), + accruedAmount, + materializedAmount, + availableAmount, + observedAtMicros, + revision + })); + } + if (workers.length !== 4) return undefined; + const castleId = safeNumber(value.castleId, true)!; + const observedAtMicros = safeBigInt(value.observedAtMicros)!; + return Object.freeze({ + castleId, + observedAtMicros, + workers: Object.freeze(workers) + }); +} + +export function decodeWorkerResourceState(value: unknown, expectedFid: bigint): ReadyWorkerResourceState | undefined { + if (!record(value) || value.fid !== expectedFid) return undefined; + const mode = value.workerSystemMode === 'active' || value.workerSystemMode === 'staged' + ? value.workerSystemMode : undefined; + const names = ['food', 'wood', 'stone', 'gold'] as const; + const available = {} as Record; + const pending = {} as Record; + const observedAtMicros = safeBigInt(value.observedAtMicros); + const settledThroughMicros = safeBigInt(value.settledThroughMicros); + const revision = safeBigInt(value.revision); + for (const name of names) { + const availableValue = safeBigInt(value[name]); + const pendingValue = safeBigInt(value[`workerPending${name[0]!.toUpperCase()}${name.slice(1)}`]); + if (availableValue === undefined || pendingValue === undefined) return undefined; + available[name] = availableValue; + pending[name] = pendingValue; + } + if ( + mode === undefined || typeof value.resourcePolicyVersion !== 'string' + || typeof value.workerPolicyVersion !== 'string' || observedAtMicros === undefined + || settledThroughMicros === undefined || revision === undefined + || settledThroughMicros > observedAtMicros + ) return undefined; + return Object.freeze({ + status: 'ready' as const, + fid: expectedFid, + available: Object.freeze(available), + pending: Object.freeze(pending), + observedAtMicros, + settledThroughMicros, + revision, + workerPolicyVersion: value.workerPolicyVersion, + workerSystemMode: mode + }); +} + +export function workerAvailabilityCount(workers: readonly RealmWorkerPublicPresentation[]) { + return workers.filter((worker) => worker.status === 'idle').length; +} + +export function realmWorkerLabel(ordinal: RealmWorkerOrdinal) { return `Worker ${ordinal}`; } +export function realmWorkerStatusLabel(worker: RealmWorkerPublicPresentation) { + if (worker.status === 'outbound' && worker.destinationLabel) return `TRAVELLING TO ${worker.destinationLabel.toUpperCase()}`; + if (worker.status === 'gathering' && worker.resourceKind) return `GATHERING ${worker.resourceKind.toUpperCase()}`; + return ({ idle: 'READY AT KEEP', outbound: 'TRAVELLING TO RESOURCE', gathering: 'GATHERING RESOURCE', returning: 'RETURNING TO KEEP' } as const)[worker.status]; +} +export function realmWorkerCanRecall(worker: RealmWorkerPublicPresentation) { + return worker.ownedByViewer && (worker.status === 'outbound' || worker.status === 'gathering'); +} diff --git a/src/spacetime/WarpkeepSpacetimeProvider.tsx b/src/spacetime/WarpkeepSpacetimeProvider.tsx index ae8dac8..7014c75 100644 --- a/src/spacetime/WarpkeepSpacetimeProvider.tsx +++ b/src/spacetime/WarpkeepSpacetimeProvider.tsx @@ -34,6 +34,11 @@ import { readWarpkeepWoodExpeditionState, readWarpkeepStoneExpeditionState, readWarpkeepResourceState, + readWarpkeepResourceStateV2, + readWarpkeepWorkerRoster, + dispatchWarpkeepWorker, + recallWarpkeepWorker, + recallAllWarpkeepWorkers, readWarpkeepRealmSnapshot, subscribeToWarpkeepRealm, type WarpkeepConnection @@ -58,6 +63,11 @@ import type { ReadyGoldExpeditionPresentation } from '../components/realm/realmG import type { ReadyFoodExpeditionPresentation } from '../components/realm/realmFoodExpeditionPresentation'; import type { ReadyWoodExpeditionPresentation } from '../components/realm/realmWoodExpeditionPresentation'; import type { ReadyStoneExpeditionPresentation } from '../components/realm/realmStoneExpeditionPresentation'; +import type { + ReadyWorkerProjection, + ReadyWorkerResourceState, + WorkerRosterPresentation +} from '../components/realm/realmWorkerPresentation'; import { createExpeditionIdempotencyKey } from './expeditionIdempotencyKey'; /** @@ -119,6 +129,12 @@ export type WarpkeepBackendControllerValue = Readonly<{ dispatchStoneExpedition: (siteId: string) => Promise; /** Settle only the caller's Stone expedition and refresh both private views. */ claimStoneExpedition: () => Promise; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + dispatchWorker: (workerId: string, resourceKind: string, siteId: string) => Promise; + recallWorker: (workerId: string) => Promise; + recallAllWorkers: () => Promise; }>; /** @@ -135,6 +151,11 @@ export type WarpkeepBackendRuntime = Readonly<{ acceptAlphaTerms: typeof acceptWarpkeepAlphaTerms; readResourceState: typeof readWarpkeepResourceState; collectResources: typeof collectWarpkeepResources; + readWorkerRoster?: typeof readWarpkeepWorkerRoster; + readResourceStateV2?: typeof readWarpkeepResourceStateV2; + dispatchWorker?: typeof dispatchWarpkeepWorker; + recallWorker?: typeof recallWarpkeepWorker; + recallAllWorkers?: typeof recallAllWarpkeepWorkers; /** Optional only for older deterministic test/QA runtimes without v5 Gold. */ readGoldExpeditionState?: typeof readWarpkeepGoldExpeditionState; /** Optional only for older deterministic test/QA runtimes without v5 Gold. */ @@ -171,6 +192,11 @@ const DEFAULT_WARPKEEP_BACKEND_RUNTIME: WarpkeepBackendRuntime = Object.freeze({ acceptAlphaTerms: acceptWarpkeepAlphaTerms, readResourceState: readWarpkeepResourceState, collectResources: collectWarpkeepResources, + readWorkerRoster: readWarpkeepWorkerRoster, + readResourceStateV2: readWarpkeepResourceStateV2, + dispatchWorker: dispatchWarpkeepWorker, + recallWorker: recallWarpkeepWorker, + recallAllWorkers: recallAllWarpkeepWorkers, readGoldExpeditionState: readWarpkeepGoldExpeditionState, dispatchGoldExpedition: dispatchWarpkeepGoldExpedition, collectGoldExpedition: collectWarpkeepGoldExpedition, @@ -275,6 +301,29 @@ function resourceProjectionIsAtLeastAsNew( ); } +function activeWorkerProjection( + snapshot: WarpkeepRealmSnapshot, + roster: WorkerRosterPresentation | undefined +): ReadyWorkerProjection | undefined { + if ( + snapshot.workerSystem?.mode !== 'active' + || snapshot.workerSystem.workersPerCastle !== 4 + || snapshot.workerSystem.expectedWorkerCount !== snapshot.workerSystem.expectedCastleCount * 4 + || snapshot.workerWorkers === undefined + || snapshot.workerOccupations === undefined + || roster === undefined + || roster.workers.length !== 4 + ) return undefined; + const publicIds = new Set(snapshot.workerWorkers.map((worker) => worker.workerId)); + if (publicIds.size !== snapshot.workerSystem.expectedWorkerCount || roster.workers.some((worker) => !publicIds.has(worker.workerId))) return undefined; + return Object.freeze({ + mode: 'active' as const, + system: snapshot.workerSystem, + workers: snapshot.workerWorkers, + occupations: snapshot.workerOccupations + }); +} + /** * A focused React provider around the generated client bindings. It bypasses * the SDK's URI/database-only connection cache so a sign-out or changed bridge @@ -338,6 +387,10 @@ export function WarpkeepSpacetimeProvider({ }> | undefined>(undefined); const stoneExpeditionOperationGenerationRef = useRef(undefined); const stoneDispatchAttemptRef = useRef(undefined); + const workerRosterStateRef = useRef | undefined>(undefined); + const workerResourceStateRef = useRef | undefined>(undefined); + const workerCommandGenerationRef = useRef(undefined); + const workerDispatchAttemptRef = useRef(undefined); const processTermsAttemptRef = useRef<() => void>(() => undefined); stateRef.current = state; @@ -367,6 +420,10 @@ export function WarpkeepSpacetimeProvider({ stoneExpeditionStateRef.current = undefined; stoneExpeditionOperationGenerationRef.current = undefined; stoneDispatchAttemptRef.current = undefined; + workerRosterStateRef.current = undefined; + workerResourceStateRef.current = undefined; + workerCommandGenerationRef.current = undefined; + workerDispatchAttemptRef.current = undefined; canonicalRealmSourceRef.current = undefined; processTermsAttemptRef.current = () => undefined; runActiveTeardown(); @@ -446,6 +503,70 @@ export function WarpkeepSpacetimeProvider({ } }, [runActiveTeardown, runtime]); + const runWorkerCommand = useCallback(async ( + command: (connection: WarpkeepConnection, idempotencyKey: string) => Promise + ) => { + const generation = generationRef.current; + const currentState = stateRef.current; + const connection = connectionRef.current; + const fid = currentState.identity?.fid; + if ( + currentState.phase !== 'ready' || currentState.admission !== 'ready' + || currentState.workerProjection?.mode !== 'active' || connection === undefined || fid === undefined + || runtime.readWorkerRoster === undefined || runtime.readResourceStateV2 === undefined + || runtime.dispatchWorker === undefined && runtime.recallWorker === undefined && runtime.recallAllWorkers === undefined + || workerCommandGenerationRef.current === generation + ) throw new Error('Worker command is unavailable.'); + const idempotencyKey = workerDispatchAttemptRef.current?.generation === generation + ? workerDispatchAttemptRef.current.idempotencyKey + : createExpeditionIdempotencyKey(); + if (idempotencyKey === undefined) throw new Error('Worker command is unavailable.'); + workerCommandGenerationRef.current = generation; + workerDispatchAttemptRef.current = Object.freeze({ generation, siteId: 'worker-command', idempotencyKey }); + try { + await withResourceOperationDeadline(command(connection, idempotencyKey)); + const [roster, resourceState] = await Promise.all([ + withResourceOperationDeadline(runtime.readWorkerRoster(connection, fid)), + withResourceOperationDeadline(runtime.readResourceStateV2(connection, fid)) + ]); + if (generationRef.current !== generation || roster === undefined || resourceState === undefined) { + throw new Error('Worker command is unavailable.'); + } + workerRosterStateRef.current = Object.freeze({ generation, value: roster }); + workerResourceStateRef.current = Object.freeze({ generation, value: resourceState }); + setState((latest) => { + if (generationRef.current !== generation || latest.phase !== 'ready') return latest; + return { ...latest, workerRoster: roster, workerResourceState: resourceState }; + }); + workerDispatchAttemptRef.current = undefined; + } catch { + throw new Error('Worker command is unavailable.'); + } finally { + if (workerCommandGenerationRef.current === generation) workerCommandGenerationRef.current = undefined; + } + }, [runtime]); + + const dispatchWorker = useCallback((workerId: string, resourceKind: string, siteId: string) => ( + runWorkerCommand((connection, idempotencyKey) => { + if (runtime.dispatchWorker === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.dispatchWorker(connection, workerId, resourceKind, siteId, idempotencyKey); + }) + ), [runWorkerCommand, runtime]); + + const recallWorker = useCallback((workerId: string) => ( + runWorkerCommand((connection, idempotencyKey) => { + if (runtime.recallWorker === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.recallWorker(connection, workerId, idempotencyKey); + }) + ), [runWorkerCommand, runtime]); + + const recallAllWorkers = useCallback(() => ( + runWorkerCommand((connection, idempotencyKey) => { + if (runtime.recallAllWorkers === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.recallAllWorkers(connection, idempotencyKey); + }) + ), [runWorkerCommand, runtime]); + const dispatchGoldExpedition = useCallback(async (siteId: string) => { const generation = generationRef.current; const currentState = stateRef.current; @@ -1342,6 +1463,12 @@ export function WarpkeepSpacetimeProvider({ const stoneExpedition = stoneExpeditionStateRef.current?.generation === generation ? stoneExpeditionStateRef.current.value : undefined; + const workerRoster = workerRosterStateRef.current?.generation === generation + ? workerRosterStateRef.current.value + : undefined; + const workerResourceState = workerResourceStateRef.current?.generation === generation + ? workerResourceStateRef.current.value + : undefined; if ( !current() || !subscriptionApplied @@ -1358,6 +1485,7 @@ export function WarpkeepSpacetimeProvider({ readinessTimeout = undefined; } canonicalRealmSourceRef.current = canonicalRealmSource; + const workerProjection = activeWorkerProjection(realm, workerRoster); setState({ phase: 'ready', identity, @@ -1367,7 +1495,10 @@ export function WarpkeepSpacetimeProvider({ ...(goldExpedition === undefined ? {} : { goldExpedition }), ...(foodExpedition === undefined ? {} : { foodExpedition }), ...(woodExpedition === undefined ? {} : { woodExpedition }), - ...(stoneExpedition === undefined ? {} : { stoneExpedition }) + ...(stoneExpedition === undefined ? {} : { stoneExpedition }), + ...(workerRoster === undefined ? {} : { workerRoster }), + ...(workerResourceState === undefined ? {} : { workerResourceState }), + ...(workerProjection === undefined ? {} : { workerProjection }) }); } catch { fail(); @@ -1427,6 +1558,16 @@ export function WarpkeepSpacetimeProvider({ : withResourceOperationDeadline( runtime.readStoneExpeditionState(activeConnection) ).catch(() => undefined); + const initialWorkerRosterPromise = runtime.readWorkerRoster === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline( + runtime.readWorkerRoster(activeConnection, bridgeFid!) + ).catch(() => undefined); + const initialWorkerResourceStatePromise = runtime.readResourceStateV2 === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline( + runtime.readResourceStateV2(activeConnection, bridgeFid!) + ).catch(() => undefined); // A synchronous observer/subscription exception can terminate this // generation before the await below. Pre-handle the same promise so // its eventual rejection cannot escape as an unhandled rejection; @@ -1457,13 +1598,17 @@ export function WarpkeepSpacetimeProvider({ initialGoldExpedition, initialFoodExpedition, initialWoodExpedition, - initialStoneExpedition + initialStoneExpedition, + initialWorkerRoster, + initialWorkerResourceState ] = await Promise.all([ initialResourcePromise, initialGoldExpeditionPromise, initialFoodExpeditionPromise, initialWoodExpeditionPromise, - initialStoneExpeditionPromise + initialStoneExpeditionPromise, + initialWorkerRosterPromise, + initialWorkerResourceStatePromise ]); if (!current()) return; resourceStateRef.current = Object.freeze({ @@ -1486,6 +1631,8 @@ export function WarpkeepSpacetimeProvider({ generation, value: initialStoneExpedition }); + workerRosterStateRef.current = Object.freeze({ generation, value: initialWorkerRoster }); + workerResourceStateRef.current = Object.freeze({ generation, value: initialWorkerResourceState }); realmActivated = true; const refreshResources = async () => { if (!current() || !realmActivated || resourceRefreshInFlight) return; @@ -1511,14 +1658,22 @@ export function WarpkeepSpacetimeProvider({ : withResourceOperationDeadline( runtime.readStoneExpeditionState(activeConnection) ).catch(() => undefined); - const [refreshed, refreshedGoldExpedition, refreshedFoodExpedition, refreshedWoodExpedition, refreshedStoneExpedition] = await Promise.all([ + const workerRosterRefresh = runtime.readWorkerRoster === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline(runtime.readWorkerRoster(activeConnection, bridgeFid!)).catch(() => undefined); + const workerResourceRefresh = runtime.readResourceStateV2 === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline(runtime.readResourceStateV2(activeConnection, bridgeFid!)).catch(() => undefined); + const [refreshed, refreshedGoldExpedition, refreshedFoodExpedition, refreshedWoodExpedition, refreshedStoneExpedition, refreshedWorkerRoster, refreshedWorkerResourceState] = await Promise.all([ withResourceOperationDeadline( runtime.readResourceState(activeConnection, bridgeFid!) ), goldRefresh, foodRefresh, woodRefresh, - stoneRefresh + stoneRefresh, + workerRosterRefresh, + workerResourceRefresh ]); if (!current()) return; if (refreshed.fid !== BigInt(bridgeFid!)) { @@ -1545,6 +1700,8 @@ export function WarpkeepSpacetimeProvider({ generation, value: refreshedStoneExpedition }); + workerRosterStateRef.current = Object.freeze({ generation, value: refreshedWorkerRoster }); + workerResourceStateRef.current = Object.freeze({ generation, value: refreshedWorkerResourceState }); setState((latest) => { const latestRetained = resourceStateRef.current?.generation === generation ? resourceStateRef.current.value @@ -1567,6 +1724,11 @@ export function WarpkeepSpacetimeProvider({ foodExpedition: refreshedFoodExpedition, woodExpedition: refreshedWoodExpedition, stoneExpedition: refreshedStoneExpedition + , workerRoster: refreshedWorkerRoster + , workerResourceState: refreshedWorkerResourceState + , ...(latest.realm === undefined ? {} : { + workerProjection: activeWorkerProjection(latest.realm, refreshedWorkerRoster) + }) }; }); } catch { @@ -1627,7 +1789,10 @@ export function WarpkeepSpacetimeProvider({ dispatchWoodExpedition, claimWoodExpedition, dispatchStoneExpedition, - claimStoneExpedition + claimStoneExpedition, + dispatchWorker, + recallWorker, + recallAllWorkers }), [ beginAlphaTermsAcceptance, cancelAlphaTermsAcceptance, @@ -1642,6 +1807,9 @@ export function WarpkeepSpacetimeProvider({ dispatchFoodExpedition, dispatchWoodExpedition, dispatchStoneExpedition, + dispatchWorker, + recallWorker, + recallAllWorkers, sharedAlphaAvailable, state ]); diff --git a/src/spacetime/canonicalGenesisSnapshot.ts b/src/spacetime/canonicalGenesisSnapshot.ts index 91fbb6c..ec460ff 100644 --- a/src/spacetime/canonicalGenesisSnapshot.ts +++ b/src/spacetime/canonicalGenesisSnapshot.ts @@ -596,6 +596,13 @@ export function validateCanonicalGenesisSnapshot( const stoneNodeOccupations = stoneSites !== undefined ? freezeRows(candidate.stoneNodeOccupations!) : undefined; + // Generic workers are a staged additive projection. Preserve only the + // complete public trio; absent or malformed rows keep the legacy renderer + // path instead of revoking the canonical world snapshot. + const rawWorkerSystem = candidate.workerSystem; + const workerSystem = rawWorkerSystem === undefined ? undefined : freezePresentationValue(rawWorkerSystem); + const workerWorkers = candidate.workerWorkers === undefined ? undefined : freezePresentationValue(candidate.workerWorkers); + const workerOccupations = candidate.workerOccupations === undefined ? undefined : freezePresentationValue(candidate.workerOccupations); // The shared forest is another additive presentation pair. Keep each // field's presence intact for the browser-safe layout policy to verify. A // v6-but-unseeded `{ forestTrees: [] }`, a one-sided table, or malformed @@ -631,6 +638,9 @@ export function validateCanonicalGenesisSnapshot( ...(woodNodeOccupations === undefined ? {} : { woodNodeOccupations }), ...(stoneSites === undefined ? {} : { stoneSites }), ...(stoneNodeOccupations === undefined ? {} : { stoneNodeOccupations }), + ...(workerSystem === undefined ? {} : { workerSystem }), + ...(workerWorkers === undefined ? {} : { workerWorkers }), + ...(workerOccupations === undefined ? {} : { workerOccupations }), ...(forestLayout === undefined ? {} : { forestLayout }), ...(forestTrees === undefined ? {} : { forestTrees }), ...(waterProjection.layout === undefined ? {} : { waterLayout: waterProjection.layout }), diff --git a/src/spacetime/playerModuleBindings.ts b/src/spacetime/playerModuleBindings.ts index 353b711..ecce8cf 100644 --- a/src/spacetime/playerModuleBindings.ts +++ b/src/spacetime/playerModuleBindings.ts @@ -32,12 +32,14 @@ import { import AcceptAlphaTermsV1Reducer from './module_bindings/accept_alpha_terms_v_1_reducer' import BootstrapPlayerV2Reducer from './module_bindings/bootstrap_player_v_2_reducer' +import DispatchWorkerV1Reducer from './module_bindings/dispatch_worker_v_1_reducer' import CollectFoodExpeditionV1Reducer from './module_bindings/collect_food_expedition_v_1_reducer' import CollectGoldExpeditionV1Reducer from './module_bindings/collect_gold_expedition_v_1_reducer' import CollectWoodExpeditionV1Reducer from './module_bindings/collect_wood_expedition_v_1_reducer' import CollectStoneExpeditionV1Reducer from './module_bindings/collect_stone_expedition_v_1_reducer' import CollectResourcesV1Reducer from './module_bindings/collect_resources_v_1_reducer' import CastleRow from './module_bindings/castle_table' +import CastleWorkerV1Row from './module_bindings/castle_worker_v_1_table' import * as GetAlphaBackendInfoProcedure from './module_bindings/get_alpha_backend_info_procedure' import * as GetMyAdmissionStatusV2Procedure from './module_bindings/get_my_admission_status_v_2_procedure' import * as GetMyFoodExpeditionStateV1Procedure from './module_bindings/get_my_food_expedition_state_v_1_procedure' @@ -45,10 +47,14 @@ import * as GetMyGoldExpeditionStateV1Procedure from './module_bindings/get_my_g import * as GetMyWoodExpeditionStateV1Procedure from './module_bindings/get_my_wood_expedition_state_v_1_procedure' import * as GetMyStoneExpeditionStateV1Procedure from './module_bindings/get_my_stone_expedition_state_v_1_procedure' import * as GetMyResourceStateV1Procedure from './module_bindings/get_my_resource_state_v_1_procedure' +import * as GetMyResourceStateV2Procedure from './module_bindings/get_my_resource_state_v_2_procedure' +import * as GetMyWorkerRosterV1Procedure from './module_bindings/get_my_worker_roster_v_1_procedure' import DispatchFoodExpeditionV1Reducer from './module_bindings/dispatch_food_expedition_v_1_reducer' import DispatchGoldExpeditionV1Reducer from './module_bindings/dispatch_gold_expedition_v_1_reducer' import DispatchWoodExpeditionV1Reducer from './module_bindings/dispatch_wood_expedition_v_1_reducer' import DispatchStoneExpeditionV1Reducer from './module_bindings/dispatch_stone_expedition_v_1_reducer' +import RecallAllWorkersV1Reducer from './module_bindings/recall_all_workers_v_1_reducer' +import RecallWorkerV1Reducer from './module_bindings/recall_worker_v_1_reducer' import FoodNodeOccupationV1Row from './module_bindings/food_node_occupation_v_1_table' import FoodSiteV1Row from './module_bindings/food_site_v_1_table' import GoldNodeOccupationV1Row from './module_bindings/gold_node_occupation_v_1_table' @@ -58,6 +64,7 @@ import RealmForestInstanceV1Row from './module_bindings/realm_forest_instance_v_ import RealmForestLayoutV1Row from './module_bindings/realm_forest_layout_v_1_table' import RealmProfileV1Row from './module_bindings/realm_profile_v_1_table' import RealmV1Row from './module_bindings/realm_v_1_table' +import RealmWorkerSystemV1Row from './module_bindings/realm_worker_system_v_1_table' import RealmEnvironmentV1Row from './module_bindings/realm_environment_v_1_table' import WorldTileMetaV1Row from './module_bindings/world_tile_meta_v_1_table' import WorldTileRow from './module_bindings/world_tile_table' @@ -69,6 +76,7 @@ import WoodNodeOccupationV1Row from './module_bindings/wood_node_occupation_v_1_ import WoodSiteV1Row from './module_bindings/wood_site_v_1_table' import StoneNodeOccupationV1Row from './module_bindings/stone_node_occupation_v_1_table' import StoneSiteV1Row from './module_bindings/stone_site_v_1_table' +import WorkerNodeOccupationV1Row from './module_bindings/worker_node_occupation_v_1_table' const tablesSchema = __schema({ castle: __table({ @@ -90,6 +98,18 @@ const tablesSchema = __schema({ { name: 'castle_tile_key_key', constraint: 'unique', columns: ['tileKey'] }, ], }, CastleRow), + // Generic workers expose only stable identity, timing, and public node + // occupation. Private assignments and cargo remain procedure-only. + castleWorkerV1: __table({ + name: 'castle_worker_v1', + indexes: [ + { accessor: 'byOriginCastle', name: 'castle_worker_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: ['originCastleId'] }, + { accessor: 'workerId', name: 'castle_worker_v1_worker_id_idx_btree', algorithm: 'btree', columns: ['workerId'] }, + ], + constraints: [ + { name: 'castle_worker_v1_worker_id_key', constraint: 'unique', columns: ['workerId'] }, + ], + }, CastleWorkerV1Row), // The Food catalog mirrors the Gold boundary: player-visible site geometry // and occupation timing only. Its internal scheduler stays out of Vite. foodNodeOccupationV1: __table({ @@ -206,6 +226,15 @@ const tablesSchema = __schema({ { name: 'realm_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, ], }, RealmV1Row), + realmWorkerSystemV1: __table({ + name: 'realm_worker_system_v1', + indexes: [ + { accessor: 'realmId', name: 'realm_worker_system_v1_realm_id_idx_btree', algorithm: 'btree', columns: ['realmId'] }, + ], + constraints: [ + { name: 'realm_worker_system_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, + ], + }, RealmWorkerSystemV1Row), realmEnvironmentV1: __table({ name: 'realm_environment_v1', indexes: [ @@ -309,6 +338,17 @@ const tablesSchema = __schema({ { name: 'stone_site_v1_site_id_key', constraint: 'unique', columns: ['siteId'] }, ], }, StoneSiteV1Row), + workerNodeOccupationV1: __table({ + name: 'worker_node_occupation_v1', + indexes: [ + { accessor: 'nodeKey', name: 'worker_node_occupation_v1_node_key_idx_btree', algorithm: 'btree', columns: ['nodeKey'] }, + { accessor: 'byOriginCastle', name: 'worker_node_occupation_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: ['originCastleId'] }, + { accessor: 'byWorker', name: 'worker_node_occupation_v1_worker_id_idx_btree', algorithm: 'btree', columns: ['workerId'] }, + ], + constraints: [ + { name: 'worker_node_occupation_v1_node_key_key', constraint: 'unique', columns: ['nodeKey'] }, + ], + }, WorkerNodeOccupationV1Row), worldTile: __table({ name: 'world_tile', indexes: [ @@ -352,6 +392,9 @@ const reducersSchema = __reducers( __reducerSchema('dispatch_gold_expedition_v1', DispatchGoldExpeditionV1Reducer), __reducerSchema('dispatch_wood_expedition_v1', DispatchWoodExpeditionV1Reducer), __reducerSchema('dispatch_stone_expedition_v1', DispatchStoneExpeditionV1Reducer), + __reducerSchema('dispatch_worker_v1', DispatchWorkerV1Reducer), + __reducerSchema('recall_worker_v1', RecallWorkerV1Reducer), + __reducerSchema('recall_all_workers_v1', RecallAllWorkersV1Reducer), ) const proceduresSchema = __procedures( @@ -390,6 +433,16 @@ const proceduresSchema = __procedures( GetMyResourceStateV1Procedure.params, GetMyResourceStateV1Procedure.returnType, ), + __procedureSchema( + 'get_my_resource_state_v2', + GetMyResourceStateV2Procedure.params, + GetMyResourceStateV2Procedure.returnType, + ), + __procedureSchema( + 'get_my_worker_roster_v1', + GetMyWorkerRosterV1Procedure.params, + GetMyWorkerRosterV1Procedure.returnType, + ), ) const PLAYER_REMOTE_MODULE = { diff --git a/src/spacetime/warpkeepBackendTypes.ts b/src/spacetime/warpkeepBackendTypes.ts index 71f3bb1..1df8469 100644 --- a/src/spacetime/warpkeepBackendTypes.ts +++ b/src/spacetime/warpkeepBackendTypes.ts @@ -4,6 +4,14 @@ import type { ReadyGoldExpeditionPresentation } from '../components/realm/realmG import type { ReadyFoodExpeditionPresentation } from '../components/realm/realmFoodExpeditionPresentation'; import type { ReadyWoodExpeditionPresentation } from '../components/realm/realmWoodExpeditionPresentation'; import type { ReadyStoneExpeditionPresentation } from '../components/realm/realmStoneExpeditionPresentation'; +import type { + RealmWorkerNodeOccupation, + RealmWorkerPublicPresentation, + RealmWorkerSystemPresentation, + WorkerRosterPresentation, + ReadyWorkerResourceState, + ReadyWorkerProjection +} from '../components/realm/realmWorkerPresentation'; export type WarpkeepAdmissionStatus = | 'not_admitted' @@ -179,6 +187,10 @@ export type WarpkeepStoneNodeOccupation = Readonly<{ returnsAtMicros: bigint; }>; +export type WarpkeepRealmWorkerSystem = RealmWorkerSystemPresentation; +export type WarpkeepCastleWorker = RealmWorkerPublicPresentation; +export type WarpkeepWorkerNodeOccupation = RealmWorkerNodeOccupation; + /** * Public, immutable realm-wide forest layout metadata. This is visual state * only: server-side seeding authority and all administrative reducers remain @@ -342,6 +354,10 @@ export type WarpkeepRealmSnapshotCandidate = Readonly<{ stoneSites?: readonly WarpkeepStoneSite[]; /** Omitted with `stoneSites`; absent/invalid data renders no Stone nodes. */ stoneNodeOccupations?: readonly WarpkeepStoneNodeOccupation[]; + /** Additive generic-worker public projection; absent keeps legacy mode. */ + workerSystem?: WarpkeepRealmWorkerSystem; + workerWorkers?: readonly WarpkeepCastleWorker[]; + workerOccupations?: readonly WarpkeepWorkerNodeOccupation[]; /** * Additive public forest metadata. The connection publishes the pair only * after one atomic subscription applies; a one-sided test/malformed value @@ -389,6 +405,11 @@ export type WarpkeepBackendState = Readonly<{ woodExpedition?: ReadyWoodExpeditionPresentation; /** Caller-only, exact procedure projection for the active Stone expedition. */ stoneExpedition?: ReadyStoneExpeditionPresentation; + /** Caller-private generic roster, never copied into the public snapshot. */ + workerRoster?: WorkerRosterPresentation; + /** v2 resource balances used by the active worker HUD. */ + workerResourceState?: ReadyWorkerResourceState; + workerProjection?: ReadyWorkerProjection; }>; export const IDLE_WARPKEEP_BACKEND_STATE: WarpkeepBackendState = Object.freeze({ diff --git a/src/spacetime/warpkeepConnection.ts b/src/spacetime/warpkeepConnection.ts index cfde15e..9ab0e3d 100644 --- a/src/spacetime/warpkeepConnection.ts +++ b/src/spacetime/warpkeepConnection.ts @@ -83,6 +83,15 @@ import { isRealmStoneNodeOccupationPublicRecord, isRealmStoneSitePublicRecord } from '../components/realm/realmStoneNodePresentation'; +import { + decodeRealmWorkerOccupations, + decodeRealmWorkerPublicRows, + decodeRealmWorkerSystem, + decodeWorkerResourceState, + decodeWorkerRoster, + type ReadyWorkerResourceState, + type WorkerRosterPresentation +} from '../components/realm/realmWorkerPresentation'; import { REALM_FOOD_SITE_COUNT, REALM_GOLD_SITE_COUNT, @@ -165,6 +174,11 @@ type WaterProjectionAvailability = | typeof WATER_PROJECTION_PENDING | typeof WATER_PROJECTION_READY; const waterProjectionAvailability = new WeakMap(); +const WORKER_PROJECTION_UNAVAILABLE = 'unavailable' as const; +const WORKER_PROJECTION_PENDING = 'pending' as const; +const WORKER_PROJECTION_READY = 'ready' as const; +type WorkerProjectionAvailability = typeof WORKER_PROJECTION_UNAVAILABLE | typeof WORKER_PROJECTION_PENDING | typeof WORKER_PROJECTION_READY; +const workerProjectionAvailability = new WeakMap(); const GOLD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const GOLD_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; const FOOD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; @@ -173,6 +187,8 @@ const WOOD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const WOOD_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; const STONE_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const STONE_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; +const WORKER_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; +const WORKER_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; export { WARPKEEP_ALPHA_TERMS_VERSION } from '../legal/alphaTermsPolicy'; const admissionStatuses = new Set([ @@ -362,6 +378,76 @@ export async function readWarpkeepResourceState( return decoded; } +/** Read the generic worker's caller-private roster; public rows never carry cargo. */ +export async function readWarpkeepWorkerRoster( + connection: WarpkeepConnection, + ownFid: number +): Promise { + if (!Number.isSafeInteger(ownFid) || ownFid <= 0) return undefined; + const procedure = (connection.procedures as unknown as { + getMyWorkerRosterV1?: (input: Readonly>) => Promise; + }).getMyWorkerRosterV1; + if (typeof procedure !== 'function') return undefined; + return decodeWorkerRoster(await procedure({}), BigInt(ownFid)); +} + +/** v2 balances are the only resource values consumed by the active worker rail. */ +export async function readWarpkeepResourceStateV2( + connection: WarpkeepConnection, + ownFid: number +): Promise { + if (!Number.isSafeInteger(ownFid) || ownFid <= 0) return undefined; + const procedure = (connection.procedures as unknown as { + getMyResourceStateV2?: (input: Readonly>) => Promise; + }).getMyResourceStateV2; + if (typeof procedure !== 'function') return undefined; + return decodeWorkerResourceState(await procedure({}), BigInt(ownFid)); +} + +function workerReducerSurface(connection: WarpkeepConnection) { + return connection.reducers as unknown as { + dispatchWorkerV1?: (input: Readonly<{ workerId: string; resourceKind: string; siteId: string; idempotencyKey: string }>) => Promise | unknown; + recallWorkerV1?: (input: Readonly<{ workerId: string; idempotencyKey: string }>) => Promise | unknown; + recallAllWorkersV1?: (input: Readonly<{ idempotencyKey: string }>) => Promise | unknown; + }; +} + +function assertWorkerIdempotency(workerId: string, idempotencyKey: string) { + if (!WORKER_ID_PATTERN.test(workerId) || !WORKER_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) { + throw new Error('Worker command is unavailable.'); + } +} + +export async function dispatchWarpkeepWorker( + connection: WarpkeepConnection, + workerId: string, + resourceKind: string, + siteId: string, + idempotencyKey: string +) { + assertWorkerIdempotency(workerId, idempotencyKey); + if (!/^(food|wood|stone|gold)$/.test(resourceKind) || !/^[a-z0-9][a-z0-9:_-]{0,95}$/i.test(siteId)) { + throw new Error('Worker command is unavailable.'); + } + const reducer = workerReducerSurface(connection).dispatchWorkerV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ workerId, resourceKind, siteId, idempotencyKey }); +} + +export async function recallWarpkeepWorker(connection: WarpkeepConnection, workerId: string, idempotencyKey: string) { + assertWorkerIdempotency(workerId, idempotencyKey); + const reducer = workerReducerSurface(connection).recallWorkerV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ workerId, idempotencyKey }); +} + +export async function recallAllWarpkeepWorkers(connection: WarpkeepConnection, idempotencyKey: string) { + if (!WORKER_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) throw new Error('Worker command is unavailable.'); + const reducer = workerReducerSurface(connection).recallAllWorkersV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ idempotencyKey }); +} + /** Settle server-authoritative yield, then fetch the exact committed view. */ export async function collectWarpkeepResources( connection: WarpkeepConnection, @@ -788,6 +874,33 @@ export function subscribeToWarpkeepRealm( stoneProjectionAvailability.set(connection, STONE_PROJECTION_UNAVAILABLE); } + let workerSubscription: SubscriptionHandle | undefined; + const workerTables = publicWorkerTables(connection); + if (workerTables !== undefined) { + try { + workerSubscription = connection + .subscriptionBuilder() + .onApplied(() => { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_READY); + if (coreApplied) onApplied(); + }) + .onError(() => { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + if (coreApplied) onApplied(); + }) + .subscribe([ + tables.realmWorkerSystemV1, + tables.castleWorkerV1, + tables.workerNodeOccupationV1 + ]); + } catch { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + if (coreApplied) onApplied(); + } + } else { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + } + let forestSubscription: SubscriptionHandle | undefined; const forestTables = publicForestTables(connection); // A pre-forest service (or a deliberately narrow test double) cannot make @@ -858,6 +971,7 @@ export function subscribeToWarpkeepRealm( foodProjectionAvailability.delete(connection); woodProjectionAvailability.delete(connection); stoneProjectionAvailability.delete(connection); + workerProjectionAvailability.delete(connection); forestProjectionAvailability.delete(connection); waterProjectionAvailability.delete(connection); try { @@ -871,7 +985,11 @@ export function subscribeToWarpkeepRealm( try { woodSubscription?.unsubscribe(); } finally { - stoneSubscription?.unsubscribe(); + try { + stoneSubscription?.unsubscribe(); + } finally { + workerSubscription?.unsubscribe(); + } } } } @@ -1385,6 +1503,35 @@ function publicStoneSubscriptionTables() { }); } +type PublicWorkerTable = PublicFoodTable; +function publicWorkerTables(connection: WarpkeepConnection) { + const db = connection.db as unknown as Readonly<{ + realmWorkerSystemV1?: PublicWorkerTable; + castleWorkerV1?: PublicWorkerTable; + workerNodeOccupationV1?: PublicWorkerTable; + }> | undefined; + if (!db?.realmWorkerSystemV1 || !db.castleWorkerV1 || !db.workerNodeOccupationV1) return undefined; + return db; +} + +function readPublicWorkerProjection( + connection: WarpkeepConnection, + castles: readonly WarpkeepCastle[], + ownCastleId: number +) { + if (workerProjectionAvailability.get(connection) !== WORKER_PROJECTION_READY) return undefined; + const db = publicWorkerTables(connection); + if (!db) return undefined; + const systems = [...db.realmWorkerSystemV1!.iter()]; + if (systems.length !== 1) return undefined; + const system = decodeRealmWorkerSystem(systems[0]); + const castleNames = new Map(castles.map((castle) => [castle.castleId, castle.name] as const)); + const workers = decodeRealmWorkerPublicRows([...db.castleWorkerV1!.iter()], castleNames, ownCastleId); + const occupations = decodeRealmWorkerOccupations([...db.workerNodeOccupationV1!.iter()]); + if (!system || !workers || !occupations) return undefined; + return Object.freeze({ system, workers, occupations }); +} + /** * Read the additive public Gold catalog as an all-or-nothing visual * projection. Any malformed or duplicate row omits the complete Gold layer; @@ -1770,6 +1917,9 @@ export function readWarpkeepRealmSnapshot( const publicStone = readPublicStoneProjection(connection); const publicForest = readPublicForestProjection(connection); const publicWater = readPublicWaterProjection(connection); + const publicWorkers = ownCastle === undefined + ? undefined + : readPublicWorkerProjection(connection, castles, ownCastle.castleId); const candidate: WarpkeepRealmSnapshotCandidate = { tiles: readWorldTiles(connection), tileMetadata: readWorldTileMetadata(connection), @@ -1806,6 +1956,11 @@ export function readWarpkeepRealmSnapshot( waterRevision: publicWater.waterRevision }) }), + ...(publicWorkers === undefined ? {} : { + workerSystem: publicWorkers.system, + workerWorkers: publicWorkers.workers, + workerOccupations: publicWorkers.occupations + }), ...(ownCastle ? { ownCastle } : {}) }; return validateCanonicalGenesisSnapshot(candidate, { @@ -1891,6 +2046,16 @@ export function observeWarpkeepRealm( stoneTables?.stoneNodeOccupationV1?.onInsert?.(sync); stoneTables?.stoneNodeOccupationV1?.onDelete?.(sync); stoneTables?.stoneNodeOccupationV1?.onUpdate?.(sync); + const workerTables = publicWorkerTables(connection); + workerTables?.realmWorkerSystemV1?.onInsert?.(sync); + workerTables?.realmWorkerSystemV1?.onDelete?.(sync); + workerTables?.realmWorkerSystemV1?.onUpdate?.(sync); + workerTables?.castleWorkerV1?.onInsert?.(sync); + workerTables?.castleWorkerV1?.onDelete?.(sync); + workerTables?.castleWorkerV1?.onUpdate?.(sync); + workerTables?.workerNodeOccupationV1?.onInsert?.(sync); + workerTables?.workerNodeOccupationV1?.onDelete?.(sync); + workerTables?.workerNodeOccupationV1?.onUpdate?.(sync); const forestTables = publicForestTables(connection); forestTables?.realmForestLayoutV1?.onInsert?.(sync); forestTables?.realmForestLayoutV1?.onDelete?.(sync); @@ -1959,6 +2124,15 @@ export function observeWarpkeepRealm( stoneTables?.stoneNodeOccupationV1?.removeOnInsert?.(sync); stoneTables?.stoneNodeOccupationV1?.removeOnDelete?.(sync); stoneTables?.stoneNodeOccupationV1?.removeOnUpdate?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnInsert?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnDelete?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnUpdate?.(sync); + workerTables?.castleWorkerV1?.removeOnInsert?.(sync); + workerTables?.castleWorkerV1?.removeOnDelete?.(sync); + workerTables?.castleWorkerV1?.removeOnUpdate?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnInsert?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnDelete?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnUpdate?.(sync); forestTables?.realmForestLayoutV1?.removeOnInsert?.(sync); forestTables?.realmForestLayoutV1?.removeOnDelete?.(sync); forestTables?.realmForestLayoutV1?.removeOnUpdate?.(sync); diff --git a/tests/playerModuleBindings.test.ts b/tests/playerModuleBindings.test.ts index 3c04151..f037ac1 100644 --- a/tests/playerModuleBindings.test.ts +++ b/tests/playerModuleBindings.test.ts @@ -8,6 +8,7 @@ import { DbConnection, tables as playerTables } from '../src/spacetime/playerMod const PLAYER_TABLE_KEYS = [ 'castle', + 'castleWorkerV1', 'foodNodeOccupationV1', 'foodSiteV1', 'goldNodeOccupationV1', @@ -18,6 +19,7 @@ const PLAYER_TABLE_KEYS = [ 'realmForestLayoutV1', 'realmProfileV1', 'realmV1', + 'realmWorkerSystemV1', 'realmWaterBodyV1', 'realmWaterCellV1', 'realmWaterLayoutV1', @@ -26,6 +28,7 @@ const PLAYER_TABLE_KEYS = [ 'stoneSiteV1', 'woodNodeOccupationV1', 'woodSiteV1', + 'workerNodeOccupationV1', 'worldTile', 'worldTileMetaV1', ] as const @@ -80,6 +83,11 @@ describe('player SpacetimeDB bindings', () => { expect(playerBindings).toContain("'get_my_wood_expedition_state_v1'") expect(playerBindings).toContain("'get_my_stone_expedition_state_v1'") expect(playerBindings).toContain("'get_my_resource_state_v1'") + expect(playerBindings).toContain("'get_my_resource_state_v2'") + expect(playerBindings).toContain("'get_my_worker_roster_v1'") + expect(playerBindings).toContain("'dispatch_worker_v1'") + expect(playerBindings).toContain("'recall_worker_v1'") + expect(playerBindings).toContain("'recall_all_workers_v1'") expect(playerBindings).toContain("'realm_forest_layout_v1'") expect(playerBindings).toContain("'realm_forest_instance_v1'") expect(playerBindings).toContain("'realm_water_revision_v1'") @@ -103,6 +111,8 @@ describe('player SpacetimeDB bindings', () => { expect(playerBindings).not.toContain('qa_observer_') expect(playerBindings).not.toContain('QA_OBSERVER') expect(playerBindings).not.toContain('/v1/qa/') + expect(playerBindings).not.toContain('worker_assignment_schedule_v_1') + expect(playerBindings).not.toContain('worker_assignment_idempotency_v_1') }) it('exposes only the player reducer and procedure accessors at runtime', () => { @@ -134,6 +144,9 @@ describe('player SpacetimeDB bindings', () => { 'dispatchGoldExpeditionV1', 'dispatchStoneExpeditionV1', 'dispatchWoodExpeditionV1', + 'dispatchWorkerV1', + 'recallAllWorkersV1', + 'recallWorkerV1', ]) expect(Object.keys(connection.procedures).sort()).toEqual([ 'getAlphaBackendInfo', @@ -141,8 +154,10 @@ describe('player SpacetimeDB bindings', () => { 'getMyFoodExpeditionStateV1', 'getMyGoldExpeditionStateV1', 'getMyResourceStateV1', + 'getMyResourceStateV2', 'getMyStoneExpeditionStateV1', 'getMyWoodExpeditionStateV1', + 'getMyWorkerRosterV1', ]) connection.disconnect() diff --git a/tests/realmPickArbitration.test.ts b/tests/realmPickArbitration.test.ts index 9ea8aa1..76332de 100644 --- a/tests/realmPickArbitration.test.ts +++ b/tests/realmPickArbitration.test.ts @@ -13,6 +13,15 @@ const RESOURCE_KINDS = [ ] as const satisfies readonly RealmResourcePickKind[]; describe('realm scene pick arbitration', () => { + it('gives a worker identity lane priority over resource and castle colliders', () => { + expect(arbitrateRealmPick({ + workerHits: [{ workerId: 'worker-1', workerOrdinal: 1, originCastleId: 77, coord: { q: 1, r: 0 }, distance: 20 }], + resourceHits: [], + castleHit: { castleId: 77, coord: { q: 0, r: 0 } }, + terrainHit: { coord: { q: 2, r: 0 } } + })).toEqual({ kind: 'worker', workerId: 'worker-1', workerOrdinal: 1, originCastleId: 77, coord: { q: 1, r: 0 } }); + }); + it.each(RESOURCE_KINDS)( 'lets the nearest moving %s wagon win over castle, static site, and terrain', (kind) => { diff --git a/tests/realmWorkerPresentation.test.ts b/tests/realmWorkerPresentation.test.ts new file mode 100644 index 0000000..83c5f7b --- /dev/null +++ b/tests/realmWorkerPresentation.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + decodeRealmWorkerOccupations, + decodeRealmWorkerPublicRows, + decodeRealmWorkerSystem, + decodeWorkerResourceState, + decodeWorkerRoster, + workerAvailabilityCount +} from '../src/components/realm/realmWorkerPresentation'; + +const publicWorker = (ordinal: number, originCastleId = 7) => ({ + workerId: `castle-7-worker-${ordinal}`, + originCastleId, + ordinal, + status: ordinal === 1 ? 'idle' : 'gathering', + assignmentId: ordinal === 1 ? undefined : `assignment-${ordinal}`, + resourceKind: ordinal === 1 ? undefined : 'stone', + siteId: ordinal === 1 ? undefined : `stone:${ordinal}`, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 1, + revision: 1n +}); + +describe('generic worker presentation boundary', () => { + it('requires the exact four-worker active system contract', () => { + expect(decodeRealmWorkerSystem({ + realmId: 'genesis-001', policyVersion: 'worker-v1', workersPerCastle: 4, + expectedCastleCount: 1, expectedWorkerCount: 4, rosterDigest: 'digest', mode: 'active', + legacyDrainRequired: false + })?.mode).toBe('active'); + expect(decodeRealmWorkerSystem({ workersPerCastle: 3 })).toBeUndefined(); + }); + + it('decodes public workers without private assignment or cargo fields', () => { + const workers = decodeRealmWorkerPublicRows( + [1, 2, 3, 4].map((ordinal) => publicWorker(ordinal)), + new Map([[7, 'Keep']]), + 7 + ); + expect(workers).toHaveLength(4); + expect(workers?.[0]).toMatchObject({ workerId: 'castle-7-worker-1', ownedByViewer: true }); + expect(Object.keys(workers?.[0] ?? {})).not.toContain('assignmentId'); + expect(workerAvailabilityCount(workers ?? [])).toBe(1); + expect(decodeRealmWorkerPublicRows([publicWorker(1), publicWorker(1)], new Map([[7, 'Keep']]), 7)).toBeUndefined(); + }); + + it('keeps occupation and caller roster projections bounded', () => { + const occupation = decodeRealmWorkerOccupations([{ + nodeKey: 'stone:1', resourceKind: 'stone', siteId: 'stone:1', workerId: 'worker-1', workerOrdinal: 1, + originCastleId: 7, assignmentId: 'private-id', phase: 'gathering', startedAtMicros: 1n, + arrivesAtMicros: 2n, gatheringEndsAtMicros: 3n, timelineRevision: 1 + }]); + expect(occupation?.[0].resourceKind).toBe('stone'); + const roster = decodeWorkerRoster({ + fid: 42n, castleId: 7n, observedAtMicros: 9n, + workers: [1, 2, 3, 4].map((ordinal) => ({ + workerId: `worker-${ordinal}`, ordinal, status: 'idle', resourceKind: undefined, siteId: undefined, + accruedAmount: 0n, materializedAmount: 0n, availableAmount: BigInt(ordinal), observedAtMicros: 9n, revision: 1n + })) + }, 42n); + expect(roster?.workers[3].availableAmount).toBe(4n); + expect(decodeWorkerResourceState({ + fid: 42n, food: 1n, wood: 2n, stone: 3n, gold: 4n, + workerPendingFood: 0n, workerPendingWood: 0n, workerPendingStone: 0n, workerPendingGold: 0n, + observedAtMicros: 5n, settledThroughMicros: 5n, revision: 1n, + resourcePolicyVersion: 'resource-v2', workerPolicyVersion: 'worker-v1', workerSystemMode: 'active' + }, 42n)?.available.stone).toBe(3n); + }); +});