From ab6b2ce9fc24c822d9d7d8a3d4e1288d7e3cd961 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 3 Jul 2026 07:32:41 -0600 Subject: [PATCH] feat: add Dungeon Boss and Star Wars Deckbuilder card games Implement two complete card games with full rule engines, AI opponents (5 skill levels), procedural rendering, and drop-in art support. Dungeon Boss: Boss Monster-style dungeon building game for 2-4 players. Players build dungeons of rooms and spells to lure and defeat heroes, harvesting souls to win. Includes 8 bosses, 31 room cards, 16 spells, and 41 heroes with class-based baiting mechanics. Star Wars Deckbuilder: 2-player deckbuilding game. Players build decks from faction and neutral cards, purchase from the galaxy row, bounty hunt enemy units, and attack bases. First to destroy 3 enemy bases wins. Includes 90-card galaxy deck, 10 bases with abilities, and the Force mechanic. Both games include: - Pure logic engines (rules-verified by headless test suites) - Synchronous heuristic AI with skill-based decision quality - Phaser scenes with vector-rendered procedural art - Drop-in sprite sheet support via JSON mappings - Tutorial documentation and art specifications - Registration in the game registry and scene loader --- assets/images/game-icons.png | Bin 292702 -> 301607 bytes assets/images/game-icons.psd | Bin 758294 -> 779096 bytes data/dungeonboss-artwork.json | 51 + data/swdbg-artwork.json | 42 + data/swdbg-cards.json | 295 +++++ src/data/gamesRegistry.js | 2 + src/games/dungeonboss/DungeonBossAI.js | 385 ++++++ src/games/dungeonboss/DungeonBossData.js | 444 +++++++ src/games/dungeonboss/DungeonBossGame.js | 1390 +++++++++++++++++++++ src/games/dungeonboss/DungeonBossLogic.js | 1375 ++++++++++++++++++++ src/games/dungeonboss/sprites.md | 252 ++++ src/games/dungeonboss/tutorial.md | 74 ++ src/games/swdbg/SWDBGAI.js | 290 +++++ src/games/swdbg/SWDBGData.js | 91 ++ src/games/swdbg/SWDBGGame.js | 1333 ++++++++++++++++++++ src/games/swdbg/SWDBGLogic.js | 872 +++++++++++++ src/games/swdbg/sprites.md | 171 +++ src/games/swdbg/tutorial.md | 72 ++ src/main.js | 4 + src/scenes/GameRoomScene.js | 2 +- src/scenes/OpponentSelectScene.js | 2 +- src/scenes/PreloadScene.js | 17 +- tools/verifyDungeonBoss.js | 322 +++++ tools/verifySWDBG.js | 380 ++++++ 24 files changed, 7862 insertions(+), 4 deletions(-) create mode 100644 data/dungeonboss-artwork.json create mode 100644 data/swdbg-artwork.json create mode 100644 data/swdbg-cards.json create mode 100644 src/games/dungeonboss/DungeonBossAI.js create mode 100644 src/games/dungeonboss/DungeonBossData.js create mode 100644 src/games/dungeonboss/DungeonBossGame.js create mode 100644 src/games/dungeonboss/DungeonBossLogic.js create mode 100644 src/games/dungeonboss/sprites.md create mode 100644 src/games/dungeonboss/tutorial.md create mode 100644 src/games/swdbg/SWDBGAI.js create mode 100644 src/games/swdbg/SWDBGData.js create mode 100644 src/games/swdbg/SWDBGGame.js create mode 100644 src/games/swdbg/SWDBGLogic.js create mode 100644 src/games/swdbg/sprites.md create mode 100644 src/games/swdbg/tutorial.md create mode 100644 tools/verifyDungeonBoss.js create mode 100644 tools/verifySWDBG.js diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 3d219da94d873f9347996573db1b6d72d66b9981..2d2456ba4d6e1e866968c338085e5bc439755400 100644 GIT binary patch delta 31089 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 U5izYjRV33hO@pVXCokUp525G{_y7O^ delta 22114 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 H?>qkmm!BH7 diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index 5e5645a28c22e7ce9f6ca99eb4f8336eff61b1f6..ad12092007ad03cbab27e288f5cbf2d3cd1f52fd 100644 GIT binary patch delta 33540 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 Z%(HU{cJDtx=^_^VNgloGB~VH4|Njr+5*+{l delta 11387 zcmeHNc{r49+rKepjLE)b-(yS^J!H$0vW)ComWs5X>`^KWB86;&J6o14*(qxx`;sDi z)@&tIwq&3AZqM_Yrtf?I`~LX8Ki=ayuHpRs&h*gyF}@5(J4P7Q)lDAiJ_2T8qFRd=I#Wk)WXQxi$t&T< zX|N#{pm({AvK-RNI5OSis9e(Exm_!}-9s+X2Q}hw3r?taX|yKJvsVF_n+Gc}u{l*Z zZGb+}jWB)2cAO)4@R`bO>7fD})^`i|=+O6Uq8Lkgfp9gE>Gu3kv#~;SAIH=FmYLz9 zsul+P10X zdG#Q{zLV}TG2V5wpGubuqHj`syXUlV4)_$bs9Wo}?fCG&QOU(S!+3DH>}57%(Q(X{_eP>_o@A{R zU3DGJ|2gtJA#OEa^}s!D&x9wsouU%opJ;P?hJ~wP@vSenW@R{%opaK!9sizO9m4Tm?MUt~4`mPk|bp+q;i5~J8W`E0XBH_4Bdmoo6g2r~?+g0vHYK1KJ4sg?j zqk2leXZ>~Lxx?YKv=YojRMdorf&97^tJ#3&$F56NH@iG-oP%VsIT|vqz0rhw?^+S( zyl%|ASj*1ttYXzRy_wPxo|@0JOpV-lY9PY1Ii2uue!Ql?ecTE77Uerq2)|`>N)-jfPY~d1ShuJ}nJ`ztD->BKd?z%8T9X0-0@$iQ2n| zBJ#!z0+cEOGyGx!^b$voMQpw)xw~enaD+aH&)3>l>DNx|(@-zw*;_BpB~^=eTsc-Qqn;R5F!blNk_uT2;caT`u^xYZQ?wFvKP^a$;iZP^CrOM{t|rR9$9wA{ha`YFiO$R!#~nuz&8ACoM_?*YoRzd+F7t^4D}SgTsreKc%EKr+S5r88PePT0}IJFi31g-nGlgu(vP6{&%32{oM}Y}6iU5w^JETrJ)HUA3pF=S+&I zBj@UHLAyt6<)Y0hoxjIiRufptBeB;) zX5DGu_KRe@8|l*wCpT+};L7E5cv%X|;AuAa02+2&A?t~0DmF94YbLzc-^st6t+>HI z>b`D@`#cj+c9_wWWdVT@l!x(LEpHnX&*Z4`GE-u?t9x;`Pn^r%OmIQ3Wf>XUYT-OE zipKU7R4HL!GY3t3Xu3TMpu5-a8iAY3?_kTijyQ)s<&{$w-R7>u@zPrv784dWy^%ll zbzz+s&w=E^jqq*8-<#z7_*^GoM~A7_~Z(KnK@t?_KqOa~NRixwZv-{Rb+y!KcB3G~;`S%k+Y z?E7_kE84LiW3cW+l7S3cwmIr(;Z})GeFa&i)E^e=xwyk^hG)Fb6cjlJSdK-er9zKD!z( ziQszE9JXboNB$~(jh^x29iKNp zxe2qiy>zEOr|@Y@KYxJk9TO?*-I$y#``4EHHb}nMHPKqH`)TPUnkF%4uw+Cg4vgb;Fw#YUHxo&J$ zUE)bo!0B{6dhNM6fBsusN0KjwjiGqN6X`QMLQlz@&6;U{^M08BIdeqD@vMfvER{$t zsn*!h(O6Lpv^@V@{b~vul}G>0TU_Tq%;?I@N=}T(B>UKk!ZHHrN0XbTKk#gByZxNn z^{#qBh^=;b#?{5?&!?Q#7prL^sHCBbE!L@PZNf2i3HY4gGG&+dZ0CFb`OCR>t;ta{ z-o8-#_c=|Gw4?e=TSN{d#zL6Pkf{@PZy`! zP%GPJqzR96bGC-OE99bGx_#R5MeczIDW{nGYH^)B?y)&EI)bY6K*j_^aiL4%0_IE7 z?XI-wxJ6oDdZ9x!tQ zLuYfnP%6T)$F%6KO5NH1As+2B`V{B{W~th7VhI*cWl!IKUZZJay#``!&Q-_gZE4cq7jG z2Tt2D13`s`@rZlqjKtbf`N7?z%)(2ow|w)DSn2Do%P+RM4_wO3dWNJ)WTLgi^1SU~ z9ACpk`v?Tk%WrmN&y_{@VWk$Cq(acDHq#Z^zVFxN8)EGvXcuc$X2jGL%bv^rl~Lhk ztFv=balGQy!Z*qH?M4{qbNSwN&VtsaGPqkyG@bEOR@yPoWvs&IpS|;S*SK67R64J2 zgrU|zB6XBqRuohwZoBvFie_&;^6o3YT>PoOJiY(rT!2bwCCjK}jzmju1YjpH!N?$2 zqsL+3*dYwwNnF$lWt*5!a*iwuDDyL>G)ak?nC{fOURy1+Iq_J&f+?WG^*T=T@-9we zK8ox6)zXi$`6m?a8Jh`Bjb6$$j`Q4CBlkv!o)wSi44= z%kbHXw!}FUC!1g0{4YFd!|e=&JDVQd~b0fx2$u`XQm^Wk@CP6PhaIztI&>*I(CQi@9wZL z<^O7(YQK0cpbhR1Pjw=_ zHK(qcpx&gqi0$(n8{fB{+fFNME-|4wLc_5*qxi9%{OsGu0^}ss7?n3VJuf^yiz`6p zU6-*PyV82pLA5>T%_agNrPhO$<&V5;io7doR!%qQ-#!=bNM?bAs}SB9w$ExmrjA%4~FC~xTJq|7^o_5Td}Ov30$$1w&zXG zTx~O#o0J!rmKUM=Qnqxqw`7s6dSvr@*Nr@{TW)6)7gFYMbJpFwMmsl+-UqqFEj+A% zC*7=lQM|>@sv@t-OzYmYyq;U0Ht+H*)T?TA#6?_}0iDH!q9- zp`_=9(44Do`M4$huzRMKL%hiyiBZ^Tg3*0!pB5@b)kb}A+Umga+W26+XirRz8k_!_ z2*CUKh?^^@#tib$b3IyW{;;~k>$)-0Gj~04P~+`#xB+j6TzKEQ<*p$3yo{3HCZ>3` z`l6iDh}>sxwYsqYh3So=K?B@X%+6DZSeYA-810oGF(116D$~!RS}e&<1GYt38)xTy zCkxCwm*XdvMxBrJ{o>);<|;dG7|st|nvF$k zcWX|#4phYG1oJPj2fVeh6O_PeZTX=HvwjTt&TBosr(e$Hl%Hi!5%9#GVW*A^-dNYZiaU6G)*A&5InNlhNVcsey)mNIvC};& z?0^HWN{Lpf<-3%8k7-3825iEVcTO)M60YBu!GnX%Y%53W^xmH8$3E|H*nL;l5%RFT z_2!_GeMJszpy+k_(!0psW8YT%zZWXHaqD|lT{CLXUQ(Y9yZ>2;D%aPfv?*!)=@kq! z)w0TSZQl&}kDou#VBZ|oxgPDN$-t(Rf1@_O)~4sv^h&`auceuCLDs<9*FK}h-YieC zZU$d{IEo6(dtM>AF0#Fv%bx183q&7{p3bgh!FRCmQ4HhfS%fL>;^|r04@Ac%i9g?z zXlub4l;e-GTH;g%*kda*8EhsZ4u({m7`aSWEHC+ZQhCyvZMkJ!)wM$Q;zh3(*~ALx z8&a&X1!!&9qOoyu`&d{W+JfiSb;49K`^%r6%T8ynt#*l81zmY|rnFqQtxBrv!{g$w zQt8rnaE7~gwLKC7rrkcek2UM(2;BV@kiuuyi8XewYn=?mAuN)87o2}6Uu!PMb}-|m z?&xb-UxmpBvNzOoPAh*_kv9L4sOTImJj8TUH@|Yo%C_xJ3XV= z4|&gA`z{yVJ6tjn^&nZONA%#;s&Jg4)m5XhnPI-(rEJA_SK{6_os9TS&4e$F)Nasp zV<=>hxBlr;8IS9JFL^OoFz-~>uu+v}^yc+RtOO9(lOKhTSyEV`DF}}ZH*LXUZQ^r} zW93^<#-=KIUhb)v8AVo(m8`@~-=gV?9QOIBY7@(Ush{8QhL!EbiNjzH-J{jOY(su= z0pPU=56{ZWSw4iaXL-*vNlASP`4$GVRpTkwL)W-9=D*->2gAP&V@ZHSi#f|UgO1fLmPFr3Yb#<{Z-g%IKjq^rh#xn#DDi`R83V)yG zWUJ~a4Df0&3`;PctroqPWl^b+`T4B%k@&SQx00IX`u(Gau%*j#>YoR@d{jB2~Pcb9>y{j;@*f=_EUw;pM|p&weGtw@V+{*W@M zCopr1PQm>NZXv`pHv4wSi_Gi8L2UL8=FneenWe9HPtM%(9{l+(P>t>ZpBGm5G}Xbe zAaU{KE?_gSOv0qsee{Q?&#lhQPNiAT9} zo-)){eR50S?k%2(i&eM#pJmR|$zH1%7UDP`Ax{5e5$}K#_#xj0V~MzHYaWZOXb3-Q zJo8BgrEZxqw|j)STSyHzSFd~3mKxouCO?s)Oa!*E$(eeG4>MuTIGj7&=v(gGDHmps zj9Z%iz6o8E3;MCWQv=Q+<#IDJMBr}ed`!ukgI#SYHufzNPO(#g78;{36R2ycYidVn zjMASG2CR@+1cCZSPpoh~zMJzJK9%z!3j=XT{-ll`jP>vDePPh2!ApHEF$xL;yf?Q1 zjVwI?mxU8wynM<%Rn1By?k?@=nS0!@zwYXC75Y^i|3Uf~-ZhF5f9{jtON)gY zhPu_Q;_N;68w!i#z&}eBPzSUC9l!uM57+>f(6R>30T%#Uzz(&H4AwJ!uc+1B4(uVaNjyBuV|}UHyOeh9=#P zh+slAyn+yosIfiZPyA=a6EV-hEi_z$c8UanoIep#$lsy^B$6AXg8mY44C@jRwwO5l zb$eg2h=|Dq3n7MOi3tC6`q*K|j-7q31bn`RP$4;~Byz@pG#I!9X!r}kLJCy{rvE}{ zl8NH$h%;=XL<$07od^*2oh=a0JOv{#a11!Jju509Bcncnx*G^XQk-g{bP71P7l&aJ zAxv^y4f<>%j7ee*$gzbmB^kXTn&g6RhasacTL?5ss~$YRjj$kz4dBi;;w(vQ1QT`; zmL#zWRN6&Yk;G!^5{JH-qo55Ks8WTFo#rc)rP&AC@C`@d- zAr3UqhKtaxk{F^;4ybkn3gSRbBglsMIyO2qIyL$0H;*X>ogtoThj<8Nh0}EN9`_%o7*kCZ_*s6tDlMH0)wYFZj~l2`)rplFmyJ_?AKT=0lAbjUY| zh+qAKZQU!sNas8^xDZ8CQHVm;-XIx3W0V6j%0Oc@ykAp1_|^|eK1Q7We~5Sdk#eN? zMc`L|q&&%82@&%YT$X`q2o69plHkfcnC}>LGAZsOSx9izUU1z#*zZ1)ljO4s9J-Ix zWB#Ax13CmE4gTBM>;@vq$3`z5BcU4dwxod~DhuAWcc(-hkJFk>(_^9%Ouo zBtNf(50Sr5Vk3Cw5t4jdzCS`*kqT-iS`>g+Wuf?WAxKG*%VtnH^bf7mX<)r9^pKdt zkcY{QbHE>aa2*7b87n^iBcIQY|HvmM{EvK2M<9RCYZDRk66}(LW8k|)vB4Bw@Vy>f zn0{n_ztzoq4cPe?|Fg^++|YxI(9V%)&@c*lltK*jk3x!*CA1lZR0XZ{;Rk7#eygTO zBXz)VeK;Lm1_>l$O2Oje|C+12c%&Fvv6wt?45C2M7^DQ5@3a`C8A)seHDk#-)c{V% zB27p>pn=H-qvRnUnQ=(+fg!{p$p_{Rh$J7FQ4pz2ickQGJwYmv#N1$)C&&tyC$!>T zBrF~*q(hO@f4L(oqVE zps~S&2qoy;6f>ZhNRcZ+0s~5oq*X=a1VOt!tz1SFCmD>10>hOcpVCYy{(pPxJO-=x z_&O%kf4_Cam{EqLc-3I-pQ0HH%9s?VhG>KbU6rBm0<5UtorG>3JeafxH?X3}ZyiN8 z6!{npvZ2Vw$cr6CeoieCLBkV}4-F0!`P{tbK>aaYAeg(S6@BmzEqYEA`IYqw5=mJ% z6HVhmX%(pYXfCq!8bAi_Kk}*NMv;%TE6*PyKQC%;4h(rwq0HCpo0aaC@a}SLjn9qk2CrbyD3|@yQ(3l^^4IVdz)6sq-*?}GWC@jZ;-0b8# zrYCjJ+TRSpMf@hc1fV1ifms45^7~0p5cT^yg(4?`0&0-^O+hlPM$kgD6+w-cp{2QPsRBI|Ezcbs2(H$U&!PpNM0qATOj!tncN1+TT1|pH(DD_ zy^j|Gz@8rToe*4Zov_nmLbcD706-(eY%c;pB!NJ)87V?RMR!1mpQKO;CbgmtO6}`c z0YDw3zxfM5z_?`2`h?_8ZWG|!P3K*cGx$kLpm-aK7dHw}P*AKx|K7NdF95*cAc1BV z0MJhx=2mvU5vVp6Uh$2QseA%};Xwjy0|30YX9-lhKfFoS)c`OuNT6B-P%AS8iV?ki zuMploNTB=<7*7!>f8pT!IIac&iXdVZsG1;94jGahy#avd5I$o&J5Hc%Q6u5?0FXaO zpjapvB~bo!COy0c0La~gTSf?!%hyTn8v!71&%4&IPXzeb41ur`z_cH_2>=QPaRkaa zimXor#Mj_ApSE_&*!J0G04RhCSp!b*K&G1``FAR3K{B!gdH`Dh?EUUI0k+#~C!tHm z)k7Tg{2aDE5vX^Dwg^ddWMt(4feHWwMj*RXDHGf^S~4zofB=1G2+P?0M4;Gp-6x>h ziU$Y;fI{=Wl_nm;;{n?Rm$3&W+i{uzOMUed# zKm`VOp_q~ThSgwq7mAA{mV<&HPzP1_S!fK)1_&Df?WvjNwV$)g)8^ci`& bTJ1Bgrpzp)6POEtdT6*vMePSv0`q?Xv*S}Y diff --git a/data/dungeonboss-artwork.json b/data/dungeonboss-artwork.json new file mode 100644 index 0000000..fb90a8d --- /dev/null +++ b/data/dungeonboss-artwork.json @@ -0,0 +1,51 @@ +{ + "_readme": [ + "Drop-in art for Dungeon Boss. All cards render fully procedurally (frame,", + "border, name, text, treasure icons) until a sheet path is filled in below;", + "supplied art fills only the card's art window inside the procedural frame,", + "so illustrations need no borders or text of their own.", + "To add art for a category:", + " 1. Drop the sheet PNG into /assets/images/ (row-major frame order).", + " 2. Set that sheet's path below (frameWidth/frameHeight already match the", + " art-window aspect the renderer uses).", + " 3. Map each card id to its 0-based frame index in the matching map below.", + "Sheets: rooms are landscape windows; heroes/spells portrait; bosses larger", + "portrait. Ids not present in a map (or maps left empty) stay procedural." + ], + "roomSheet": { "key": "dungeonboss-rooms", "path": "", "frameWidth": 300, "frameHeight": 200 }, + "heroSheet": { "key": "dungeonboss-heroes", "path": "", "frameWidth": 220, "frameHeight": 300 }, + "spellSheet": { "key": "dungeonboss-spells", "path": "", "frameWidth": 220, "frameHeight": 300 }, + "bossSheet": { "key": "dungeonboss-bosses", "path": "", "frameWidth": 260, "frameHeight": 360 }, + + "rooms": { + "darkaltar": 0, "opengrave": 1, "specterssanctum": 2, "succubusspa": 3, + "dracolichlair": 4, "vampirebordello": 5, "goblinarmory": 6, "golemfactory": 7, + "minotaursmaze": 8, "neanderthalcave": 9, "beastmenagerie": 10, "monstersballroom": 11, + "brainsuckerhive": 12, "darklaboratory": 13, "hauntedlibrary": 14, "witchskitchen": 15, + "allseeingeye": 16, "ligersden": 17, "bottomlesspit": 18, "boulderramp": 19, + "dizzygashallway": 20, "jackpotstash": 21, "recyclingcenter": 22, "thecrushinator": 23, + "centipedetunnel": 24, "constructionzone": 25, "dragonhatchery": 26, "mimicvault": 27, + "monstrousmonument": 28, "torturechamber": 29, "zombieprison": 30 + }, + "spells": { + "annihilator": 0, "assassin": 1, "cavein": 2, "counterspell": 3, "exhaustion": 4, + "fear": 5, "freeze": 6, "giantsize": 7, "jeopardy": 8, "koboldstrike": 9, + "motivation": 10, "princessinperil": 11, "soulharvest": 12, "teleportation": 13, + "trepidation": 14, "zombieattack": 15 + }, + "heroes": { + "nick": 0, "pugi": 1, "acacia": 2, "charles": 3, "delatorious": 4, "romero": 5, + "boden": 6, "jarek": 7, "firesbreath": 8, "samuraitom": 9, "johnny": 10, "crystolalan": 11, + "tieg": 12, "brandork": 13, "mitchell": 14, "kalish": 15, "dartteon": 16, "koey": 17, + "joman": 18, "lance": 19, "jesta": 20, "kins": 21, "digby": 22, "jerome": 23, + "fool": 24, + "katelyn": 25, "kerberos": 26, "vanette": 27, "jejune": 28, + "frankov": 29, "nate": 30, "antonius": 31, "asmor": 32, + "tempros": 33, "chiakang": 34, "terric": 35, "wayward": 36, + "hya": 37, "blackbeard": 38, "cecil": 39, "wallbanger": 40 + }, + "bosses": { + "draculord": 0, "cleopatra": 1, "kingcroak": 2, "xyzax": 3, + "cerebellus": 4, "seducia": 5, "gorgona": 6, "robobo": 7 + } +} diff --git a/data/swdbg-artwork.json b/data/swdbg-artwork.json new file mode 100644 index 0000000..deea3cc --- /dev/null +++ b/data/swdbg-artwork.json @@ -0,0 +1,42 @@ +{ + "_readme": [ + "Drop-in art for Star Wars Deckbuilder. All cards render fully procedurally", + "(faction frame, cost badge, stats, name, rules text) until a sheet path is", + "filled in below; supplied art fills only the card's art window inside the", + "procedural frame, so illustrations need no borders or text of their own.", + "To add art:", + " 1. Drop the sheet PNG into /assets/images/ (row-major frame order).", + " 2. Set that sheet's path below (frameWidth/frameHeight already match the", + " art-window aspect the renderer uses: cards landscape, bases portrait).", + " 3. Map each card id to its 0-based frame index in the matching map below.", + "Ids not present in a map (or maps left empty) stay procedural." + ], + "cardSheet": { "key": "swdbg-cards-art", "path": "", "frameWidth": 300, "frameHeight": 230 }, + "baseSheet": { "key": "swdbg-bases-art", "path": "", "frameWidth": 220, "frameHeight": 300 }, + + "cards": { + "allianceshuttle": 0, "rebeltrooper": 1, "templeguardian": 2, + "imperialshuttle": 3, "stormtrooper": 4, "inquisitor": 5, + "outerrimpilot": 6, + "tiefighter": 7, "scouttrooper": 8, "deathtrooper": 9, "tiebomber": 10, + "tieinterceptor": 11, "atst": 12, "atat": 13, "generalveers": 14, + "grandmofftarkin": 15, "directorkrennic": 16, "darthvader": 17, + "emperorpalpatine": 18, "gozanticruiser": 19, "imperialcarrier": 20, + "stardestroyer": 21, + "rebelpathfinder": 22, "ywing": 23, "snowspeeder": 24, "durosspy": 25, + "xwing": 26, "rebelcommando": 27, "cassianandor": 28, "k2so": 29, + "chirrutimwe": 30, "jynerso": 31, "chewbacca": 32, "hansolo": 33, + "princessleia": 34, "lukeskywalker": 35, "blockaderunner": 36, + "hammerheadcorvette": 37, "medicalfrigate": 38, "moncalamaricruiser": 39, + "millenniumfalcon": 40, + "z95headhunter": 41, "jawascavenger": 42, "keldormystic": 43, + "rodiangunslinger": 44, "hwk290": 45, "twileksmuggler": 46, + "fangfighter": 47, "quarrenmercenary": 48, "bossk": 49, "ig88": 50, + "bobafett": 51, "jabbathehutt": 52, "crocruiser": 53, "khetanna": 54 + }, + + "bases": { + "dantooine": 0, "hoth": 1, "sullust": 2, "moncala": 3, "yavin4": 4, + "lothal": 5, "corellia": 6, "coruscant": 7, "deathstar": 8, "endor": 9 + } +} diff --git a/data/swdbg-cards.json b/data/swdbg-cards.json new file mode 100644 index 0000000..adee222 --- /dev/null +++ b/data/swdbg-cards.json @@ -0,0 +1,295 @@ +{ + "_readme": [ + "Star Wars: The Deckbuilding Game — full card data. This file is the single", + "source of truth for every card: edit stats/text here and the game engine", + "picks the changes up on the next page load (no rebuild needed).", + "", + "RULES DATA CAVEAT: the rules engine follows the official rulebook exactly,", + "but individual card stats/abilities below were reconstructed from memory and", + "public previews — verify them against the physical cards and edit freely.", + "", + "CARD FIELDS:", + " id unique lowercase key (do not change once art is mapped)", + " name display name", + " faction 'empire' | 'rebel' | 'neutral'", + " type 'unit' | 'capital' (capital ships stay in play, have hp)", + " unique true = 1-of, may be referenced by other cards", + " traits flavor tags referenced by abilities (Fighter, Trooper, ...)", + " cost purchase cost in resources", + " attack / resources / force printed values gained when played", + " hp capital ships only — damage needed to destroy it in play", + " count copies in the 90-card galaxy deck", + " target attack needed to bounty-hunt/sabotage it in the row (faction", + " cards only; neutral cards and capital ships use null)", + " reward what the defeater gains: {resources, force, draw} or null", + " ability machine-readable effect (see OPS below) or null", + " text display-only rules text — keep in sync with `ability`", + "", + "ABILITY SHAPE: { timing, op, ...params, cost?, requiresForce? }", + " timing 'action' = use once per turn while in play (the default)", + " 'constant' = always on while in play ('While ...')", + " 'onPlay' = triggers when the card is played", + " 'onBounty' = triggers when you defeat a galaxy-row card", + " 'onReveal' = bases only: when the base is revealed", + " cost { exileSelf: true } and/or { exileFromHand: 1 } paid before the op", + " requiresForce: true = usable only while the Force is with you", + " nForce / amountForce = upgraded number while the Force is with you", + "", + "SUPPORTED OPS (the engine understands exactly these):", + " draw{n,nForce} draw cards", + " gainResources{n,nForce} add resources to your pool this turn", + " gainForce{n} move the Force marker toward you", + " gainAttack{n,nForce} this card gains attack this turn", + " damageBase{n,nForce} damage enemy base directly (ignores capitals)", + " repairBase{n} remove damage from your base", + " oppDiscard{n,randomForce} opponent discards (random while Force is w/ you)", + " discardRow{} discard a card from the galaxy row (no reward)", + " destroyCapital{} destroy a capital ship (in play or in the row)", + " exileCards{n,optional} exile up to n cards from your hand/discard", + " freePurchase{faction,maxCost,forceTopdeck} take a row card for free", + " recoverDiscard{filter} return a card from your discard to your hand", + " lookTop{mayDiscard} look at top of galaxy deck, optionally discard", + " revealTop{} reveal top of galaxy deck; if it matches your", + " faction gain 1 resource, if enemy you may", + " discard it, if neutral nothing happens", + " spyChoice{} opponent chooses: discard 1 OR you gain 1 Force", + " choose{options:[a,b]} pick one of two sub-abilities", + " traitBoost{trait,amount} constant: your units with trait get +attack", + " rowBonus{amount} constant: +attack when attacking the galaxy row", + " selfBoostWhileForce{amount} constant: +attack while the Force is with you", + " selfBoostWithTrait{trait,amount} constant: +attack while you have another", + " card with the trait in play", + " capitalDiscount{n} constant (bases): your capital ships cost less", + " onPlayTraitAttack{trait,amount} constant (bases): played units with the", + " trait gain +attack", + "", + "BASES: hp, starting (true = begin the game with it), ability (onReveal /", + "constant / action ops above), text. Beginner game uses these five per side;", + "add more entries to play Full Galaxy mode." + ], + + "meta": { + "basesToWin": 3, + "handSize": 5, + "galaxyRowSize": 6, + "forceSpaces": 3, + "forceStartsWith": "rebel" + }, + + "starters": { + "rebel": [ + { "id": "allianceshuttle", "count": 7 }, + { "id": "rebeltrooper", "count": 2 }, + { "id": "templeguardian", "count": 1 } + ], + "empire": [ + { "id": "imperialshuttle", "count": 7 }, + { "id": "stormtrooper", "count": 2 }, + { "id": "inquisitor", "count": 1 } + ] + }, + + "starterCards": [ + { "id": "allianceshuttle", "name": "Alliance Shuttle", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Transport"], "cost": 0, "attack": 0, "resources": 1, "force": 0, "target": null, "reward": null, "ability": null, + "text": "The workhorse of the Rebellion's supply lines." }, + { "id": "rebeltrooper", "name": "Rebel Trooper", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 0, "attack": 2, "resources": 0, "force": 0, "target": null, "reward": null, "ability": null, + "text": "Rebels don't wait for the odds to look good." }, + { "id": "templeguardian", "name": "Temple Guardian", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Guardian"], "cost": 0, "attack": 1, "resources": 0, "force": 1, "target": null, "reward": null, "ability": null, + "text": "The Force is with me, and I am one with the Force." }, + { "id": "imperialshuttle", "name": "Imperial Shuttle", "faction": "empire", "type": "unit", "unique": false, "traits": ["Transport"], "cost": 0, "attack": 0, "resources": 1, "force": 0, "target": null, "reward": null, "ability": null, + "text": "Cleared for landing on platform 327." }, + { "id": "stormtrooper", "name": "Stormtrooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 0, "attack": 2, "resources": 0, "force": 0, "target": null, "reward": null, "ability": null, + "text": "Precision aim is a myth. Overwhelming numbers are not." }, + { "id": "inquisitor", "name": "Inquisitor", "faction": "empire", "type": "unit", "unique": false, "traits": ["Sith"], "cost": 0, "attack": 1, "resources": 0, "force": 1, "target": null, "reward": null, "ability": null, + "text": "There are things far worse than death." } + ], + + "outerRimPilot": { + "id": "outerrimpilot", "name": "Outer Rim Pilot", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Pilot"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 10, "target": null, "reward": null, + "ability": { "timing": "action", "op": "exileCards", "n": 1, "optional": true, "cost": { "exileSelf": true } }, + "text": "Exile Outer Rim Pilot from play: exile a card from your hand or discard pile." + }, + + "cards": [ + { "id": "tiefighter", "name": "TIE Fighter", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 1, "resources": 0, "force": 0, "count": 5, "target": 2, "reward": { "resources": 1 }, + "ability": { "timing": "constant", "op": "selfBoostWithTrait", "trait": "Fighter", "amount": 1 }, + "text": "TIE Fighter gains 1 attack while you have another Fighter in play." }, + { "id": "scouttrooper", "name": "Scout Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1 }, + "ability": { "timing": "action", "op": "revealTop" }, + "text": "Reveal the top card of the galaxy deck. If it is an Empire card, gain 1 resource. If it is a Rebel card, you may discard it." }, + { "id": "deathtrooper", "name": "Death Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 3, "target": 4, "reward": { "resources": 2 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "tiebomber", "name": "TIE Bomber", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 3, "attack": 2, "resources": 0, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1, "force": 1 }, + "ability": { "timing": "action", "op": "damageBase", "n": 1, "nForce": 2 }, + "text": "Deal 1 damage to the enemy base (2 damage instead while the Force is with you)." }, + { "id": "tieinterceptor", "name": "TIE Interceptor", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 4, "attack": 3, "resources": 0, "force": 0, "count": 2, "target": 4, "reward": { "resources": 2 }, + "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 1 }, + "text": "TIE Interceptor gains 1 attack while the Force is with you." }, + { "id": "atst", "name": "AT-ST", "faction": "empire", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 2, "target": 5, "reward": { "resources": 2, "force": 1 }, + "ability": null, + "text": "The chicken walker: ridiculous nickname, unfunny results." }, + { "id": "atat", "name": "AT-AT", "faction": "empire", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 6, "attack": 6, "resources": 0, "force": 0, "count": 1, "target": 7, "reward": { "resources": 3, "force": 1 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "generalveers", "name": "General Veers", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 4, "attack": 2, "resources": 1, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, + "ability": { "timing": "constant", "op": "traitBoost", "trait": "Vehicle", "amount": 2 }, + "text": "Your Vehicles gain 2 attack while General Veers is in play." }, + { "id": "grandmofftarkin", "name": "Grand Moff Tarkin", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 6, "attack": 2, "resources": 2, "force": 2, "count": 1, "target": 7, "reward": { "resources": 3, "force": 1 }, + "ability": { "timing": "action", "op": "discardRow" }, + "text": "You may discard a card from the galaxy row." }, + { "id": "directorkrennic", "name": "Director Krennic", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 5, "attack": 3, "resources": 2, "force": 0, "count": 1, "target": 5, "reward": { "resources": 3, "force": 2 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "nBase": { "base": "deathstar", "n": 2 } }, + "text": "Draw 1 card (2 cards instead if your base is the Death Star)." }, + { "id": "darthvader", "name": "Darth Vader", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "damageBase", "n": 2, "requiresForce": true }, + "text": "If the Force is with you, deal 2 damage to the enemy base." }, + { "id": "emperorpalpatine", "name": "Emperor Palpatine", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 3, "resources": 0, "force": 3, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "choose", "options": [ { "op": "draw", "n": 2 }, { "op": "oppDiscard", "n": 1, "randomForce": true } ] }, + "text": "Choose one: draw 2 cards, or your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "gozanticruiser", "name": "Gozanti Cruiser", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 3, "attack": 0, "resources": 1, "force": 0, "hp": 3, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "A slow freighter with just enough guns to matter." }, + { "id": "imperialcarrier", "name": "Imperial Carrier", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 5, "attack": 0, "resources": 1, "force": 0, "hp": 5, "count": 2, "target": null, "reward": null, + "ability": { "timing": "constant", "op": "traitBoost", "trait": "Fighter", "amount": 1 }, + "text": "Your Fighters gain 1 attack while Imperial Carrier is in play." }, + { "id": "stardestroyer", "name": "Star Destroyer", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 7, "attack": 3, "resources": 1, "force": 0, "hp": 7, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "The Imperial Navy's argument, delivered at length." }, + + { "id": "rebelpathfinder", "name": "Rebel Pathfinder", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 1, "attack": 1, "resources": 1, "force": 0, "count": 3, "target": 2, "reward": { "resources": 1 }, + "ability": null, + "text": "First in, last out, rarely thanked." }, + { "id": "ywing", "name": "Y-Wing", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 3, "target": 2, "reward": { "resources": 1 }, + "ability": { "timing": "action", "op": "damageBase", "n": 1 }, + "text": "Deal 1 damage to the enemy base." }, + { "id": "snowspeeder", "name": "Snowspeeder", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 2, "target": 3, "reward": { "resources": 1 }, + "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 1 }, + "text": "Snowspeeder gains 1 attack while the Force is with you." }, + { "id": "durosspy", "name": "Duros Spy", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Spy"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 2, "target": 2, "reward": { "force": 1 }, + "ability": { "timing": "action", "op": "spyChoice" }, + "text": "Your opponent chooses one: they discard a card from their hand, or you gain 1 Force." }, + { "id": "xwing", "name": "X-Wing", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1, "force": 1 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresForce": true }, + "text": "If the Force is with you, draw a card." }, + { "id": "rebelcommando", "name": "Rebel Commando", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 3, "target": 3, "reward": { "resources": 2 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "cassianandor", "name": "Cassian Andor", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper", "Spy"], "cost": 4, "attack": 3, "resources": 1, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, + "ability": { "timing": "action", "op": "lookTop", "mayDiscard": true }, + "text": "Look at the top card of the galaxy deck. You may discard it." }, + { "id": "k2so", "name": "K-2SO", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Droid"], "cost": 4, "attack": 3, "resources": 0, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "chirrutimwe", "name": "Chirrut Îmwe", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Guardian"], "cost": 3, "attack": 2, "resources": 0, "force": 2, "count": 1, "target": 4, "reward": { "force": 2 }, + "ability": { "timing": "action", "op": "gainForce", "n": 1 }, + "text": "Gain 1 Force." }, + { "id": "jynerso", "name": "Jyn Erso", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper"], "cost": 5, "attack": 4, "resources": 0, "force": 0, "count": 1, "target": 6, "reward": { "resources": 2, "force": 2 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresForce": true }, + "text": "If the Force is with you, draw a card." }, + { "id": "chewbacca", "name": "Chewbacca", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 5, "attack": 4, "resources": 1, "force": 0, "count": 1, "target": 6, "reward": { "resources": 2, "force": 2 }, + "ability": { "timing": "action", "op": "recoverDiscard", "filter": "unique" }, + "text": "Return a unique card from your discard pile to your hand." }, + { "id": "hansolo", "name": "Han Solo", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 7, "attack": 3, "resources": 2, "force": 0, "count": 1, "target": 8, "reward": { "resources": 3, "force": 2 }, + "ability": { "timing": "action", "op": "draw", "n": 1 }, + "text": "Draw a card." }, + { "id": "princessleia", "name": "Princess Leia", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 6, "attack": 2, "resources": 2, "force": 2, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "freePurchase", "faction": "rebel", "forceTopdeck": true }, + "text": "Purchase a Rebel card from the galaxy row for free. If the Force is with you, place that card on top of your deck." }, + { "id": "lukeskywalker", "name": "Luke Skywalker", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Jedi"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 8, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "gainAttack", "n": 2, "requiresForce": true }, + "text": "If the Force is with you, Luke Skywalker gains 2 attack." }, + { "id": "blockaderunner", "name": "Blockade Runner", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 4, "attack": 1, "resources": 1, "force": 0, "hp": 4, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "Though designed as a diplomatic courier, the Corellian CR90 corvette is often used to run planetary blockades." }, + { "id": "hammerheadcorvette", "name": "Hammerhead Corvette", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 5, "attack": 1, "resources": 1, "force": 0, "hp": 4, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "destroyCapital", "cost": { "exileSelf": true } }, + "text": "Exile Hammerhead Corvette from play: destroy a capital ship." }, + { "id": "medicalfrigate", "name": "Medical Frigate", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 6, "attack": 0, "resources": 1, "force": 0, "hp": 5, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "repairBase", "n": 1 }, + "text": "Repair 1 damage from your base." }, + { "id": "moncalamaricruiser", "name": "Mon Calamari Cruiser", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 8, "attack": 3, "resources": 1, "force": 0, "hp": 8, "count": 1, "target": null, "reward": null, + "ability": null, + "text": "The backbone of the Rebel fleet, crewed by the galaxy's finest shipwrights." }, + { "id": "millenniumfalcon", "name": "Millennium Falcon", "faction": "rebel", "type": "capital", "unique": true, "traits": ["Capital Ship", "Freighter"], "cost": 7, "attack": 2, "resources": 1, "force": 0, "hp": 6, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "recoverDiscard", "filter": "unique" }, + "text": "Return a unique card from your discard pile to your hand." }, + + { "id": "z95headhunter", "name": "Z-95 Headhunter", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 1, "resources": 1, "force": 0, "count": 4, "target": null, "reward": null, + "ability": null, + "text": "Obsolete everywhere except where it counts." }, + { "id": "jawascavenger", "name": "Jawa Scavenger", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Scavenger"], "cost": 1, "attack": 0, "resources": 1, "force": 0, "count": 3, "target": null, "reward": null, + "ability": { "timing": "action", "op": "exileCards", "n": 1, "optional": true }, + "text": "Exile a card from your hand or discard pile. Utinni!" }, + { "id": "keldormystic", "name": "Kel Dor Mystic", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Mystic"], "cost": 2, "attack": 0, "resources": 0, "force": 2, "count": 3, "target": null, "reward": null, + "ability": { "timing": "action", "op": "exileCards", "n": 1, "optional": true }, + "text": "Exile a card from your hand or discard pile." }, + { "id": "rodiangunslinger", "name": "Rodian Gunslinger", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Bounty Hunter"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 3, "target": null, "reward": null, + "ability": { "timing": "constant", "op": "rowBonus", "amount": 2 }, + "text": "Rodian Gunslinger gains 2 attack while attacking a target in the galaxy row." }, + { "id": "hwk290", "name": "HWK-290", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Freighter"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "lookTop", "mayDiscard": true }, + "text": "Look at the top card of the galaxy deck. You may discard it." }, + { "id": "twileksmuggler", "name": "Twi'lek Smuggler", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Smuggler"], "cost": 3, "attack": 0, "resources": 3, "force": 0, "count": 3, "target": null, "reward": null, + "ability": null, + "text": "No manifest, no questions, no refunds." }, + { "id": "fangfighter", "name": "Fang Fighter", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "Mandalorian engineering does not forgive." }, + { "id": "quarrenmercenary", "name": "Quarren Mercenary", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Mercenary"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 3, "target": null, "reward": null, + "ability": null, + "text": "Loyal to the last credit." }, + { "id": "bossk", "name": "Bossk", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Bounty Hunter"], "cost": 4, "attack": 3, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "onBounty", "op": "gainResources", "n": 2 }, + "text": "When you defeat a card in the galaxy row, gain 2 resources." }, + { "id": "ig88", "name": "IG-88", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Droid", "Bounty Hunter"], "cost": 5, "attack": 4, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "constant", "op": "rowBonus", "amount": 2 }, + "text": "IG-88 gains 2 attack while attacking a target in the galaxy row." }, + { "id": "bobafett", "name": "Boba Fett", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Bounty Hunter"], "cost": 6, "attack": 5, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "onBounty", "op": "draw", "n": 1 }, + "text": "When you defeat a card in the galaxy row, draw a card." }, + { "id": "jabbathehutt", "name": "Jabba the Hutt", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 8, "attack": 2, "resources": 2, "force": 2, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "draw", "n": 1, "nForce": 2, "cost": { "exileFromHand": 1 } }, + "text": "Exile 1 card from your hand to draw 1 card (2 cards instead if the Force is with you)." }, + { "id": "crocruiser", "name": "C-ROC Cruiser", "faction": "neutral", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 4, "attack": 0, "resources": 2, "force": 0, "hp": 4, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "A smuggler's idea of a warship." }, + { "id": "khetanna", "name": "Khetanna", "faction": "neutral", "type": "capital", "unique": true, "traits": ["Capital Ship", "Barge"], "cost": 6, "attack": 2, "resources": 2, "force": 0, "hp": 5, "count": 1, "target": null, "reward": null, + "ability": null, + "text": "Jabba's sail barge: pleasure craft, prison, and gallows in one." } + ], + + "bases": { + "rebel": [ + { "id": "dantooine", "name": "Dantooine", "hp": 8, "starting": true, "ability": null, + "text": "Rebel Outpost. Begin the game with this base in play." }, + { "id": "hoth", "name": "Hoth", "hp": 10, "starting": false, + "ability": { "timing": "onReveal", "op": "draw", "n": 2 }, + "text": "Echo Station. When you reveal this base, draw 2 cards." }, + { "id": "sullust", "name": "Sullust", "hp": 12, "starting": false, + "ability": { "timing": "onReveal", "op": "exileCards", "n": 2, "optional": true }, + "text": "Staging Point. When you reveal this base, exile up to 2 cards from your hand or discard pile." }, + { "id": "moncala", "name": "Mon Cala", "hp": 12, "starting": false, + "ability": { "timing": "constant", "op": "capitalDiscount", "n": 1 }, + "text": "Shipyards. Your capital ships cost 1 less resource." }, + { "id": "yavin4", "name": "Yavin IV", "hp": 14, "starting": false, + "ability": { "timing": "constant", "op": "onPlayTraitAttack", "trait": "Fighter", "amount": 1 }, + "text": "Great Temple. When you play a Fighter, it gains 1 attack." } + ], + "empire": [ + { "id": "lothal", "name": "Lothal", "hp": 8, "starting": true, "ability": null, + "text": "Imperial Outpost. Begin the game with this base in play." }, + { "id": "corellia", "name": "Corellia", "hp": 10, "starting": false, + "ability": { "timing": "constant", "op": "capitalDiscount", "n": 1 }, + "text": "Shipyards. Your capital ships cost 1 less resource." }, + { "id": "coruscant", "name": "Coruscant", "hp": 12, "starting": false, + "ability": { "timing": "onReveal", "op": "gainResources", "n": 3 }, + "text": "Imperial Center. When you reveal this base, gain 3 resources." }, + { "id": "deathstar", "name": "Death Star", "hp": 14, "starting": false, + "ability": { "timing": "action", "op": "damageBase", "n": 1 }, + "text": "Battle Station. Once per turn: deal 1 damage to the enemy base." }, + { "id": "endor", "name": "Endor", "hp": 16, "starting": false, "ability": null, + "text": "Shield Generator. The toughest Imperial position in the galaxy." } + ] + } +} diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 568a186..e2b31a4 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -103,3 +103,5 @@ registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGa registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 }); registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 }); registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 }); +registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77 }); +registerGame({ slug: 'swdbg', name: 'Star Wars Deckbuilder', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78 }); diff --git a/src/games/dungeonboss/DungeonBossAI.js b/src/games/dungeonboss/DungeonBossAI.js new file mode 100644 index 0000000..62639f6 --- /dev/null +++ b/src/games/dungeonboss/DungeonBossAI.js @@ -0,0 +1,385 @@ +// Dungeon Boss AI — synchronous heuristics over the engine's public view +// (publicView in DungeonBossLogic redacts rival hands and pending builds; the +// AI never sees hidden information). One handler per decision kind, routed +// through decide(). Skill 1–5 follows the AzulAI shape: widening top-N with +// score noise and a blunder chance at low skill. + +import { + BOSSES, ROOMS, SPELLS, HEROES, CLASSES, + heroSouls, roomDef, spellDef, heroDef, +} from './DungeonBossData.js'; +import { + legalBuilds, windowActions, treasureCount, dungeonDamage, baitTargets, +} from './DungeonBossLogic.js'; + +const SKILL_PROFILES = { + 1: { topN: 5, blunder: 0.40, noise: 10, actChance: 0.25, delay: [700, 1200] }, + 2: { topN: 4, blunder: 0.22, noise: 7, actChance: 0.45, delay: [650, 1100] }, + 3: { topN: 3, blunder: 0.10, noise: 4, actChance: 0.70, delay: [600, 1000] }, + 4: { topN: 2, blunder: 0.03, noise: 2, actChance: 0.90, delay: [520, 900] }, + 5: { topN: 1, blunder: 0.00, noise: 0, actChance: 1.00, delay: [440, 820] }, +}; +function profileFor(skill) { + return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; +} +export function nextThinkDelay(skill) { + const [lo, hi] = profileFor(skill).delay; + return lo + Math.random() * (hi - lo); +} + +// rnd: a plain ()=>[0,1) function (seeded in verify, Math.random in the scene). +function pickScored(cands, skill, rnd) { + const prof = profileFor(skill); + if (!cands.length) return null; + if (rnd() < prof.blunder) return cands[Math.floor(rnd() * cands.length)].choice; + const jittered = cands.map((c) => ({ choice: c.choice, s: c.s + (rnd() * 2 - 1) * prof.noise })); + jittered.sort((a, b) => b.s - a.s); + const n = Math.min(prof.topN, jittered.length); + return jittered[Math.floor(rnd() * n)].choice; +} + +const me = (view, seat) => view.players[seat]; +const rivals = (view, seat) => view.players.filter((p) => p.alive && p.seat !== seat); +const soulLeader = (view, seat) => rivals(view, seat).sort((a, b) => b.souls - a.souls)[0] || null; +const canKill = (view, seat, hero) => dungeonDamage(view, seat) >= hero.hp; + +function roomValue(def) { + const icons = Object.values(def.treasure || {}).reduce((a, b) => a + b, 0); + return def.dmg * 2 + icons * 1.5 + (def.effects || []).length + (def.passive ? 1 : 0); +} +function handCardValue(inst) { + const r = ROOMS[inst.id]; + if (r) return roomValue(r) + (r.advanced ? 1 : 0); + return 5; // spells are versatile — keep them over weak rooms +} + +// ── Decision handlers ──────────────────────────────────────────────────────── + +function chooseSetupDiscard(view, seat) { + const p = me(view, seat); + const all = [...p.hand.rooms, ...p.hand.spells] + .map((c) => ({ uid: c.uid, v: handCardValue(c) + (ROOMS[c.id] && ROOMS[c.id].advanced ? -2 : 0) })) + .sort((a, b) => a.v - b.v); + // Never discard down to zero ordinary rooms — the setup build needs one. + const isOrdinary = (uid) => { + const c = p.hand.rooms.find((x) => x.uid === uid); + return c && !ROOMS[c.id].advanced; + }; + const picked = []; + for (const cand of all) { + if (picked.length === 2) break; + const remainingOrdinary = p.hand.rooms.filter((c) => !ROOMS[c.id].advanced + && !picked.includes(c.uid) && c.uid !== cand.uid).length; + if (isOrdinary(cand.uid) && remainingOrdinary === 0) continue; + picked.push(cand.uid); + } + // Fallback (hand was nearly all ordinary): take the two cheapest regardless. + while (picked.length < 2) { + const next = all.find((c) => !picked.includes(c.uid)); + picked.push(next.uid); + } + return picked; +} + +function scoreBuild(view, seat, b) { + const p = me(view, seat); + const def = roomDef(p.hand.rooms.find((c) => c.uid === b.roomUid)); + let s = 0; + const isNew = b.slotIdx >= p.dungeon.length; + // Value of the room itself, minus what a build-over buries. + s += def.dmg * 1.5 + Object.values(def.treasure || {}).reduce((a, c) => a + c, 0); + if (!isNew) { + const buried = roomDef(p.dungeon[b.slotIdx].room); + s -= roomValue(buried) * 0.6; + if (buried.dmg <= 1 && !buried.passive) s += 2; // upgrading a weak room is fine + } else { + s += 2; + if (p.dungeon.length === 4 && !p.boss.leveledUp) s += 6; // level-up on the 5th room + } + if (def.advanced) s += 2; + // Bait consequences: compare who I attract (and whether I can kill them) + // before and after this build — existing icons already pull heroes, so + // exposure to unkillable heroes matters as much as new pulls. + const curDmg = dungeonDamage(view, seat); + const projDmg = curDmg + def.dmg + (isNew ? 0 : -roomDef(p.dungeon[b.slotIdx].room).dmg); + for (const hero of view.town) { + const h = heroDef(hero); + if (h.fool) continue; + const curMine = treasureCount(view, seat, h.cls); + const mine = curMine + + (def.treasure?.[h.cls] || 0) + - (isNew ? 0 : ((roomDef(p.dungeon[b.slotIdx].room).treasure || {})[h.cls] || 0)); + const bestRival = Math.max(0, ...rivals(view, seat).map((r) => treasureCount(view, r.seat, h.cls))); + const wasMax = curMine > bestRival; + const nowMax = mine > bestRival; + const killCur = curDmg >= hero.hp; + const killNow = projDmg >= hero.hp; + if (!wasMax && nowMax) { + s += killNow ? 4 + heroSouls(h) * 3 : -(6 + (h.epic ? 6 : 0)); + } else if (wasMax && nowMax) { + if (!killCur && killNow) s += 8; // this build saves me a wound + else if (!killNow) s -= 2; // still doomed; prefer damage elsewhere + } else if (wasMax && !nowMax) { + s += killCur ? -4 : 5; // dodged an unkillable hero / lost a soul + } + } + return s; +} + +function chooseBuild(view, seat, skill, rnd, decision) { + const builds = legalBuilds(view, seat); + if (decision.setup) { + let best = null; let bestV = -1e9; + for (const b of builds) { + const def = roomDef(me(view, seat).hand.rooms.find((c) => c.uid === b.roomUid)); + if (def.advanced) continue; // advanced can't open a dungeon anyway + const v = roomValue(def); + if (v > bestV) { bestV = v; best = b; } + } + return best || builds[0] || null; + } + const cands = builds.map((b) => ({ choice: b, s: scoreBuild(view, seat, b) })); + cands.push({ choice: null, s: -1 }); // passing is rarely right + return pickScored(cands, skill, rnd); +} + +// One scored list across castable spells + activatable rooms + pass. +function chooseWindow(view, seat, skill, rnd, decision) { + const prof = profileFor(skill); + const acts = windowActions(view, seat); + if ((!acts.spells.length && !acts.rooms.length) || rnd() > prof.actChance) return { pass: true }; + const p = me(view, seat); + const isAdv = decision.window === 'advStart'; + const myTurn = isAdv && decision.advSeat === seat; + const leader = soulLeader(view, seat); + const cands = [{ choice: { pass: true }, s: 1 }]; + + const survivor = (who) => who.entrance.find((h) => h.hp > dungeonDamage(view, who.seat)); + const dying = (who) => who.entrance.find((h) => h.hp <= dungeonDamage(view, who.seat)); + + for (const sp of acts.spells) { + const def = spellDef({ id: sp.id }); + const op = def.op.op; + const push = (target, s) => cands.push({ choice: { spellUid: sp.uid, target }, s }); + if (op === 'dmgBoostRoom' && myTurn && survivor(p)) { + // Boost my biggest matching room if it flips a survivor to a kill. + const own = (sp.targets || []).filter((t) => t.seat === seat); + const best = own.sort((a, b) => roomDef(p.dungeon[b.slotIdx].room).dmg - roomDef(p.dungeon[a.slotIdx].room).dmg)[0]; + if (best && survivor(p).hp <= dungeonDamage(view, seat) + def.op.amount) push(best, 12); + else if (best) push(best, 3); + } else if (op === 'healHero' && isAdv && !myTurn && leader) { + const t = (sp.targets || []).find((t2) => { + const owner = view.players.find((q) => q.entrance.some((h) => h.uid === t2.uid)); + if (!owner || owner.seat === seat) return false; + const hero = owner.entrance.find((h) => h.uid === t2.uid); + return hero.hp <= dungeonDamage(view, owner.seat) && hero.hp + def.op.amount > dungeonDamage(view, owner.seat); + }); + if (t) push(t, 11); + } else if (op === 'damageHero' && myTurn) { + const sv = survivor(p); + if (sv && sv.hp - p.dungeon.length <= dungeonDamage(view, seat)) push({ kind: 'hero', uid: sv.uid }, 11); + } else if (op === 'teleportHero' && myTurn) { + const sv = survivor(p); + if (sv && sv.hp <= dungeonDamage(view, seat) * 2) push({ kind: 'hero', uid: sv.uid }, 10); + } else if (op === 'fearHero' && myTurn) { + const sv = survivor(p); + if (sv) push({ kind: 'hero', uid: sv.uid }, 9); // shoo away a hero I can't kill + } else if (op === 'deactivateRoom' && !isAdv && leader) { + const t = (sp.targets || []).filter((t2) => t2.seat === leader.seat) + .sort((a, b) => roomDef(leader.dungeon[b.slotIdx].room).dmg - roomDef(leader.dungeon[a.slotIdx].room).dmg)[0]; + if (t) push(t, 6); + } else if (op === 'lureHero') { + const t = (sp.targets || []).find((t2) => { + const hero = view.town.find((h) => h.uid === t2.uid); + return hero && canKill(view, seat, hero); + }); + if (t) push(t, 8 + (heroDef(view.town.find((h) => h.uid === t.uid)).epic ? 4 : 0)); + } else if (op === 'resurrectAttack' && leader) { + const t = (sp.targets || []).find((t2) => { + if (t2.seat !== leader.seat) return false; + const hero = leader.soulCards.find((h) => h.uid === t2.uid); + return hero && hero.hpMax + (def.op.hpBonus || 0) > dungeonDamage(view, leader.seat); + }); + if (t) push(t, 9); + } else if (op === 'blockHeroes') { + const t = (sp.targets || [])[0]; + if (t) push(t, 7); + } else if (op === 'caveIn' && myTurn) { + const sv = survivor(p); + if (sv) { + const t = (sp.targets || []).sort((a, b) => + roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; + if (t) push(t, 8); + } + } else if (op === 'sacrificeSoulDraw') { + if (p.hand.spells.length <= 1 && p.souls > 2 && p.souls < 9) push((sp.targets || [])[0], 3); + } else if (op === 'handReset') { + if (p.hand.rooms.length + p.hand.spells.length <= 2) push(null, 4); + } else if (op === 'noBuildRound') { + const rivalNearLevel = rivals(view, seat).some((r) => r.dungeon.length === 4 && !r.boss.leveledUp); + if (rivalNearLevel) push(null, 5); + } else if (op === 'extraBuild') { + if (p.hand.rooms.length) push(null, 6); + } else if (op === 'dmgBoostAllRooms' && myTurn && survivor(p)) { + const t = (sp.targets || [])[0]; + if (t) push(t, 6); + } + } + for (const ra of acts.rooms) { + const def = roomDef(p.dungeon[ra.slotIdx].room); + const eff = (def.effects || [])[ra.effIdx]; + const push = (target, s, extra = {}) => cands.push({ choice: { slotIdx: ra.slotIdx, effIdx: ra.effIdx, target, ...extra }, s }); + if (eff.op === 'pitTrap' && myTurn) { + const sv = survivor(p); + if (sv) push(null, 12); // guaranteed kill beats keeping a 1-damage trap + } else if (eff.op === 'rampTrap' && myTurn) { + const sv = survivor(p); + if (sv && sv.hp <= dungeonDamage(view, seat) + 5) { + const t = (ra.targets || []).sort((a, b) => + roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; + if (t) push(t, 10); + } + } else if (eff.op === 'dmgBoostAllRooms' && myTurn && survivor(p)) { + const t = (ra.targets || []).sort((a, b) => + roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; + if (t) push(t, 9); + } else if (eff.op === 'doubleTreasure' && !isAdv) { + const killable = view.town.filter((h) => !heroDef(h).fool && canKill(view, seat, h)); + const bt = baitTargets(view); + if (killable.some((h) => bt[h.uid] !== seat)) push(null, 7); + } else if (eff.op === 'drawSpell' && eff.cost && eff.cost.discardMonsterRoom) { + if (p.hand.rooms.filter((c) => roomDef(c).type === 'monster').length > 2) push(null, 2); + } else if (eff.op === 'recoverDiscard') { + const bestRoom = (ra.targets || []).map((t) => view.roomDiscard.find((c) => c.uid === t.uid)) + .filter(Boolean).sort((a, b) => roomValue(roomDef(b)) - roomValue(roomDef(a)))[0]; + if (bestRoom && roomValue(roomDef(bestRoom)) >= 8 && p.hand.rooms.length >= (eff.cost?.discardRooms || 0) + 1) { + push({ kind: 'card', seat: -1, uid: bestRoom.uid }, 3); + } + } else if (eff.op === 'opponentDiscardRandom' && leader) { + const t = (ra.targets || []).find((t2) => t2.seat === leader.seat); + if (t && p.souls >= 6) push(t, 2); + } else if (eff.op === 'resurrectAttack' && leader) { + const t = (ra.targets || []).find((t2) => { + if (t2.seat !== leader.seat) return false; + const hero = leader.soulCards.find((h) => h.uid === t2.uid); + return hero && hero.hpMax > dungeonDamage(view, leader.seat); + }); + if (t) push(t, 8); + } + } + return pickScored(cands, skill, rnd) || { pass: true }; +} + +function chooseReact(view, seat, skill, rnd, decision) { + const prof = profileFor(skill); + if (rnd() > prof.actChance) return null; + const t = decision.target; + const aimedAtMe = t && (t.seat === seat + || (t.kind === 'hero' && me(view, seat).entrance.some((h) => h.uid === t.uid))); + const casterIsLeader = soulLeader(view, seat) && decision.casterSeat === soulLeader(view, seat).seat; + if (!aimedAtMe && !(casterIsLeader && rnd() < 0.3)) return null; + const p = me(view, seat); + // Prefer the All-Seeing Eye (costs a spare spell) over burning Counterspell. + for (let slotIdx = 0; slotIdx < p.dungeon.length; slotIdx++) { + const slot = p.dungeon[slotIdx]; + if (slot.deactivated) continue; + const effIdx = (roomDef(slot.room).effects || []).findIndex((e) => e.trigger === 'reaction'); + if (effIdx >= 0 && !slot.usedOnce[effIdx] && p.hand.spells.length > 1) { + const spare = p.hand.spells.find((c) => spellDef(c).op.op !== 'counterSpell') || p.hand.spells[0]; + return { type: 'allseeingeye', slotIdx, discardSpellUid: spare.uid }; + } + } + const cs = p.hand.spells.find((c) => spellDef(c).op.op === 'counterSpell'); + if (cs && aimedAtMe) return { type: 'counterspell', spellUid: cs.uid }; + return null; +} + +function chooseTarget(view, seat, skill, rnd, decision) { + const cands = decision.candidates || []; + const p = me(view, seat); + const best = (scoreFn) => { + let top = null; let topS = -1e9; + for (const c of cands) { const s = scoreFn(c); if (s > topS) { topS = s; top = c; } } + return { top, topS }; + }; + switch (decision.op) { + case 'destroyOwnRoom': // forced (Robobo) — lose the least + return best((c) => -roomValue(roomDef(p.dungeon[c.slotIdx].room))).top; + case 'recoverDiscard': { + const { top } = best((c) => { + const r = view.roomDiscard.find((x) => x.uid === c.uid); + if (r) return roomValue(roomDef(r)); + return 4; // an unknown spell is a decent pull + }); + return top; + } + case 'revealTake': { + // Level-up peek: real hands are exposed by the candidate list via uids the + // engine validated; without card identity here, take from the soul leader. + const leader = soulLeader(view, seat); + return best((c) => (leader && c.seat === leader.seat ? 2 : 1) + rnd()).top; + } + case 'stealRandom': case 'opponentDiscardRandom': { + const leader = soulLeader(view, seat); + return best((c) => (leader && c.seat === leader.seat ? 2 : 1)).top; + } + case 'tutorAdvanced': + return best((c) => { + const inst = [...(view.roomDiscard || [])].find((x) => x.uid === c.roomUid); + return inst ? roomValue(roomDef(inst)) : 5 + rnd(); + }).top; + case 'killHeroTown': + return best((c) => { + const hero = view.town.find((h) => h.uid === c.uid); + return hero ? heroSouls(heroDef(hero)) * 4 + hero.hp : 0; + }).top; + case 'lureHero': { + const { top, topS } = best((c) => { + const hero = view.town.find((h) => h.uid === c.uid) + || [(view.deckCounts ? null : null)].filter(Boolean)[0]; + if (!hero) return -1; // deck heroes are unknown to the view; skip them + const kill = canKill(view, seat, hero); + return (kill ? 8 : -8) + heroSouls(heroDef(hero)) * 2; + }); + if (decision.optional && topS <= 0) return null; + return top; + } + case 'swapRooms': + return decision.optional ? null : cands[0]; + default: { + const pick = cands[Math.floor(rnd() * cands.length)]; + return decision.optional && rnd() < 0.5 ? pick : (decision.optional ? pick : pick); + } + } +} + +function chooseDiscard(view, seat, decision) { + const p = me(view, seat); + const pool = decision.cardType === 'spell' ? p.hand.spells : p.hand.rooms; + return pool.slice().sort((a, b) => handCardValue(a) - handCardValue(b)) + .slice(0, decision.n).map((c) => c.uid); +} + +function chooseRoomDraw(view, seat) { + return me(view, seat).hand.spells.length < 2 ? 'spell' : 'room'; +} + +// ── Router ────────────────────────────────────────────────────────────────── +// Returns the argument for the matching act* mutator: +// setupDiscard → [uidA, uidB] · build → {roomUid,slotIdx}|null · +// window → {spellUid,target}|{slotIdx,effIdx,target,costUids}|{pass:true} · +// react → response|null · target → targetRef|null · discard → [uids] · +// roomDraw → 'room'|'spell' +export function decide(view, decision, skill, rnd = Math.random) { + const seat = decision.seat; + switch (decision.kind) { + case 'setupDiscard': return chooseSetupDiscard(view, seat); + case 'build': return chooseBuild(view, seat, skill, rnd, decision); + case 'window': return chooseWindow(view, seat, skill, rnd, decision); + case 'react': return chooseReact(view, seat, skill, rnd, decision); + case 'target': return chooseTarget(view, seat, skill, rnd, decision); + case 'discard': return chooseDiscard(view, seat, decision); + case 'roomDraw': return chooseRoomDraw(view, seat); + default: return null; + } +} diff --git a/src/games/dungeonboss/DungeonBossData.js b/src/games/dungeonboss/DungeonBossData.js new file mode 100644 index 0000000..35bffc9 --- /dev/null +++ b/src/games/dungeonboss/DungeonBossData.js @@ -0,0 +1,444 @@ +// Dungeon Boss — card content (Boss Monster base set: real names/stats, rules text our own wording). +// Effects are machine-encoded; `text` is display-only. The engine consumes the encodings, so the +// two must stay in sync — verifyDungeonBoss.js cross-checks ops against the supported enum. + +export const CLASSES = ['fighter', 'mage', 'thief', 'cleric']; + +export const CLASS_INFO = { + fighter: { label: 'Fighter', color: 0xc0392b, glyph: 'sword' }, + mage: { label: 'Mage', color: 0x2e6fd8, glyph: 'tome' }, + thief: { label: 'Thief', color: 0xd9a520, glyph: 'coins' }, + cleric: { label: 'Cleric', color: 0x2ea08c, glyph: 'ankh' }, +}; + +// --------------------------------------------------------------------------- +// Bosses — one-time Level Up ability when the 5th room is built. +// Turn order: highest XP first. End-of-game soul tie: LOWEST XP wins. +// --------------------------------------------------------------------------- +export const BOSSES = { + draculord: { + name: 'Draculord', xp: 900, treasure: 'cleric', + levelUp: { op: 'revealTake' }, + text: 'Level Up: Look at an opponent’s hand and take a card of your choice.', + }, + cleopatra: { + name: 'Cleopatra', xp: 850, treasure: 'thief', + levelUp: { op: 'tutorAdvanced', roomType: 'trap' }, + text: 'Level Up: Fetch an Advanced Trap Room from the deck or discard and build it at once.', + }, + kingcroak: { + name: 'King Croak', xp: 800, treasure: 'fighter', + levelUp: { op: 'tutorAdvanced', roomType: 'monster' }, + text: 'Level Up: Fetch an Advanced Monster Room from the deck or discard and build it at once.', + }, + xyzax: { + name: 'Xyzax', xp: 750, treasure: 'cleric', + levelUp: { op: 'recoverDiscard', pool: 'any', n: 2 }, + text: 'Level Up: Pick two cards from the discard piles and add them to your hand.', + }, + cerebellus: { + name: 'Cerebellus', xp: 650, treasure: 'mage', + levelUp: { op: 'drawThenDiscard', draw: 'spell', n: 3, discard: 'spell', d: 1 }, + text: 'Level Up: Draw three Spells, then discard one Spell.', + }, + seducia: { + name: 'Seducia', xp: 600, treasure: 'mage', + levelUp: { op: 'lureHero', from: 'townOrDeck' }, + text: 'Level Up: Pick any Hero in town or in the Hero decks and drop it at your entrance.', + }, + gorgona: { + name: 'Gorgona', xp: 500, treasure: 'thief', + levelUp: { op: 'killHeroTown' }, + text: 'Level Up: Turn one Hero in town to stone — score its soul immediately.', + }, + robobo: { + name: 'Robobo', xp: 400, treasure: 'fighter', + levelUp: { op: 'wreckEachOpponent' }, + text: 'Level Up: Every opponent must demolish one room of their own dungeon.', + }, +}; + +// --------------------------------------------------------------------------- +// Rooms. type: 'monster'|'trap'; advanced rooms may only be built over a room +// sharing at least one treasure class. treasure: { class: count }. +// Effect triggers: +// onBuild — resolves when the room is revealed in the build flip +// onHeroDieHere — once per round, when a hero dies in this room +// turnStart — choice offered at the start of each round +// activated — player action during a spell window (build/adventure); may have a cost +// reaction — offered when an opponent casts a spell (cancel) +// passive — computed directly by the engine (keyword field, no op) +// --------------------------------------------------------------------------- +export const ROOMS = { + // --- Cleric --- + darkaltar: { + name: 'Dark Altar', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 2 }, count: 3, + effects: [{ trigger: 'activated', window: 'any', cost: { destroySelf: true }, op: 'recoverDiscard', pool: 'any', n: 1 }], + text: 'Sacrifice this room: reclaim any one card from a discard pile.', + }, + opengrave: { + name: 'Open Grave', type: 'trap', advanced: false, dmg: 2, treasure: { cleric: 1 }, count: 2, + effects: [{ trigger: 'onHeroDieHere', op: 'recoverDiscard', pool: 'room', n: 1, auto: true }], + text: 'When a hero dies here (once a round), reclaim a Room card from the discard pile.', + }, + specterssanctum: { + name: "Specter's Sanctum", type: 'monster', advanced: false, dmg: 2, treasure: { cleric: 1 }, count: 3, + effects: [{ trigger: 'onBuild', op: 'opponentDiscardRandom', filter: 'spell' }], + text: 'On build: a rival of your choice loses a random Spell.', + }, + succubusspa: { + name: 'Succubus Spa', type: 'monster', advanced: false, dmg: 1, treasure: { cleric: 1 }, count: 3, + effects: [{ trigger: 'onHeroDieHere', op: 'stealRandom', filter: 'any' }], + text: 'When a hero dies here (once a round), snatch a random card from a rival’s hand.', + }, + dracolichlair: { + name: 'Dracolich Lair', type: 'monster', advanced: true, dmg: 3, treasure: { cleric: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'any', oncePerTurn: true, cost: { discardRooms: 2 }, op: 'recoverDiscard', pool: 'room', n: 1 }], + text: 'Once a round, toss two Room cards to reclaim any Room card from the discard pile.', + }, + vampirebordello: { + name: 'Vampire Bordello', type: 'monster', advanced: true, dmg: 3, treasure: { cleric: 1 }, count: 2, + effects: [{ trigger: 'onHeroDieHere', op: 'healWound' }], + text: 'When a hero dies here (once a round), mend one of your wounds.', + }, + // --- Fighter --- + goblinarmory: { + name: 'Goblin Armory', type: 'monster', advanced: false, dmg: 1, treasure: { fighter: 2 }, count: 3, + passive: 'adjacentMonsterBoost', + text: 'Monster Rooms next to this one strike for +1 damage.', + }, + golemfactory: { + name: 'Golem Factory', type: 'monster', advanced: false, dmg: 2, treasure: { fighter: 1 }, count: 3, + effects: [{ trigger: 'onHeroDieHere', op: 'drawRoom', n: 1 }], + text: 'When a hero dies here (once a round), draw a Room card.', + }, + minotaursmaze: { + name: "Minotaur's Maze", type: 'monster', advanced: false, dmg: 0, treasure: { fighter: 1 }, count: 2, + passive: 'minotaurBounce', + text: 'The first hero through each round is shoved back into the room it just left.', + }, + neanderthalcave: { + name: 'Neanderthal Cave', type: 'monster', advanced: false, dmg: 3, treasure: { fighter: 1 }, count: 3, + passive: 'noAdvancedOnTop', + text: 'No Advanced Room may ever be built over this cave.', + }, + beastmenagerie: { + name: 'Beast Menagerie', type: 'monster', advanced: true, dmg: 4, treasure: { fighter: 1 }, count: 2, + effects: [{ trigger: 'onBuildOtherMonster', op: 'drawRoom', n: 1, oncePerTurn: true }], + text: 'Once a round, when you build another Monster Room, draw a Room card.', + }, + monstersballroom: { + name: "Monster's Ballroom", type: 'monster', advanced: true, dmg: 0, treasure: { fighter: 1 }, count: 2, + passive: 'ballroomDamage', + text: 'Deals damage equal to the number of Monster Rooms in your dungeon.', + }, + // --- Mage --- + brainsuckerhive: { + name: 'Brainsucker Hive', type: 'monster', advanced: false, dmg: 2, treasure: { mage: 1 }, count: 3, + effects: [{ trigger: 'onHeroDieHere', op: 'drawSpell', n: 1 }], + text: 'When a hero dies here (once a round), draw a Spell card.', + }, + darklaboratory: { + name: 'Dark Laboratory', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 2 }, count: 3, + effects: [{ trigger: 'onBuild', op: 'drawThenDiscard', draw: 'spell', n: 2, discard: 'spell', d: 1 }], + text: 'On build: draw two Spells, then discard one Spell.', + }, + hauntedlibrary: { + name: 'Haunted Library', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 1 }, count: 2, + passive: 'librarySpellDraw', + text: 'At the start of each round you may draw a Spell instead of a Room.', + }, + witchskitchen: { + name: "Witch's Kitchen", type: 'monster', advanced: false, dmg: 1, treasure: { mage: 1 }, count: 3, + effects: [{ trigger: 'activated', window: 'any', oncePerTurn: true, cost: { discardMonsterRoom: true }, op: 'drawSpell', n: 1 }], + text: 'Once a round, toss a Monster Room card to draw a Spell card.', + }, + allseeingeye: { + name: 'All-Seeing Eye', type: 'trap', advanced: true, dmg: 3, treasure: { mage: 1 }, count: 2, + effects: [{ trigger: 'reaction', oncePerTurn: true, cost: { discardSpell: true }, op: 'counterSpell' }], + text: 'Once a round, toss a Spell card to cancel a rival’s spell as it is cast.', + }, + ligersden: { + name: "Liger's Den", type: 'monster', advanced: true, dmg: 2, treasure: { mage: 1 }, count: 2, + effects: [{ trigger: 'onCastSpell', op: 'drawSpell', n: 1, oncePerTurn: true }], + text: 'Once a round, when you cast a Spell, draw a Spell card.', + }, + // --- Thief --- + bottomlesspit: { + name: 'Bottomless Pit', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 3, + effects: [{ trigger: 'activated', window: 'adventure', cost: { destroySelf: true }, op: 'pitTrap' }], + text: 'Sacrifice this room: the next hero to enter it this round plummets and dies.', + }, + boulderramp: { + name: 'Boulder Ramp', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'adventure', cost: { destroyOther: true }, op: 'rampTrap' }], + text: 'Demolish another of your rooms: the next hero entering here takes 5 extra damage.', + }, + dizzygashallway: { + name: 'Dizzygas Hallway', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 3, + passive: 'dizzygas', + text: 'If the room after this one is a Trap Room, that room strikes for +2 damage.', + }, + jackpotstash: { + name: 'Jackpot Stash', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 2 }, count: 3, + effects: [{ trigger: 'activated', window: 'build', cost: { destroySelf: true }, op: 'doubleTreasure' }], + text: 'Sacrifice this room: your rooms’ treasure counts double until end of round.', + }, + recyclingcenter: { + name: 'Recycling Center', type: 'trap', advanced: true, dmg: 3, treasure: { thief: 1 }, count: 2, + effects: [{ trigger: 'onOwnRoomDestroyed', op: 'drawRoom', n: 2 }], + text: 'Whenever another of your rooms is destroyed, draw two Room cards.', + }, + thecrushinator: { + name: 'The Crushinator', type: 'trap', advanced: true, dmg: 2, treasure: { thief: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'adventure', oncePerTurn: true, cost: { destroyOther: true }, op: 'dmgBoostAllRooms', amount: 2 }], + text: 'Once a round, demolish another of your rooms: all your rooms strike for +2 until end of round.', + }, + // --- Dual / hybrid treasure --- + centipedetunnel: { + name: 'Centipede Tunnel', type: 'monster', advanced: false, dmg: 1, treasure: { fighter: 1, mage: 1 }, count: 2, + effects: [{ trigger: 'onBuild', op: 'swapRooms', optional: true }], + text: 'On build: you may swap the position of two rooms in any one dungeon.', + }, + constructionzone: { + name: 'Construction Zone', type: 'trap', advanced: false, dmg: 1, treasure: { fighter: 1, thief: 1 }, count: 2, + effects: [{ trigger: 'onBuild', op: 'extraBuild', optional: true }], + text: 'On build: you may immediately build one more room.', + }, + dragonhatchery: { + name: 'Dragon Hatchery', type: 'monster', advanced: false, dmg: 0, treasure: { fighter: 1, mage: 1, thief: 1, cleric: 1 }, count: 3, + text: 'Glitters with every kind of treasure — and guards none of it with teeth.', + }, + mimicvault: { + name: 'Mimic Vault', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 1, thief: 1 }, count: 2, + effects: [{ trigger: 'onBuild', op: 'lureHero', from: 'town', filter: 'ordinary', optional: true }], + text: 'On build: drag one ordinary Hero from town to your entrance.', + }, + monstrousmonument: { + name: 'Monstrous Monument', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 1, fighter: 1 }, count: 2, + effects: [{ trigger: 'onBuild', op: 'recoverDiscard', pool: 'monsterRoom', n: 1 }], + text: 'On build: reclaim a Monster Room card from the discard pile.', + }, + torturechamber: { + name: 'Torture Chamber', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 1, thief: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'any', cost: { destroySelf: true }, op: 'opponentDiscardRandom', filter: 'room' }], + text: 'Sacrifice this room: a rival of your choice loses a random Room card.', + }, + zombieprison: { + name: 'Zombie Prison', type: 'monster', advanced: false, dmg: 1, treasure: { cleric: 1, mage: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'build', cost: { destroySelf: true }, op: 'resurrectAttack', hpBonus: 0 }], + text: 'Sacrifice this room: a dead hero in a rival’s score pile shambles back to their entrance.', + }, +}; + +// --------------------------------------------------------------------------- +// Spells. phase: 'build' | 'adventure' | 'both'. +// --------------------------------------------------------------------------- +export const SPELLS = { + annihilator: { + name: 'Annihilator', phase: 'adventure', count: 2, + op: { op: 'dmgBoostRoom', amount: 3, filter: 'trap' }, + text: 'One Trap Room strikes for +3 damage until end of round.', + }, + assassin: { + name: 'Assassin', phase: 'adventure', count: 3, + op: { op: 'healHero', amount: 3, where: 'opponentDungeon' }, + text: 'A hero in a rival’s dungeon gains +3 health until end of round.', + }, + cavein: { + name: 'Cave-In', phase: 'adventure', count: 2, + op: { op: 'caveIn' }, + text: 'Collapse one of your own rooms — any hero inside it is crushed.', + }, + counterspell: { + name: 'Counterspell', phase: 'both', count: 2, + op: { op: 'counterSpell' }, + text: 'Cancel a spell the moment a rival casts it.', + }, + exhaustion: { + name: 'Exhaustion', phase: 'adventure', count: 1, + op: { op: 'damageHero', amount: 'dungeonSize' }, + text: 'A hero in your dungeon takes damage equal to your room count.', + }, + fear: { + name: 'Fear', phase: 'adventure', count: 2, + op: { op: 'fearHero' }, + text: 'A hero in any dungeon flees back to town.', + }, + freeze: { + name: 'Freeze', phase: 'both', count: 4, + op: { op: 'deactivateRoom' }, + text: 'Ice over one room in any dungeon — no damage, treasure, or abilities until end of round.', + }, + giantsize: { + name: 'Giant Size', phase: 'adventure', count: 2, + op: { op: 'dmgBoostRoom', amount: 3, filter: 'monster' }, + text: 'One Monster Room strikes for +3 damage until end of round.', + }, + jeopardy: { + name: 'Jeopardy', phase: 'both', count: 2, + op: { op: 'handReset' }, + text: 'Every player discards their hand, then draws one Spell and two Rooms.', + }, + koboldstrike: { + name: 'Kobold Strike', phase: 'build', count: 1, + op: { op: 'noBuildRound' }, + text: 'The kobolds walk out: nobody may build a room this round.', + }, + motivation: { + name: 'Motivation', phase: 'build', count: 2, + op: { op: 'extraBuild', cond: 'fewerRooms' }, + text: 'If a rival has more rooms than you, you may build a second room this round.', + }, + princessinperil: { + name: 'Princess in Peril', phase: 'build', count: 2, + op: { op: 'lureHero', from: 'town' }, + text: 'Stage a kidnapping: one Hero in town rushes straight to your entrance.', + }, + soulharvest: { + name: 'Soul Harvest', phase: 'both', count: 1, + op: { op: 'sacrificeSoulDraw', n: 2 }, + text: 'Burn one scored soul to draw two Spell cards.', + }, + teleportation: { + name: 'Teleportation', phase: 'adventure', count: 2, + op: { op: 'teleportHero' }, + text: 'Yank a hero in your dungeon back to your entrance — it walks the gauntlet again.', + }, + trepidation: { + name: 'Trepidation', phase: 'adventure', count: 1, + op: { op: 'blockHeroes', cond: 'twoMoreSouls' }, + text: 'Pick a player at least two souls ahead of you: no hero enters their dungeon this round.', + }, + zombieattack: { + name: 'Zombie Attack', phase: 'build', count: 2, + op: { op: 'resurrectAttack', hpBonus: 2 }, + text: 'A dead hero in a rival’s score pile rises with +2 health and storms their entrance.', + }, +}; + +// --------------------------------------------------------------------------- +// Heroes. Ordinary: 1 soul / 1 wound. Epic: 2 souls / 2 wounds. +// The Fool is lured to the dungeon of the player with the FEWEST souls. +// Hero deck sizing per player count (ordinary/epic per class) handled in buildDecks. +// --------------------------------------------------------------------------- +export const HEROES = { + // Ordinary — Cleric + nick: { name: 'Nick the Masher', cls: 'cleric', hp: 4, epic: false }, + pugi: { name: 'Pugi the Druidess', cls: 'cleric', hp: 4, epic: false }, + acacia: { name: 'Acacia, Warrior of Light', cls: 'cleric', hp: 6, epic: false }, + charles: { name: 'Charles the Young', cls: 'cleric', hp: 6, epic: false }, + delatorious: { name: 'Delatorious', cls: 'cleric', hp: 8, epic: false }, + romero: { name: 'Romero, the Indigo Friar', cls: 'cleric', hp: 8, epic: false }, + // Ordinary — Fighter + boden: { name: 'Boden the Pantless', cls: 'fighter', hp: 4, epic: false }, + jarek: { name: 'Jarek the Squire', cls: 'fighter', hp: 4, epic: false }, + firesbreath: { name: "Fire's Breath", cls: 'fighter', hp: 6, epic: false }, + samuraitom: { name: 'Samurai Tom', cls: 'fighter', hp: 6, epic: false }, + johnny: { name: 'Johnny of the Evening Watch', cls: 'fighter', hp: 8, epic: false }, + crystolalan: { name: "Crystol & A'lan of Gerd", cls: 'fighter', hp: 8, epic: false }, + // Ordinary — Mage + tieg: { name: 'Tieg and the Magic Bubble', cls: 'mage', hp: 4, epic: false }, + brandork: { name: 'Brandork the Neverwrong', cls: 'mage', hp: 4, epic: false }, + mitchell: { name: 'Mitchell, the Judge', cls: 'mage', hp: 6, epic: false }, + kalish: { name: 'Kalish Ninefingers', cls: 'mage', hp: 6, epic: false }, + dartteon: { name: 'Dartteon, Elf Pyromancer', cls: 'mage', hp: 8, epic: false }, + koey: { name: 'Koey, the Last Dragon Mage', cls: 'mage', hp: 8, epic: false }, + // Ordinary — Thief + joman: { name: 'Joman Chimm, Cutpurse', cls: 'thief', hp: 4, epic: false }, + lance: { name: 'Lance Uppercut', cls: 'thief', hp: 4, epic: false }, + jesta: { name: 'Jesta the Rogue', cls: 'thief', hp: 6, epic: false }, + kins: { name: 'Kins Klauski', cls: 'thief', hp: 6, epic: false }, + digby: { name: 'Sir Digby Apple', cls: 'thief', hp: 8, epic: false }, + jerome: { name: 'Jerome, Kung Fu Monkey', cls: 'thief', hp: 8, epic: false }, + // The Fool — any player count; baits to the FEWEST souls + fool: { name: 'The Fool', cls: 'fool', hp: 2, epic: false, fool: true }, + // Epic — Cleric + katelyn: { name: 'Katelyn, Angelic Healer', cls: 'cleric', hp: 11, epic: true }, + kerberos: { name: 'Kerberos Dirtbeard', cls: 'cleric', hp: 11, epic: true }, + vanette: { name: 'Lord Van Ette', cls: 'cleric', hp: 13, epic: true }, + jejune: { name: 'Jejune & Everlea', cls: 'cleric', hp: 13, epic: true }, + // Epic — Fighter + frankov: { name: 'Frankov, the Envoy', cls: 'fighter', hp: 11, epic: true }, + nate: { name: 'Nate the Squidslayer', cls: 'fighter', hp: 11, epic: true }, + antonius: { name: 'Antonius, the Rune Knight', cls: 'fighter', hp: 13, epic: true }, + asmor: { name: 'Asmor the Aweless', cls: 'fighter', hp: 13, epic: true }, + // Epic — Mage + tempros: { name: 'Tempros the Time Marauder', cls: 'mage', hp: 11, epic: true }, + chiakang: { name: 'Chia Kang, Warlock of Yu', cls: 'mage', hp: 11, epic: true }, + terric: { name: 'Terric Warhelm', cls: 'mage', hp: 13, epic: true }, + wayward: { name: 'Wayward, the Drifter', cls: 'mage', hp: 13, epic: true }, + // Epic — Thief + hya: { name: 'Hya, Legendary Shinobi', cls: 'thief', hp: 11, epic: true }, + blackbeard: { name: 'Blackbeard Jake', cls: 'thief', hp: 11, epic: true }, + cecil: { name: 'Cecil Leoran, Factotum', cls: 'thief', hp: 13, epic: true }, + wallbanger: { name: 'Wallbanger Basketweaver', cls: 'thief', hp: 13, epic: true }, +}; + +export function heroSouls(def) { return def.epic ? 2 : 1; } +export function heroWounds(def) { return def.epic ? 2 : 1; } + +// Ordinary/epic heroes used per class, by player count (base-game rule). +export const HERO_DECK_SIZING = { + 2: { ordinaryPerClass: 3, epicPerClass: 2 }, + 3: { ordinaryPerClass: 4, epicPerClass: 3 }, + 4: { ordinaryPerClass: 6, epicPerClass: 4 }, +}; + +// The full op enum — verify script checks every encoded effect against this. +export const OPS = [ + 'drawRoom', 'drawSpell', 'drawThenDiscard', 'recoverDiscard', 'revealTake', 'stealRandom', + 'opponentDiscardRandom', 'handReset', 'wreckEachOpponent', 'swapRooms', 'extraBuild', + 'noBuildRound', 'deactivateRoom', 'tutorAdvanced', 'dmgBoostRoom', 'dmgBoostAllRooms', + 'doubleTreasure', 'damageHero', 'healHero', 'killHeroTown', 'lureHero', 'fearHero', + 'teleportHero', 'resurrectAttack', 'blockHeroes', 'sacrificeSoulDraw', 'healWound', + 'counterSpell', 'pitTrap', 'rampTrap', 'caveIn', +]; + +export const PASSIVES = [ + 'adjacentMonsterBoost', 'minotaurBounce', 'noAdvancedOnTop', 'ballroomDamage', + 'librarySpellDraw', 'dizzygas', +]; + +let uidCounter = 0; +export function makeInstance(id) { return { uid: ++uidCounter, id }; } +export function resetUids() { uidCounter = 0; } + +// Build the four decks for a game. rng: makeRng instance from DungeonBossLogic. +export function buildDecks(rng, nPlayers) { + const rooms = []; + for (const [id, def] of Object.entries(ROOMS)) { + for (let i = 0; i < def.count; i++) rooms.push(makeInstance(id)); + } + const spells = []; + for (const [id, def] of Object.entries(SPELLS)) { + for (let i = 0; i < def.count; i++) spells.push(makeInstance(id)); + } + const sizing = HERO_DECK_SIZING[nPlayers] || HERO_DECK_SIZING[4]; + const heroes = []; + const epics = []; + for (const cls of CLASSES) { + const ord = rng.shuffle(Object.keys(HEROES).filter((id) => !HEROES[id].epic && HEROES[id].cls === cls)); + const epi = rng.shuffle(Object.keys(HEROES).filter((id) => HEROES[id].epic && HEROES[id].cls === cls)); + for (const id of ord.slice(0, sizing.ordinaryPerClass)) heroes.push(instHero(id)); + for (const id of epi.slice(0, sizing.epicPerClass)) epics.push(instHero(id)); + } + heroes.push(instHero('fool')); + return { + rooms: rng.shuffle(rooms), + spells: rng.shuffle(spells), + heroes: rng.shuffle(heroes), + epics: rng.shuffle(epics), + }; +} + +function instHero(id) { + const inst = makeInstance(id); + inst.hp = HEROES[id].hp; + inst.hpMax = HEROES[id].hp; + return inst; +} + +export function roomDef(inst) { return ROOMS[inst.id]; } +export function spellDef(inst) { return SPELLS[inst.id]; } +export function heroDef(inst) { return HEROES[inst.id]; } diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js new file mode 100644 index 0000000..8e6e36f --- /dev/null +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -0,0 +1,1390 @@ +// DungeonBossGame.js — Phaser scene for Dungeon Boss (presentation only; all +// rules live in DungeonBossLogic). The scene pumps the engine's decision queue: +// human decisions enable click UI, AI decisions run on a think delay, and each +// action's event stream drives a sequential animation queue before re-render. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { api } from '../../services/api.js'; +import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js'; +import { + BOSSES, ROOMS, SPELLS, HEROES, CLASS_INFO, + heroSouls, roomDef, spellDef, heroDef, +} from './DungeonBossData.js'; +import { + newGame, pendingDecision, takeEvents, publicView, + actSetupDiscard, actBuild, actWindow, actReact, actChooseTarget, actDiscard, actRoomDraw, + legalBuilds, windowActions, treasureCount, dungeonDamage, + SOULS_TO_WIN, WOUNDS_TO_DIE, MAX_ROOMS, +} from './DungeonBossLogic.js'; +import { decide, nextThinkDelay } from './DungeonBossAI.js'; + +// ── palette: retro dungeon (dark stone, parchment, class colors) ───────────── +const C = { + bg: 0x120e16, bgTop: 0x221a2a, + parchment: 0xe8dcbf, ink: '#2a2118', inkLight: '#f2ead8', muted: '#9e9080', + gold: 0xd9a520, goldHex: '#d9a520', + monsterEdge: 0x8a5a2a, monsterPlate: 0x7a1f1f, + trapEdge: 0xc9962a, trapPlate: 0x8a6a14, + spellEdge: 0x8f6fd8, spellPlate: 0x453569, + bossEdge: 0x8c1f28, + heroWindow: 0x274060, artWindow: 0x1b2436, + soul: 0xf5d76e, wound: 0xc0392b, frozen: 0x9fd8f0, + // Card face background — slightly off-white; the name plates, art windows, + // and rules boxes stay dark/parchment (own contrast), so only the few bits + // of text drawn directly on the bare face need darker ink variants below. + cardBg: 0xf3eee0, + goldDarkHex: '#8a6a14', spellPhaseDarkHex: '#5b3f96', xpDarkHex: '#57506a', +}; +const DEPTH = { board: 10, town: 20, hand: 40, fx: 60, ui: 80, overlay: 90 }; + +export default class DungeonBossGame extends Phaser.Scene { + constructor() { super('DungeonBossGame'); } + + init(data) { + this._initData = data; + this.gameDef = data?.game ?? { slug: 'dungeonboss', name: 'Dungeon Boss' }; + this.opponents = data?.opponents ?? []; + this.playfield = data?.playfield ?? null; + this.nPlayers = 1 + this.opponents.length; + this.humanSeat = 0; + this.gs = null; + this.mode = { type: 'idle' }; // current human input mode + this.busy = false; // animation lock + this._heroTokens = new Map(); // uid → world position of last render + this._slotRects = new Map(); // `${seat}:${idx}` → {x,y,w,h} + this._handSprites = new Map(); // uid → container + this._soulsPos = new Map(); // seat → {x,y} + this._recorded = false; + } + + create() { + try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) { /* optional */ } + this.art = this.cache.json.get('dungeonboss-artwork') || {}; + + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(0); + } else { + const bg = this.add.graphics().setDepth(0); + bg.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1); + bg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + // faint stonework + bg.lineStyle(1, 0xffffff, 0.025); + for (let y = 0; y < GAME_HEIGHT; y += 64) { bg.lineBetween(0, y, GAME_WIDTH, y); } + for (let x = 0; x < GAME_WIDTH; x += 96) { bg.lineBetween(x, 0, x, GAME_HEIGHT); } + } + + this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board); + this.townLayer = this.add.container(0, 0).setDepth(DEPTH.town); + this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand); + this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx); + this.uiLayer = this.add.container(0, 0).setDepth(DEPTH.ui); + + this.buildStaticUi(); + this.gs = newGame(this.nPlayers, (Math.random() * 1e9) | 0); + takeEvents(this.gs); // swallow gameStart + this.renderAll(); + this.showBanner('Discard 2 cards, then build your first room'); + this.pump(); + } + + // ── static chrome ────────────────────────────────────────────────────────── + buildStaticUi() { + new Button(this, 90, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'), + { width: 130, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.ui); + this.deckText = this.add.text(20, 280, '', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#9e9080', lineSpacing: 6, + }).setDepth(DEPTH.ui); + this.promptText = this.add.text(GAME_WIDTH / 2, 262, '', { + fontFamily: 'Righteous', fontSize: '24px', color: C.goldHex, + backgroundColor: '#120e16dd', padding: { x: 16, y: 6 }, + }).setOrigin(0.5).setDepth(DEPTH.ui); + + // portraits (static; boards re-render around them) + const panels = this.oppPanelCenters(); + this.opponents.forEach((opp, i) => { + createOpponentPortrait(this, opp, panels[i].x - 245, panels[i].y - 8, 36, DEPTH.ui); + }); + createPlayerPortrait(this, 80, 700, 40, DEPTH.ui, 'DungeonBossGame'); + } + + oppPanelCenters() { + const y = 138; + const xs = this.opponents.length === 1 ? [960] + : this.opponents.length === 2 ? [630, 1290] : [400, 960, 1520]; + return xs.map((x) => ({ x, y })); + } + + // ── art lookup ───────────────────────────────────────────────────────────── + artFor(kind, id) { + const sheet = this.art[`${kind}Sheet`]; + const frame = this.art[kind === 'boss' ? 'bosses' : `${kind}s`]?.[id]; + if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key)) { + return { key: sheet.key, frame }; + } + return null; + } + + // ── vector treasure icons ────────────────────────────────────────────────── + drawTreasureIcon(cont, x, y, cls, s = 10) { + const info = CLASS_INFO[cls]; + if (!info) return; + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.35); + g.fillRoundedRect(x - s - 2, y - s - 2, s * 2 + 4, s * 2 + 4, 3); + g.fillStyle(info.color, 1); + g.lineStyle(Math.max(1.5, s / 5), info.color, 1); + switch (info.glyph) { + case 'sword': + g.fillTriangle(x, y - s, x - s * 0.28, y + s * 0.35, x + s * 0.28, y + s * 0.35); + g.fillRect(x - s * 0.55, y + s * 0.35, s * 1.1, s * 0.18); + g.fillRect(x - s * 0.12, y + s * 0.5, s * 0.24, s * 0.5); + break; + case 'tome': + g.fillRoundedRect(x - s * 0.75, y - s * 0.65, s * 1.5, s * 1.3, 2); + g.lineStyle(Math.max(1, s / 7), 0x120e16, 1); + g.lineBetween(x, y - s * 0.6, x, y + s * 0.6); + g.lineBetween(x - s * 0.5, y - s * 0.2, x - s * 0.15, y - s * 0.2); + g.lineBetween(x + s * 0.15, y - s * 0.2, x + s * 0.5, y - s * 0.2); + break; + case 'coins': + g.fillCircle(x - s * 0.3, y + s * 0.2, s * 0.45); + g.fillCircle(x + s * 0.35, y + s * 0.15, s * 0.45); + g.fillCircle(x + 0, y - s * 0.35, s * 0.45); + break; + case 'ankh': + g.fillCircle(x, y - s * 0.45, s * 0.34); + g.fillStyle(0x000000, 0.001); // punch look via inner stroke instead + g.lineStyle(Math.max(2, s / 3.2), info.color, 1); + g.lineBetween(x, y - s * 0.15, x, y + s); + g.lineBetween(x - s * 0.5, y + s * 0.15, x + s * 0.5, y + s * 0.15); + break; + default: break; + } + cont.add(g); + } + + // ── card renderers (Boss-Monster-style procedural frames) ────────────────── + makeRoomCard(x, y, inst, w, h, opts = {}) { + const def = roomDef(inst); + const cont = this.add.container(x, y); + const trap = def.type === 'trap'; + const edge = opts.selected ? C.gold : (trap ? C.trapEdge : C.monsterEdge); + const plate = trap ? C.trapPlate : C.monsterPlate; + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(Math.max(2, w / 55), edge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6); + if (def.advanced) { + g.lineStyle(1.5, C.gold, 0.9); + g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 4); + for (const [gx, gy] of [[-w / 2 + 9, -h / 2 + 9], [w / 2 - 9, -h / 2 + 9], [-w / 2 + 9, h / 2 - 9], [w / 2 - 9, h / 2 - 9]]) { + g.fillStyle(C.gold, 1); g.fillCircle(gx, gy, Math.max(2.5, w / 60)); + } + } + // name plate + const plateH = Math.max(16, h * 0.17); + g.fillStyle(plate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3); + // art window + const artH = h * (opts.showText ? 0.42 : 0.58); + g.fillStyle(C.artWindow, 1); + g.fillRect(-w / 2 + 7, -h / 2 + plateH + 8, w - 14, artH); + cont.add(g); + + const art = this.artFor('room', inst.id); + const artCY = -h / 2 + plateH + 8 + artH / 2; + if (art) { + const img = this.add.image(0, artCY, art.key, art.frame); + img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + cont.add(this.add.text(0, artCY, trap ? '⚙' : '👹', { fontSize: `${Math.round(artH * 0.5)}px` }).setOrigin(0.5).setAlpha(0.75)); + } + cont.add(this.add.text(0, -h / 2 + 5 + plateH / 2, def.name, { + fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 12))}px`, + color: '#f2ead8', wordWrap: { width: w - 14 }, + }).setOrigin(0.5).setScale(Math.min(1, (w - 16) / Math.max(1, def.name.length * (w / 20))))); + + // damage badge (bottom-left) + const bd = this.add.graphics(); + const br = Math.max(9, w / 13); + bd.fillStyle(0x1a1420, 1); bd.fillCircle(-w / 2 + br + 5, h / 2 - br - 5, br + 2); + bd.fillStyle(C.wound, 1); bd.fillCircle(-w / 2 + br + 5, h / 2 - br - 5, br); + cont.add(bd); + const dmgLabel = def.passive === 'ballroomDamage' ? '✷' : `${def.dmg}`; + cont.add(this.add.text(-w / 2 + br + 5, h / 2 - br - 5, dmgLabel, { + fontFamily: 'Righteous', fontSize: `${Math.round(br * 1.1)}px`, color: '#f2ead8', + }).setOrigin(0.5)); + + // treasure icons (bottom-right) + const icons = []; + for (const [cls, n] of Object.entries(def.treasure || {})) for (let i = 0; i < n; i++) icons.push(cls); + const isz = Math.max(6, w / 22); + icons.forEach((cls, i) => { + this.drawTreasureIcon(cont, w / 2 - 12 - i * (isz * 2 + 4), h / 2 - isz - 6, cls, isz); + }); + + if (opts.showText && def.text) { + const boxTop = -h / 2 + plateH + 12 + artH; + const tg = this.add.graphics(); + tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 26, 3); + cont.add(tg); + cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 26) / 2, def.text, { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.ink, align: 'center', + wordWrap: { width: w - 22 }, + }).setOrigin(0.5)); + } + this.finishCard(cont, w, h, opts); + return cont; + } + + makeSpellCard(x, y, inst, w, h, opts = {}) { + const def = spellDef(inst); + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(Math.max(2, w / 45), opts.selected ? C.gold : C.spellEdge, 1); + g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6); + const plateH = Math.max(15, h * 0.15); + g.fillStyle(C.spellPlate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3); + const artH = h * 0.36; + g.fillStyle(0x201640, 1); g.fillRect(-w / 2 + 7, -h / 2 + plateH + 8, w - 14, artH); + cont.add(g); + const art = this.artFor('spell', inst.id); + const artCY = -h / 2 + plateH + 8 + artH / 2; + if (art) { + const img = this.add.image(0, artCY, art.key, art.frame); + img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + cont.add(this.add.text(0, artCY, '✦', { fontSize: `${Math.round(artH * 0.55)}px`, color: '#8f6fd8' }).setOrigin(0.5)); + } + cont.add(this.add.text(0, -h / 2 + 5 + plateH / 2, def.name, { + fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 8.5))}px`, color: '#f2ead8', + }).setOrigin(0.5).setScale(Math.min(1, (w - 14) / Math.max(1, def.name.length * (w / 14))))); + const phase = def.phase === 'both' ? 'BUILD · ADV' : def.phase.toUpperCase(); + cont.add(this.add.text(0, -h / 2 + plateH + artH + 16, phase, { + fontFamily: '"Julius Sans One"', fontSize: '10px', color: C.spellPhaseDarkHex, + }).setOrigin(0.5)); + const boxTop = -h / 2 + plateH + artH + 26; + const tg = this.add.graphics(); + tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3); + cont.add(tg); + cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', + wordWrap: { width: w - 20 }, + }).setOrigin(0.5)); + this.finishCard(cont, w, h, opts); + return cont; + } + + makeHeroCard(x, y, hero, w, h, opts = {}) { + const def = heroDef(hero); + const info = CLASS_INFO[def.cls] || { color: 0x888888, label: 'Wanderer' }; + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(Math.max(2, w / 40), opts.selected ? C.gold : info.color, 1); + g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6); + if (def.epic) { g.lineStyle(2, C.gold, 1); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 4); } + const artH = h * 0.52; + g.fillStyle(C.heroWindow, 1); g.fillRect(-w / 2 + 6, -h / 2 + 6, w - 12, artH); + // class banner + g.fillStyle(info.color, 1); g.fillRoundedRect(-w / 2 + 6, -h / 2 + artH + 10, w - 12, 16, 3); + cont.add(g); + const art = this.artFor('hero', hero.id); + if (art) { + const img = this.add.image(0, -h / 2 + 6 + artH / 2, art.key, art.frame); + img.setScale(Math.min((w - 12) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + const glyph = def.fool ? '🃏' : def.cls === 'fighter' ? '🛡' : def.cls === 'mage' ? '🔮' : def.cls === 'thief' ? '🗝' : '📿'; + cont.add(this.add.text(0, -h / 2 + 6 + artH / 2, glyph, { fontSize: `${Math.round(artH * 0.42)}px` }).setOrigin(0.5).setAlpha(0.85)); + } + if (def.epic) { + cont.add(this.add.text(0, -h / 2 + 14, '★ EPIC', { fontFamily: 'Righteous', fontSize: '11px', color: C.goldHex }).setOrigin(0.5)); + } + cont.add(this.add.text(0, -h / 2 + artH + 18, (def.fool ? 'THE FOOL' : info.label.toUpperCase()), { + fontFamily: 'Righteous', fontSize: '11px', color: '#f2ead8', + }).setOrigin(0.5)); + cont.add(this.add.text(0, -h / 2 + artH + 38, def.name, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', + wordWrap: { width: w - 12 }, + }).setOrigin(0.5)); + // hp heart + soul value + const hy = h / 2 - 16; + const hg = this.add.graphics(); + hg.fillStyle(C.wound, 1); + hg.fillCircle(-w / 2 + 16, hy - 3, 6); hg.fillCircle(-w / 2 + 26, hy - 3, 6); + hg.fillTriangle(-w / 2 + 9, hy - 1, -w / 2 + 33, hy - 1, -w / 2 + 21, hy + 11); + cont.add(hg); + cont.add(this.add.text(-w / 2 + 21, hy + 1, `${hero.hp}`, { + fontFamily: 'Righteous', fontSize: '13px', color: '#fff', + }).setOrigin(0.5)); + const souls = heroSouls(def); + const sg = this.add.graphics(); + sg.fillStyle(C.soul, 1); sg.fillCircle(w / 2 - 18, hy + 2, 9); + sg.lineStyle(1.5, 0x8a6a14, 1); sg.strokeCircle(w / 2 - 18, hy + 2, 9); + cont.add(sg); + cont.add(this.add.text(w / 2 - 18, hy + 2, `${souls}`, { + fontFamily: 'Righteous', fontSize: '12px', color: '#2a2118', + }).setOrigin(0.5)); + this.finishCard(cont, w, h, opts); + return cont; + } + + makeBossCard(x, y, bossId, w, h, opts = {}) { + const def = BOSSES[bossId]; + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(Math.max(2.5, w / 40), C.bossEdge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(1.5, C.gold, 0.7); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 5); + const artH = h * (opts.showText ? 0.44 : 0.6); + g.fillStyle(0x0d0a12, 1); g.fillRect(-w / 2 + 7, -h / 2 + 30, w - 14, artH); + cont.add(g); + const art = this.artFor('boss', bossId); + if (art) { + const img = this.add.image(0, -h / 2 + 30 + artH / 2, art.key, art.frame); + img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + cont.add(this.add.text(0, -h / 2 + 30 + artH / 2, '☠', { fontSize: `${Math.round(artH * 0.5)}px`, color: '#8c1f28' }).setOrigin(0.5)); + } + cont.add(this.add.text(0, -h / 2 + 16, def.name, { + fontFamily: 'Righteous', fontSize: `${Math.max(11, Math.round(w / 10))}px`, color: C.goldDarkHex, + }).setOrigin(0.5).setScale(Math.min(1, (w - 12) / Math.max(1, def.name.length * (w / 16))))); + const iy = -h / 2 + 30 + artH + 14; + cont.add(this.add.text(-w / 2 + 10, iy, `${def.xp} XP`, { + fontFamily: 'Righteous', fontSize: '13px', color: C.xpDarkHex, + }).setOrigin(0, 0.5)); + this.drawTreasureIcon(cont, w / 2 - 18, iy, def.treasure, 9); + if (opts.showText) { + const boxTop = iy + 12; + const tg = this.add.graphics(); + tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3); + cont.add(tg); + cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', + wordWrap: { width: w - 20 }, + }).setOrigin(0.5)); + } + this.finishCard(cont, w, h, opts); + return cont; + } + + makeCardBack(x, y, w, h, parent) { + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(0x241a2e, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(2, 0x4a3a5c, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(1, 0x4a3a5c, 0.6); g.strokeRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 4); + cont.add(g); + cont.add(this.add.text(0, 0, '✦', { fontSize: `${Math.round(h * 0.3)}px`, color: '#5c4a70' }).setOrigin(0.5)); + (parent || this.boardLayer).add(cont); + return cont; + } + + finishCard(cont, w, h, opts) { + if (opts.deactivated) { + const ov = this.add.graphics(); + ov.fillStyle(C.frozen, 0.28); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + cont.add(ov); + cont.add(this.add.text(0, 0, '❄', { fontSize: `${Math.round(h * 0.3)}px`, color: '#d8f0fa' }).setOrigin(0.5).setAlpha(0.9)); + } + if (opts.highlight) { + const hl = this.add.graphics(); + hl.lineStyle(3, C.gold, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 8); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 }); + } + if (opts.onClick) { + cont.setSize(w, h); + cont.setInteractive({ useHandCursor: true }); + if (opts.hover !== false) { + const baseY = cont.y; + cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 14, duration: 100 }); }); + cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 })); + } + cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); }); + } + (opts.parent || this.boardLayer).add(cont); + } + + // ── layout helpers ────────────────────────────────────────────────────────── + humanSlotRect(idx) { + // Boss at the right; slot 0 sits beside it, entrance grows leftward. + return { x: 1500 - idx * 212, y: 700, w: 196, h: 136 }; + } + townPos(i, n) { + const pitch = Math.min(124, 1100 / Math.max(1, n)); + return { x: GAME_WIDTH / 2 - ((n - 1) * pitch) / 2 + i * pitch, y: 408 }; + } + + // ── full re-render ────────────────────────────────────────────────────────── + renderAll() { + this.boardLayer.removeAll(true); + this.townLayer.removeAll(true); + this.handLayer.removeAll(true); + this._heroTokens.clear(); + this._slotRects.clear(); + this._handSprites.clear(); + this._soulsPos.clear(); + const gs = this.gs; + if (!gs) return; + + this.renderOpponents(); + this.renderTown(); + this.renderHumanBoard(); + this.renderHand(); + this.deckText.setText([ + `Rooms ${gs.decks.rooms.length}`, + `Spells ${gs.decks.spells.length}`, + `Heroes ${gs.decks.heroes.length}`, + `Epics ${gs.decks.epics.length}${gs.epicsActive ? ' ⚡' : ''}`, + `Round ${gs.round}`, + ].join('\n')); + } + + renderOpponents() { + const centers = this.oppPanelCenters(); + for (let i = 0; i < this.opponents.length; i++) { + const seat = i + 1; + const p = this.gs.players[seat]; + const { x, y } = centers[i]; + const pw = 560, ph = 224; + const g = this.add.graphics(); + g.fillStyle(0x1a1422, p.alive ? 0.92 : 0.5); + g.fillRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10); + g.lineStyle(2, p.alive ? 0x3a2f4d : 0x2a2233, 1); + g.strokeRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10); + this.boardLayer.add(g); + const nameCol = p.alive ? '#f2ead8' : '#6f6678'; + this.boardLayer.add(this.add.text(x - 195, y - 62, this.opponents[i].name || `Boss ${seat}`, { + fontFamily: 'Righteous', fontSize: '18px', color: nameCol, + }).setOrigin(0.5)); + if (!p.alive) { + this.boardLayer.add(this.add.text(x, y, 'DEFEATED', { + fontFamily: 'Righteous', fontSize: '34px', color: '#8c1f28', + }).setOrigin(0.5).setAngle(-8)); + continue; + } + // boss mini-card at panel right + this.makeBossCard(x + 228, y - 20, p.boss.id, 88, 124, { + parent: this.boardLayer, hover: false, + onClick: () => this.showInspect('boss', p.boss.id), + }); + // souls & wounds under the name + const sy = y - 20; + const sg = this.add.graphics(); + sg.fillStyle(C.soul, 1); sg.fillCircle(x - 245, sy, 11); + this.boardLayer.add(sg); + this._soulsPos.set(seat, { x: x - 245, y: sy }); + this.boardLayer.add(this.add.text(x - 228, sy, `${p.souls}/${SOULS_TO_WIN}`, { + fontFamily: 'Righteous', fontSize: '17px', color: C.goldHex, + }).setOrigin(0, 0.5)); + for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) { + this.boardLayer.add(this.add.text(x - 250 + wI * 22, sy + 30, '☠', { + fontSize: '17px', color: wI < p.wounds ? '#c0392b' : '#3a3242', + }).setOrigin(0.5)); + } + this.boardLayer.add(this.add.text(x - 245, sy + 56, `✋ ${p.hand.rooms.length + p.hand.spells.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#9e9080', + }).setOrigin(0, 0.5)); + // mini dungeon: boss-adjacent on the right, entrance leftward + const mw = 82, mh = 58, pitch = 88; + for (let idx = 0; idx < p.dungeon.length; idx++) { + const sx = x + 128 - idx * pitch; + const slot = p.dungeon[idx]; + this._slotRects.set(`${seat}:${idx}`, { x: sx, y: y + 62, w: mw, h: mh }); + this.makeRoomCard(sx, y + 62, slot.room, mw, mh, { + parent: this.boardLayer, hover: false, + deactivated: slot.deactivated, + highlight: this.isRoomTarget(seat, idx), + onClick: () => this.onRoomClicked(seat, idx), + }); + if (slot.tempDmg) this.miniBadge(sx + mw / 2 - 8, y + 62 - mh / 2 + 8, `+${slot.tempDmg}`); + } + // pending secret build + if (p.pendingBuild) this.makeCardBack(x + 128 - p.dungeon.length * pitch, y + 62, mw * 0.8, mh * 0.9, this.boardLayer); + // entrance heroes + p.entrance.forEach((hero, hi) => { + const hx = x - pw / 2 + 26 + hi * 20; + const t = this.heroToken(hero, hx, y + 62); + this.boardLayer.add(t); + this._heroTokens.set(hero.uid, { x: hx, y: y + 62 }); + }); + } + } + + renderTown() { + const gs = this.gs; + const n = gs.town.length; + const label = this.add.text(GAME_WIDTH / 2, 318, `— TOWN ${n ? '' : '(empty)'} —`, { + fontFamily: 'Righteous', fontSize: '18px', color: '#9e9080', + }).setOrigin(0.5); + this.townLayer.add(label); + gs.town.forEach((hero, i) => { + const { x, y } = this.townPos(i, n); + this._heroTokens.set(hero.uid, { x, y }); + this.makeHeroCard(x, y, hero, 106, 148, { + parent: this.townLayer, + highlight: this.isHeroTarget(hero.uid), + onClick: () => this.onHeroClicked(hero.uid), + }); + }); + } + + renderHumanBoard() { + const gs = this.gs; + const p = gs.players[this.humanSeat]; + const seat = this.humanSeat; + // boss card + this.makeBossCard(1740, 700, p.boss.id, 168, 236, { + parent: this.boardLayer, showText: true, hover: false, + onClick: () => this.showInspect('boss', p.boss.id), + }); + // souls & wounds beside the player portrait + const sg = this.add.graphics(); + sg.fillStyle(C.soul, 1); sg.fillCircle(60, 780, 13); + this.boardLayer.add(sg); + this._soulsPos.set(seat, { x: 60, y: 780 }); + this.boardLayer.add(this.add.text(82, 780, `${p.souls}/${SOULS_TO_WIN} souls`, { + fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex, + }).setOrigin(0, 0.5)); + for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) { + this.boardLayer.add(this.add.text(52 + wI * 26, 818, '☠', { + fontSize: '21px', color: wI < p.wounds ? '#c0392b' : '#3a3242', + }).setOrigin(0.5)); + } + if (!p.alive) { + this.boardLayer.add(this.add.text(1060, 700, 'YOUR BOSS HAS FALLEN', { + fontFamily: 'Righteous', fontSize: '42px', color: '#8c1f28', + }).setOrigin(0.5).setAngle(-4)); + return; + } + // slots: existing rooms + (in build mode) legal placement outlines + for (let idx = 0; idx < MAX_ROOMS; idx++) { + const r = this.humanSlotRect(idx); + const slot = p.dungeon[idx]; + if (slot) { + this._slotRects.set(`${seat}:${idx}`, r); + this.makeRoomCard(r.x, r.y, slot.room, r.w, r.h, { + parent: this.boardLayer, hover: false, + deactivated: slot.deactivated, + highlight: this.isRoomTarget(seat, idx) || this.isBuildSlot(idx), + onClick: () => this.onRoomClicked(seat, idx), + }); + if (slot.under.length) { + this.boardLayer.add(this.add.text(r.x - r.w / 2 + 10, r.y - r.h / 2 - 10, `▤ ${slot.under.length + 1}`, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#9e9080', + }).setOrigin(0, 0.5)); + } + if (slot.tempDmg) this.miniBadge(r.x + r.w / 2 - 10, r.y - r.h / 2 + 10, `+${slot.tempDmg}`); + if (slot.armed) this.miniBadge(r.x, r.y - r.h / 2 + 10, slot.armed.type === 'ramp' ? '⚠+5' : '⚠', 0xc9962a); + // activatable badge during windows + const act = this.activatableFor(idx); + if (act) { + const b = this.add.container(r.x, r.y + r.h / 2 + 16); + const bg = this.add.graphics(); + bg.fillStyle(C.gold, 1); bg.fillRoundedRect(-42, -12, 84, 24, 12); + b.add(bg); + b.add(this.add.text(0, 0, 'USE', { fontFamily: 'Righteous', fontSize: '14px', color: '#2a2118' }).setOrigin(0.5)); + b.setSize(84, 24); + b.setInteractive({ useHandCursor: true }); + b.on('pointerdown', () => { if (!this.busy) this.beginActivate(idx, act); }); + this.boardLayer.add(b); + this.tweens.add({ targets: b, alpha: 0.6, duration: 500, yoyo: true, repeat: -1 }); + } + } else if (this.isBuildSlot(idx)) { + const g = this.add.graphics(); + g.lineStyle(3, C.gold, 0.9); + g.strokeRoundedRect(r.x - r.w / 2, r.y - r.h / 2, r.w, r.h, 8); + this.boardLayer.add(g); + this.tweens.add({ targets: g, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 }); + const z = this.add.zone(r.x, r.y, r.w, r.h).setInteractive({ useHandCursor: true }); + z.on('pointerdown', () => this.onBuildSlotClicked(idx)); + this.boardLayer.add(z); + } else if (idx === p.dungeon.length) { + const g = this.add.graphics(); + g.lineStyle(2, 0x3a2f4d, 0.8); + g.strokeRoundedRect(r.x - r.w / 2, r.y - r.h / 2, r.w, r.h, 8); + this.boardLayer.add(g); + } + } + // entrance arch + queued heroes + const entR = this.humanSlotRect(Math.max(p.dungeon.length, 1) - 0.35); + this.boardLayer.add(this.add.text(entR.x - 130, 700, '⛩', { fontSize: '44px' }).setOrigin(0.5).setAlpha(0.8)); + this.boardLayer.add(this.add.text(entR.x - 130, 748, 'ENTRANCE', { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#9e9080', + }).setOrigin(0.5)); + p.entrance.forEach((hero, hi) => { + const hx = entR.x - 130 - hi * 24; + const t = this.heroToken(hero, hx, 640); + this.boardLayer.add(t); + this._heroTokens.set(hero.uid, { x: hx, y: 640 }); + }); + // pending build indicator + if (p.pendingBuild) { + const r = this.humanSlotRect(p.pendingBuild.slotIdx); + this.makeCardBack(r.x, r.y - 20, r.w * 0.85, r.h * 0.9, this.boardLayer).setAlpha(0.9); + } + } + + heroToken(hero, x, y) { + const def = heroDef(hero); + const info = CLASS_INFO[def.cls] || { color: 0x888888 }; + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(0x10141c, 1); g.fillCircle(0, 0, 17); + g.lineStyle(def.epic ? 3 : 2, def.epic ? C.gold : info.color, 1); g.strokeCircle(0, 0, 17); + cont.add(g); + cont.add(this.add.text(0, -1, `${hero.hp}`, { fontFamily: 'Righteous', fontSize: '15px', color: '#f2ead8' }).setOrigin(0.5)); + cont.setSize(36, 36); + cont.setInteractive({ useHandCursor: true }); + cont.on('pointerdown', () => { if (!this.busy) this.onHeroClicked(hero.uid); }); + if (this.isHeroTarget(hero.uid)) { + const hl = this.add.graphics(); + hl.lineStyle(3, C.gold, 1); hl.strokeCircle(0, 0, 22); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.3, duration: 420, yoyo: true, repeat: -1 }); + } + return cont; + } + + miniBadge(x, y, txt, color = 0x8f6fd8) { + const g = this.add.graphics(); + g.fillStyle(color, 1); g.fillRoundedRect(x - 17, y - 9, 34, 18, 9); + this.boardLayer.add(g); + this.boardLayer.add(this.add.text(x, y, txt, { + fontFamily: 'Righteous', fontSize: '12px', color: '#f2ead8', + }).setOrigin(0.5)); + } + + renderHand() { + const p = this.gs.players[this.humanSeat]; + if (!p.alive) return; + const rooms = p.hand.rooms; + const spells = p.hand.spells; + const total = rooms.length + spells.length; + if (!total) return; + const roomW = 148, roomH = 102, spellW = 104, spellH = 146; + const pitch = Math.min(160, 1500 / Math.max(1, total)); + const width = (total - 1) * pitch; + let x = GAME_WIDTH / 2 - width / 2; + const y = 985; + const mode = this.mode; + for (const inst of rooms) { + const selectable = this.isHandSelectable(inst); + const sp = this.makeRoomCard(x, y, inst, roomW, roomH, { + parent: this.handLayer, showText: false, + selected: this.isHandSelected(inst.uid), + highlight: selectable && (mode.type === 'discard' || mode.type === 'setupDiscard'), + onClick: () => this.onHandClicked(inst), + }); + if (!selectable && (mode.type === 'build' || mode.type === 'discard' || mode.type === 'setupDiscard' || mode.type === 'window')) sp.setAlpha(0.85); + this._handSprites.set(inst.uid, sp); + x += pitch; + } + for (const inst of spells) { + const castable = this.castableUids?.has(inst.uid); + const sp = this.makeSpellCard(x, y - 16, inst, spellW, spellH, { + parent: this.handLayer, + selected: this.isHandSelected(inst.uid), + highlight: !!castable || (this.isHandSelectable(inst) && (mode.type === 'discard' || mode.type === 'setupDiscard')), + onClick: () => this.onHandClicked(inst), + }); + this._handSprites.set(inst.uid, sp); + x += pitch; + } + } + + // ── mode / highlight queries ──────────────────────────────────────────────── + isBuildSlot(idx) { + return this.mode.type === 'build' && this.mode.selectedRoom + && this.mode.legal.some((b) => b.roomUid === this.mode.selectedRoom && b.slotIdx === idx); + } + isRoomTarget(seat, idx) { + return (this.mode.type === 'target' || this.mode.type === 'castTarget' || this.mode.type === 'activateTarget') + && (this.mode.candidates || []).some((c) => (c.kind === 'room' && c.seat === seat && c.slotIdx === idx) + || (c.kind === 'swap' && c.seat === seat && (c.a === idx || c.b === idx))); + } + isHeroTarget(uid) { + return (this.mode.type === 'target' || this.mode.type === 'castTarget' || this.mode.type === 'activateTarget') + && (this.mode.candidates || []).some((c) => c.kind === 'hero' && c.uid === uid); + } + isHandSelected(uid) { return (this.mode.selected || []).includes(uid) || this.mode.selectedRoom === uid; } + isHandSelectable(inst) { + const m = this.mode; + if (m.type === 'setupDiscard' || m.type === 'discard') { + if (m.cardType === 'spell') return !!SPELLS[inst.id]; + if (m.cardType === 'room') return !!ROOMS[inst.id]; + return true; + } + if (m.type === 'build') return !!ROOMS[inst.id] && m.legal.some((b) => b.roomUid === inst.uid); + if (m.type === 'window') return this.castableUids?.has(inst.uid); + return false; + } + + // ── pump: run engine until human input is required ───────────────────────── + pump() { + if (!this.gs || this.busy) return; + if (this.gs.gameOver) { this.onGameOver(); return; } + const d = pendingDecision(this.gs); + if (!d) return; + if (d.seat !== this.humanSeat) { + this.setPrompt(this.aiPromptFor(d)); + const skill = this.opponents[d.seat - 1]?.skill ?? 3; + this.busy = true; + this.time.delayedCall(nextThinkDelay(skill), () => { + this.busy = false; + if (this.gs.gameOver) { this.onGameOver(); return; } + const nd = pendingDecision(this.gs); + if (!nd || nd.seat === this.humanSeat) { this.pump(); return; } + const choice = decide(publicView(this.gs, nd.seat), nd, skill); + this.applyDecision(nd, choice); + }); + return; + } + this.enterHumanMode(d); + } + + aiPromptFor(d) { + const name = this.opponents[d.seat - 1]?.name || `Boss ${d.seat}`; + switch (d.kind) { + case 'build': return `${name} is scheming a room…`; + case 'window': return `${name} is considering spells…`; + case 'react': return `${name} eyes the spell…`; + default: return `${name} is thinking…`; + } + } + + applyDecision(d, choice) { + try { + switch (d.kind) { + case 'setupDiscard': actSetupDiscard(this.gs, d.seat, choice); break; + case 'build': actBuild(this.gs, d.seat, choice); break; + case 'window': actWindow(this.gs, d.seat, choice); break; + case 'react': actReact(this.gs, d.seat, choice); break; + case 'target': actChooseTarget(this.gs, d.seat, choice); break; + case 'discard': actDiscard(this.gs, d.seat, choice); break; + case 'roomDraw': actRoomDraw(this.gs, d.seat, choice); break; + default: break; + } + } catch (err) { + // A decision the engine rejects should never soft-lock the table. + console.error('dungeonboss action rejected:', err); + this.renderAll(); + this.pump(); + return; + } + this.mode = { type: 'idle' }; + this.castableUids = null; + this.clearActionButtons(); + this.playEvents(takeEvents(this.gs)); + } + + // ── human decision modes ──────────────────────────────────────────────────── + enterHumanMode(d) { + this.clearActionButtons(); + switch (d.kind) { + case 'setupDiscard': + this.mode = { type: 'setupDiscard', selected: [], n: 2 }; + this.setPrompt('Discard 2 cards — click cards, then Confirm'); + this.addActionButton('Confirm', () => { + if (this.mode.selected.length === 2) this.applyDecision(d, this.mode.selected); + }); + break; + case 'discard': + this.mode = { type: 'discard', selected: [], n: d.n, cardType: d.cardType }; + this.setPrompt(`Discard ${d.n} ${d.cardType} card${d.n > 1 ? 's' : ''}`); + this.addActionButton('Confirm', () => { + if (this.mode.selected.length === d.n) this.applyDecision(d, this.mode.selected); + }); + break; + case 'build': { + const legal = legalBuilds(this.gs, this.humanSeat); + this.mode = { type: 'build', legal, selectedRoom: null, decision: d }; + if (d.setup) this.setPrompt('Build your first room — click a room card, then a slot'); + else if (d.extra) this.setPrompt('Extra build! Click a room card, then a slot'); + else this.setPrompt('Build a room (click card, then slot) — or Pass'); + if (!d.setup || !legal.length) this.addActionButton('Pass', () => this.applyDecision(d, null)); + break; + } + case 'window': { + const acts = windowActions(this.gs, this.humanSeat); + this.mode = { type: 'window', decision: d, acts }; + this.castableUids = new Set(acts.spells.map((s) => s.uid)); + const label = d.window === 'advStart' + ? (d.advSeat === this.humanSeat ? 'Heroes charge your gate! Cast spells or Pass' + : `Heroes storm ${this.seatName(d.advSeat)} — interfere or Pass`) + : 'Spell window — cast, use a room, or Pass'; + this.setPrompt(label); + this.addActionButton('Pass', () => this.applyDecision(d, { pass: true })); + break; + } + case 'react': { + this.mode = { type: 'react', decision: d }; + this.showReactModal(d); + break; + } + case 'target': { + this.mode = { type: 'target', decision: d, candidates: d.candidates }; + this.setPrompt(this.targetPrompt(d)); + if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null)); + if (this.needsModalTargets(d)) this.showTargetModal(d); + break; + } + case 'roomDraw': { + this.mode = { type: 'roomDraw', decision: d }; + this.setPrompt('Haunted Library: choose your draw'); + this.addActionButton('Draw Room', () => this.applyDecision(d, 'room')); + this.addActionButton('Draw Spell', () => this.applyDecision(d, 'spell')); + break; + } + default: break; + } + this.renderAll(); + } + + seatName(seat) { + return seat === this.humanSeat ? 'you' : (this.opponents[seat - 1]?.name || `Boss ${seat}`); + } + + targetPrompt(d) { + switch (d.op) { + case 'destroyOwnRoom': return 'Robobo strikes! Choose one of your rooms to demolish'; + case 'killHeroTown': return 'Choose a hero in town to petrify'; + case 'lureHero': return 'Choose a hero to lure to your entrance'; + case 'swapRooms': return 'Choose a room pair to swap'; + case 'tutorAdvanced': return 'Choose an advanced room to build'; + case 'recoverDiscard': return 'Reclaim a card from the discard pile'; + case 'revealTake': return 'Take a card from a rival\'s hand'; + case 'stealRandom': case 'opponentDiscardRandom': return 'Choose a rival'; + default: return 'Choose a target'; + } + } + + // Ops whose candidates aren't on the board (cards in discards/hands/decks). + needsModalTargets(d) { + return ['recoverDiscard', 'revealTake', 'tutorAdvanced'].includes(d.op) + || (d.op === 'lureHero' && (d.candidates || []).some((c) => c.kind === 'deckHero')) + || ['stealRandom', 'opponentDiscardRandom'].includes(d.op) + || (d.candidates || []).every((c) => c.kind === 'soul' || c.kind === 'player'); + } + + // ── click handlers ────────────────────────────────────────────────────────── + onHandClicked(inst) { + const m = this.mode; + if (m.type === 'setupDiscard' || m.type === 'discard') { + if (!this.isHandSelectable(inst)) return; + const i = m.selected.indexOf(inst.uid); + if (i >= 0) m.selected.splice(i, 1); + else if (m.selected.length < m.n) m.selected.push(inst.uid); + this.sfx(SFX.CARD_SHOW); + this.renderAll(); + return; + } + if (m.type === 'build') { + if (!ROOMS[inst.id] || !m.legal.some((b) => b.roomUid === inst.uid)) return; + m.selectedRoom = m.selectedRoom === inst.uid ? null : inst.uid; + this.sfx(SFX.CARD_SHOW); + this.renderAll(); + return; + } + if (m.type === 'window' && this.castableUids?.has(inst.uid)) { + const act = m.acts.spells.find((s) => s.uid === inst.uid); + if (act.targets === null) { + this.applyDecision(m.decision, { spellUid: inst.uid }); + } else { + this.mode = { type: 'castTarget', decision: m.decision, spellUid: inst.uid, candidates: act.targets }; + this.setPrompt(`${SPELLS[inst.id].name}: choose a target`); + this.clearActionButtons(); + this.addActionButton('Cancel', () => this.enterHumanMode(m.decision)); + if (act.targets.every((c) => ['card', 'soul', 'player', 'deckHero', 'build', 'swap'].includes(c.kind))) { + this.showPickModal(act.targets, (t) => this.applyDecision(this.mode.decision, { spellUid: inst.uid, target: t })); + } + this.renderAll(); + } + } + } + + onBuildSlotClicked(idx) { + const m = this.mode; + if (m.type !== 'build' || !m.selectedRoom) return; + this.sfx(SFX.CARD_PLACE); + this.applyDecision(m.decision, { roomUid: m.selectedRoom, slotIdx: idx }); + } + + onRoomClicked(seat, idx) { + const m = this.mode; + const ref = this.matchCandidate((c) => (c.kind === 'room' && c.seat === seat && c.slotIdx === idx) + || (c.kind === 'swap' && c.seat === seat && (c.a === idx || c.b === idx))); + if (ref && ref.kind === 'swap') { + // two-click swap: first click picks `a`, second picks the pair + if (m.swapFirst == null) { m.swapFirst = idx; this.setPrompt('Now choose the room to swap with'); return; } + const pair = (m.candidates || []).find((c) => c.kind === 'swap' && c.seat === seat + && ((c.a === m.swapFirst && c.b === idx) || (c.a === idx && c.b === m.swapFirst))); + if (pair) this.resolveTargetClick(pair); + return; + } + if (ref) { this.resolveTargetClick(ref); return; } + // build-over: clicking an occupied slot while a room is armed + if (m.type === 'build' && m.selectedRoom && seat === this.humanSeat + && m.legal.some((b) => b.roomUid === m.selectedRoom && b.slotIdx === idx)) { + this.onBuildSlotClicked(idx); + return; + } + // otherwise: inspect + const slot = this.gs.players[seat].dungeon[idx]; + if (slot) this.showInspect('room', slot.room.id); + } + + onHeroClicked(uid) { + const ref = this.matchCandidate((c) => c.kind === 'hero' && c.uid === uid); + if (ref) { this.resolveTargetClick(ref); return; } + const hero = this.findHero(uid); + if (hero) this.showInspect('hero', hero.id); + } + + matchCandidate(fn) { + const m = this.mode; + if (!['target', 'castTarget', 'activateTarget'].includes(m.type)) return null; + return (m.candidates || []).find(fn) || null; + } + + resolveTargetClick(ref) { + const m = this.mode; + if (m.type === 'target') this.applyDecision(m.decision, ref); + else if (m.type === 'castTarget') this.applyDecision(m.decision, { spellUid: m.spellUid, target: ref }); + else if (m.type === 'activateTarget') this.applyDecision(m.decision, { slotIdx: m.slotIdx, effIdx: m.effIdx, target: ref, costUids: m.costUids }); + } + + findHero(uid) { + for (const h of this.gs.town) if (h.uid === uid) return h; + for (const p of this.gs.players) for (const h of p.entrance) if (h.uid === uid) return h; + return null; + } + + activatableFor(idx) { + if (this.mode.type !== 'window') return null; + return (this.mode.acts?.rooms || []).find((r) => r.slotIdx === idx) || null; + } + + beginActivate(idx, act) { + const d = this.mode.decision; + const slot = this.gs.players[this.humanSeat].dungeon[idx]; + const eff = roomDef(slot.room).effects[act.effIdx]; + const costUids = this.autoCostUids(eff.cost); + if (act.targets === null) { + this.applyDecision(d, { slotIdx: idx, effIdx: act.effIdx, costUids }); + return; + } + this.mode = { type: 'activateTarget', decision: d, slotIdx: idx, effIdx: act.effIdx, costUids, candidates: act.targets }; + this.setPrompt(`${roomDef(slot.room).name}: choose a target`); + this.clearActionButtons(); + this.addActionButton('Cancel', () => this.enterHumanMode(d)); + if (act.targets.every((c) => ['card', 'soul', 'player', 'deckHero', 'build'].includes(c.kind))) { + this.showPickModal(act.targets, (t) => this.applyDecision(d, { slotIdx: idx, effIdx: act.effIdx, target: t, costUids })); + } + this.renderAll(); + } + + // Cheapest valid cards to pay a cost (humans aren't prompted for cost picks). + autoCostUids(cost) { + if (!cost) return undefined; + const p = this.gs.players[this.humanSeat]; + if (cost.discardRooms) { + return p.hand.rooms.slice().sort((a, b) => roomDef(a).dmg - roomDef(b).dmg) + .slice(0, cost.discardRooms).map((c) => c.uid); + } + if (cost.discardMonsterRoom) { + const m = p.hand.rooms.filter((c) => roomDef(c).type === 'monster') + .sort((a, b) => roomDef(a).dmg - roomDef(b).dmg)[0]; + return m ? [m.uid] : undefined; + } + if (cost.discardSpell) return p.hand.spells.length ? [p.hand.spells[0].uid] : undefined; + return undefined; + } + + // ── modals ────────────────────────────────────────────────────────────────── + modalRoot() { + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6); + dim.setInteractive(); // swallow clicks + root.add(dim); + this._modal = root; + return root; + } + closeModal() { if (this._modal) { this._modal.destroy(); this._modal = null; } } + + // Generic candidate picker for off-board targets. + showPickModal(cands, onPick) { + const root = this.modalRoot(); + const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(cands.length)))); + const cw = 150, ch = 168; + const rows = Math.ceil(cands.length / cols); + const x0 = GAME_WIDTH / 2 - ((cols - 1) * (cw + 14)) / 2; + const y0 = GAME_HEIGHT / 2 - ((rows - 1) * (ch + 16)) / 2; + cands.forEach((ref, i) => { + const x = x0 + (i % cols) * (cw + 14); + const y = y0 + Math.floor(i / cols) * (ch + 16); + const pick = () => { this.closeModal(); onPick(ref); }; + const card = this.renderCandidate(ref, x, y, cw, ch, root, pick); + if (!card) { + const b = new Button(this, x, y, this.candidateLabel(ref), pick, { width: cw, fontSize: 16 }); + b.setDepth(DEPTH.overlay + 1); + } + }); + const cancel = this.mode.decision?.optional; + new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + (rows / 2) * (ch + 16) + 60, + cancel ? 'Skip' : 'Cancel', () => { + this.closeModal(); + if (cancel) this.applyDecision(this.mode.decision, null); + else this.enterHumanMode(this.mode.decision); + }, { width: 150, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + } + + renderCandidate(ref, x, y, cw, ch, root, onPick) { + const gs = this.gs; + if (ref.kind === 'card') { + const inst = [...gs.decks.roomDiscard, ...gs.decks.spellDiscard, + ...gs.players.flatMap((p) => [...p.hand.rooms, ...p.hand.spells])] + .find((c) => c.uid === ref.uid); + if (!inst) return null; + if (ROOMS[inst.id]) return this.makeRoomCard(x, y, inst, cw, ch * 0.72, { parent: root, showText: false, onClick: onPick, hover: false }); + return this.makeSpellCard(x, y, inst, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false }); + } + if (ref.kind === 'soul') { + const hero = gs.players[ref.seat].soulCards.find((h) => h.uid === ref.uid); + if (!hero) return null; + return this.makeHeroCard(x, y, hero, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false }); + } + if (ref.kind === 'deckHero') { + const hero = [...gs.decks.heroes, ...gs.decks.epics].find((h) => h.uid === ref.uid); + if (!hero) return null; + return this.makeHeroCard(x, y, hero, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false }); + } + if (ref.kind === 'build') { + const inst = [...gs.decks.rooms, ...gs.decks.roomDiscard].find((c) => c.uid === ref.roomUid); + if (!inst) return null; + const card = this.makeRoomCard(x, y - 12, inst, cw, ch * 0.66, { parent: root, showText: false, onClick: onPick, hover: false }); + root.add(this.add.text(x, y + ch / 2 - 14, `→ slot ${ref.slotIdx + 1}`, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: C.goldHex, + }).setOrigin(0.5)); + return card; + } + return null; + } + + candidateLabel(ref) { + if (ref.kind === 'player') return this.seatName(ref.seat); + if (ref.kind === 'swap') return `Swap ${ref.a + 1} ↔ ${ref.b + 1} (${this.seatName(ref.seat)})`; + return 'Choose'; + } + + showReactModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const g = this.add.graphics(); + g.fillStyle(0x1a1422, 0.97); g.fillRoundedRect(cx - 330, cy - 150, 660, 300, 12); + g.lineStyle(3, C.spellEdge, 1); g.strokeRoundedRect(cx - 330, cy - 150, 660, 300, 12); + root.add(g); + root.add(this.add.text(cx, cy - 110, `${this.seatName(d.casterSeat)} casts ${SPELLS[d.spellId].name}!`, { + fontFamily: 'Righteous', fontSize: '26px', color: '#f2ead8', + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 66, SPELLS[d.spellId].text, { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: '#b7a6e8', align: 'center', + wordWrap: { width: 560 }, + }).setOrigin(0.5)); + const p = this.gs.players[this.humanSeat]; + const buttons = []; + const cs = p.hand.spells.find((c) => spellDef(c).op.op === 'counterSpell'); + if (cs) buttons.push({ label: 'Counterspell!', resp: { type: 'counterspell', spellUid: cs.uid } }); + p.dungeon.forEach((slot, slotIdx) => { + if (slot.deactivated) return; + const effIdx = (roomDef(slot.room).effects || []).findIndex((e) => e.trigger === 'reaction'); + if (effIdx >= 0 && !slot.usedOnce[effIdx] && p.hand.spells.length) { + const spare = p.hand.spells.find((c) => c.uid !== cs?.uid) || p.hand.spells[0]; + buttons.push({ label: 'All-Seeing Eye (toss a spell)', resp: { type: 'allseeingeye', slotIdx, discardSpellUid: spare.uid } }); + } + }); + buttons.push({ label: 'Let it resolve', resp: null }); + buttons.forEach((b, i) => { + new Button(this, cx, cy + 4 + i * 54, b.label, () => { this.closeModal(); this.applyDecision(d, b.resp); }, + { width: 380, fontSize: 19, variant: b.resp ? undefined : 'ghost' }).setDepth(DEPTH.overlay + 1); + }); + } + + showInspect(kind, id) { + if (this._modal) return; + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const inst = { uid: -1, id, hp: kind === 'hero' ? HEROES[id]?.hp : 0, hpMax: 0 }; + if (kind === 'room') this.makeRoomCard(cx, cy, inst, 380, 300, { parent: root, showText: true, hover: false }); + else if (kind === 'hero') this.makeHeroCard(cx, cy, inst, 280, 390, { parent: root, hover: false }); + else if (kind === 'boss') this.makeBossCard(cx, cy, id, 300, 430, { parent: root, showText: true, hover: false }); + const zone = this.add.zone(cx, cy, GAME_WIDTH, GAME_HEIGHT).setInteractive(); + zone.on('pointerdown', () => this.closeModal()); + root.add(zone); + root.bringToTop(zone); + // let the card render above the dim but below the close zone… close on any click + } + + // ── prompt + action buttons ───────────────────────────────────────────────── + setPrompt(txt) { this.promptText.setText(txt || ''); } + addActionButton(label, cb) { + this._actionButtons = this._actionButtons || []; + const i = this._actionButtons.length; + const b = new Button(this, GAME_WIDTH / 2 + 470 + (i % 2) * 160, 262 + Math.floor(i / 2) * 48, label, cb, + { width: 150, fontSize: 18 }); + b.setDepth(DEPTH.ui); + this._actionButtons.push(b); + } + clearActionButtons() { + for (const b of this._actionButtons || []) b.destroy(); + this._actionButtons = []; + } + + // ── event playback ────────────────────────────────────────────────────────── + playEvents(events) { + this.busy = true; + this.setPrompt(''); + const queue = events.filter((e) => this.eventDelay(e) > 0 || this.eventFx(e, true)); + const step = () => { + const e = queue.shift(); + if (!e) { + this.busy = false; + this.renderAll(); + this.pump(); + return; + } + this.eventFx(e, false); + this.time.delayedCall(this.eventDelay(e), step); + }; + // render intermediate state once so effects anchor to fresh positions + this.renderAll(); + step(); + } + + eventDelay(e) { + switch (e.type) { + case 'flip': return 260; + case 'roomBuilt': return 240; + case 'heroRevealed': return 140; + case 'heroWalks': return 320; + case 'heroEnters': return 200; + case 'roomHits': return 340; + case 'heroDies': return 480; + case 'bossWounded': return 420; + case 'levelUp': return 800; + case 'eliminated': return 800; + case 'spellCast': return 820; + case 'spellCountered': return 700; + case 'roundStart': return 600; + case 'heroResurrected': return 400; + case 'roomDestroyed': return 320; + case 'roomFrozen': return 300; + case 'trapArmed': return 260; + default: return 0; + } + } + + // When probe=true just report whether the event produces fx. + eventFx(e, probe) { + if (probe) return this.eventDelay(e) > 0; + const gs = this.gs; + switch (e.type) { + case 'flip': + this.sfx(SFX.CARD_SHOW); + break; + case 'roomBuilt': { + this.sfx(SFX.CARD_PLACE); + const r = this.slotRect(e.seat, e.slotIdx); + if (r) this.popText(r.x, r.y, ROOMS[e.id]?.name || '', '#f2ead8'); + break; + } + case 'heroRevealed': + this.sfx(SFX.CARD_DEAL); + break; + case 'heroWalks': { + const from = this._heroTokens.get(e.uid); + const hero = this.findHero(e.uid) || { uid: e.uid, id: e.id, hp: 0 }; + if (from) { + const tok = this.heroToken(hero, from.x, from.y); + this.fxLayer.add(tok); + const to = this.entranceApprox(e.seat); + this.tweens.add({ targets: tok, x: to.x, y: to.y, duration: 300, ease: 'Cubic.easeIn', onComplete: () => tok.destroy() }); + } + break; + } + case 'roomHits': { + const r = this.slotRect(e.seat, e.slotIdx); + if (r) { + this.popText(r.x, r.y - 20, `-${e.amount}`, '#ff6b5e', 26); + this.shakeAt(r.x, r.y); + } + break; + } + case 'heroDies': { + const from = this._heroTokens.get(e.uid) || this.entranceApprox(e.seat); + const to = this._soulsPos.get(e.seat) || { x: 60, y: 780 }; + const wisp = this.add.graphics(); + wisp.fillStyle(C.soul, 1); wisp.fillCircle(0, 0, 10); + const cont = this.add.container(from.x, from.y, [wisp]); + this.fxLayer.add(cont); + this.tweens.add({ targets: cont, x: to.x, y: to.y, duration: 440, ease: 'Cubic.easeInOut', onComplete: () => cont.destroy() }); + this.popText(from.x, from.y - 26, `+${e.souls} soul${e.souls > 1 ? 's' : ''}`, '#f5d76e'); + this.sfx(SFX.COINS); + break; + } + case 'bossWounded': { + const pos = e.seat === this.humanSeat ? { x: 1740, y: 700 } : this.oppBossPos(e.seat); + this.popText(pos.x, pos.y - 40, `☠ wound${e.wounds > 1 ? ' ×2' : ''}`, '#ff5348', 26); + const flash = this.add.rectangle(pos.x, pos.y, 180, 250, 0xc0392b, 0.4).setDepth(DEPTH.fx); + this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() }); + break; + } + case 'levelUp': + this.showBanner(`⚡ ${BOSSES[e.boss].name} LEVELS UP — ${this.seatName(e.seat)}`); + break; + case 'eliminated': + this.showBanner(`${this.seatName(e.seat)} has been slain!`); + break; + case 'spellCast': { + this.sfx(SFX.CARD_SHOW); + const cx = GAME_WIDTH / 2, cy = 560; + const card = this.makeSpellCard(cx, cy, { uid: -1, id: e.id }, 168, 236, { parent: this.fxLayer, hover: false }); + card.setScale(0.4).setAlpha(0); + this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 180, ease: 'Back.easeOut' }); + this.fxLayer.add(this.add.text(cx, cy - 150, `${this.seatName(e.seat)} casts`, { + fontFamily: 'Righteous', fontSize: '20px', color: '#b7a6e8', + }).setOrigin(0.5)); + this.time.delayedCall(680, () => this.fxLayer.removeAll(true)); + break; + } + case 'spellCountered': + this.showBanner(`✕ ${SPELLS[e.spellId]?.name || 'The spell'} is countered by ${this.seatName(e.seat)}!`); + break; + case 'roundStart': + this.showBanner(`— Round ${e.round} —`); + this.sfx(SFX.CARD_DEAL); + break; + case 'heroResurrected': + this.popText(GAME_WIDTH / 2, 500, 'A hero rises from the grave!', '#9be89b'); + break; + case 'roomDestroyed': { + const r = this.slotRect(e.seat, e.slotIdx); + if (r) { this.popText(r.x, r.y, '💥', '#ffffff', 34); this.shakeAt(r.x, r.y); } + break; + } + case 'roomFrozen': { + const r = this.slotRect(e.seat, e.slotIdx); + if (r) this.popText(r.x, r.y, '❄', '#d8f0fa', 34); + break; + } + case 'trapArmed': + this.popText(GAME_WIDTH / 2, 700, '⚠ trap armed', '#c9962a'); + break; + default: break; + } + return true; + } + + slotRect(seat, idx) { + return this._slotRects.get(`${seat}:${idx}`) + || (seat === this.humanSeat ? this.humanSlotRect(idx) : null); + } + entranceApprox(seat) { + if (seat === this.humanSeat) { + const len = this.gs.players[seat].dungeon.length; + const r = this.humanSlotRect(Math.max(0, len - 1)); + return { x: r.x - 130, y: 640 }; + } + const centers = this.oppPanelCenters(); + const c = centers[seat - 1]; + return { x: c.x - 250, y: c.y + 62 }; + } + oppBossPos(seat) { + const c = this.oppPanelCenters()[seat - 1]; + return { x: c.x + 228, y: c.y - 20 }; + } + + popText(x, y, txt, color, size = 20) { + const t = this.add.text(x, y, txt, { + fontFamily: 'Righteous', fontSize: `${size}px`, color, stroke: '#120e16', strokeThickness: 4, + }).setOrigin(0.5).setDepth(DEPTH.fx); + this.tweens.add({ targets: t, y: y - 46, alpha: 0, duration: 900, ease: 'Cubic.easeOut', onComplete: () => t.destroy() }); + } + + shakeAt(x, y) { + const g = this.add.graphics().setDepth(DEPTH.fx); + g.lineStyle(3, 0xffffff, 0.5); + g.strokeCircle(x, y, 26); + this.tweens.add({ targets: g, scale: 1.6, alpha: 0, duration: 300, onComplete: () => g.destroy() }); + } + + showBanner(text) { + const banner = this.add.text(GAME_WIDTH / 2, 330, text, { + fontFamily: 'Righteous', fontSize: '30px', color: '#f2ead8', + backgroundColor: '#120e16ee', padding: { x: 26, y: 12 }, + }).setOrigin(0.5).setDepth(DEPTH.overlay); + this.tweens.add({ + targets: banner, y: 356, duration: 240, ease: 'Back.easeOut', + onComplete: () => this.time.delayedCall(900, () => + this.tweens.add({ targets: banner, alpha: 0, y: 330, duration: 220, onComplete: () => banner.destroy() })), + }); + } + + sfx(key) { try { playSound(this, key); } catch (_) { /* optional */ } } + + // ── game over ─────────────────────────────────────────────────────────────── + onGameOver() { + if (this._gameOverShown) return; + this._gameOverShown = true; + this.clearActionButtons(); + this.setPrompt(''); + const gs = this.gs; + const won = gs.winner === this.humanSeat; + const human = gs.players[this.humanSeat]; + const oppScores = gs.players.filter((p) => p.seat !== this.humanSeat).map((p) => p.souls); + this.recordResult(won ? 'win' : 'loss', human.souls, oppScores); + + const order = gs.players.slice().sort((a, b) => (b.seat === gs.winner) - (a.seat === gs.winner) || b.souls - a.souls); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const h = 170 + order.length * 44; + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive()); + const g = this.add.graphics(); + g.fillStyle(0x14101c, 0.97); g.fillRoundedRect(cx - 340, cy - h / 2, 680, h, 14); + g.lineStyle(3, won ? C.gold : C.bossEdge, 1); g.strokeRoundedRect(cx - 340, cy - h / 2, 680, h, 14); + root.add(g); + root.add(this.add.text(cx, cy - h / 2 + 40, won ? '👑 Your dungeon reigns!' : 'Your reign is over', { + fontFamily: 'Righteous', fontSize: '36px', color: won ? C.goldHex : '#f2ead8', + }).setOrigin(0.5)); + order.forEach((p, i) => { + const tag = p.seat === gs.winner ? '👑 ' : p.alive ? '' : '☠ '; + root.add(this.add.text(cx, cy - h / 2 + 96 + i * 42, + `${tag}${this.seatName(p.seat)} — ${p.souls} souls, ${p.wounds} wounds`, { + fontFamily: '"Julius Sans One"', fontSize: '21px', + color: p.seat === gs.winner ? C.goldHex : '#e6ddc8', + }).setOrigin(0.5)); + }); + new Button(this, cx - 115, cy + h / 2 - 42, 'Play Again', () => this.scene.restart(this._initData), + { width: 195, fontSize: 21 }).setDepth(DEPTH.overlay + 1); + new Button(this, cx + 115, cy + h / 2 - 42, 'Leave', () => this.scene.start('GameMenu'), + { width: 195, fontSize: 21, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + } + + async recordResult(result, score, opponentScores) { + if (this._recorded) return; + this._recorded = true; + try { + await api.post('/history/single-player', { slug: 'dungeonboss', score, opponentScores, result }); + } catch { /* best effort */ } + } +} diff --git a/src/games/dungeonboss/DungeonBossLogic.js b/src/games/dungeonboss/DungeonBossLogic.js new file mode 100644 index 0000000..c41e147 --- /dev/null +++ b/src/games/dungeonboss/DungeonBossLogic.js @@ -0,0 +1,1375 @@ +// DungeonBossLogic.js +// Pure (no Phaser) engine for Dungeon Boss, a Boss Monster adaptation. +// Pull model: after every act*() mutator the engine advances automatically +// (reveals, draws, bait, adventure walking) until it needs a decision — +// pendingDecision(state) says whose and what kind. Typed events accumulate in +// state.events; the scene/verify drain them with takeEvents(). +// +// Deliberate simplifications from the tabletop game (noted where they apply): +// - Spells resolve at three cast windows (build start, post flip, pre-walk) +// instead of true any-time interrupts; Pit/Ramp/Cave-In "arm" a room and +// resolve when a hero enters it, which is where they'd be used anyway. +// - Counterspell chains don't recurse (a reaction can't itself be countered). +// - Vampire Bordello heals a wound without flipping it to a soul. +// - Trepidation keeps blocked heroes in town rather than parked at an entrance. + +import { + CLASSES, BOSSES, ROOMS, SPELLS, HEROES, OPS, + heroSouls, heroWounds, buildDecks, makeInstance, resetUids, + roomDef, spellDef, heroDef, +} from './DungeonBossData.js'; + +export const SOULS_TO_WIN = 10; +export const WOUNDS_TO_DIE = 5; +export const MAX_ROOMS = 5; +const WINDOW_CAST_CAP = 20; + +// ── RNG (mulberry32, seedable) ────────────────────────────────────────────── +export function makeRng(seed) { + let s = (seed >>> 0) || 1; + const fn = () => { + s |= 0; s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + fn.int = (n) => Math.floor(fn() * n); + fn.pick = (arr) => arr[fn.int(arr.length)]; + fn.range = (lo, hi) => lo + fn.int(hi - lo + 1); + fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; }; + return fn; +} + +// ── Game creation ─────────────────────────────────────────────────────────── +export function newGame(nPlayers, seed = (Math.random() * 1e9) | 0) { + resetUids(); + const rng = makeRng(seed); + const decks = buildDecks(rng, nPlayers); + const bossIds = rng.shuffle(Object.keys(BOSSES)).slice(0, nPlayers); + const players = []; + for (let seat = 0; seat < nPlayers; seat++) { + players.push({ + seat, + alive: true, + boss: { id: bossIds[seat], leveledUp: false }, + dungeon: [], // index 0 = boss-adjacent; entrance = last index + hand: { rooms: [], spells: [] }, + entrance: [], // heroes queued to walk this round + souls: 0, + soulCards: [], + wounds: 0, + pendingBuild: undefined, // undefined = not asked/answered, null = pass + extraBuilds: 0, + roundMods: freshMods(), + }); + } + const state = { + seed, rng, nPlayers, round: 0, + phase: 'setupDiscard', + decks: { ...decks, roomDiscard: [], spellDiscard: [] }, + epicsActive: false, + town: [], + players, + // Highest XP acts first. Souls tie at game end: LOWEST XP wins. + turnOrder: players.map((p) => p.seat).sort((a, b) => BOSSES[players[b].boss.id].xp - BOSSES[players[a].boss.id].xp), + window: null, + reaction: null, + effectQueue: [], + adv: null, + roundNoBuild: false, + events: [], + gameOver: false, + winner: null, + setupBuilt: false, + }; + for (const p of players) { + for (let i = 0; i < 5; i++) p.hand.rooms.push(state.decks.rooms.pop()); + for (let i = 0; i < 2; i++) p.hand.spells.push(state.decks.spells.pop()); + } + emit(state, { type: 'gameStart', bosses: players.map((p) => p.boss.id) }); + return state; +} + +function freshMods() { + return { allBoost: 0, doubleTreasure: false, blocked: false, extraBuildUsed: false }; +} + +function emit(state, ev) { state.events.push(ev); } +export function takeEvents(state) { const e = state.events; state.events = []; return e; } + +// ── Small helpers ─────────────────────────────────────────────────────────── +const P = (state, seat) => state.players[seat]; +const aliveSeats = (state) => state.turnOrder.filter((s) => P(state, s).alive); +function handAll(p) { return [...p.hand.rooms, ...p.hand.spells]; } +function removeFromHand(p, uid) { + let i = p.hand.rooms.findIndex((c) => c.uid === uid); + if (i >= 0) return p.hand.rooms.splice(i, 1)[0]; + i = p.hand.spells.findIndex((c) => c.uid === uid); + if (i >= 0) return p.hand.spells.splice(i, 1)[0]; + return null; +} +function discardCard(state, inst) { + if (ROOMS[inst.id]) state.decks.roomDiscard.push(inst); + else state.decks.spellDiscard.push(inst); +} +function drawRooms(state, seat, n) { + const p = P(state, seat); + for (let i = 0; i < n; i++) { + if (!state.decks.rooms.length) reshuffleRooms(state); + if (!state.decks.rooms.length) return; + p.hand.rooms.push(state.decks.rooms.pop()); + emit(state, { type: 'draw', seat, card: 'room' }); + } +} +function drawSpells(state, seat, n) { + const p = P(state, seat); + for (let i = 0; i < n; i++) { + if (!state.decks.spells.length) reshuffleSpells(state); + if (!state.decks.spells.length) return; + p.hand.spells.push(state.decks.spells.pop()); + emit(state, { type: 'draw', seat, card: 'spell' }); + } +} +function reshuffleRooms(state) { + if (!state.decks.roomDiscard.length) return; + state.decks.rooms = state.rng.shuffle(state.decks.roomDiscard.filter((c) => ROOMS[c.id])); + state.decks.roomDiscard = []; + emit(state, { type: 'reshuffle', deck: 'rooms' }); +} +function reshuffleSpells(state) { + if (!state.decks.spellDiscard.length) return; + state.decks.spells = state.rng.shuffle(state.decks.spellDiscard); + state.decks.spellDiscard = []; + emit(state, { type: 'reshuffle', deck: 'spells' }); +} + +function newSlot(roomInst) { + return { room: roomInst, under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }; +} + +// ── Treasure / damage math ────────────────────────────────────────────────── +export function treasureCount(state, seat, cls) { + const p = P(state, seat); + let rooms = 0; + for (const slot of p.dungeon) { + if (slot.deactivated) continue; + rooms += (roomDef(slot.room).treasure || {})[cls] || 0; + } + if (p.roundMods.doubleTreasure) rooms *= 2; + return rooms + (BOSSES[p.boss.id].treasure === cls ? 1 : 0); +} + +// Damage a hero takes in dungeon slot `idx` (entrance = dungeon.length-1). +export function roomDamage(state, seat, idx) { + const p = P(state, seat); + const slot = p.dungeon[idx]; + if (!slot || slot.deactivated) return 0; + const def = roomDef(slot.room); + let dmg = def.dmg; + if (def.passive === 'ballroomDamage') { + dmg = p.dungeon.filter((s) => !s.deactivated && roomDef(s.room).type === 'monster').length; + } + if (def.type === 'monster') { + for (const j of [idx - 1, idx + 1]) { + const adj = p.dungeon[j]; + if (adj && !adj.deactivated && roomDef(adj.room).passive === 'adjacentMonsterBoost') dmg += 1; + } + } + // Dizzygas: if the room the hero just left (idx+1, nearer the entrance) is an + // active Dizzygas Hallway and this room is a trap, +2. + const prev = p.dungeon[idx + 1]; + if (def.type === 'trap' && prev && !prev.deactivated && roomDef(prev.room).passive === 'dizzygas') dmg += 2; + dmg += slot.tempDmg + p.roundMods.allBoost; + return Math.max(0, dmg); +} + +// Total damage of a full walk — used by the AI to judge "can I kill this hero". +export function dungeonDamage(state, seat) { + let total = 0; + for (let i = P(state, seat).dungeon.length - 1; i >= 0; i--) total += roomDamage(state, seat, i); + return total; +} + +// ── Legality helpers (shared by scene, AI, verify) ────────────────────────── +export function legalBuilds(state, seat) { + const p = P(state, seat); + const out = []; + for (const inst of p.hand.rooms) { + const def = roomDef(inst); + if (def.advanced) { + for (let idx = 0; idx < p.dungeon.length; idx++) { + if (canBuildOver(state, seat, def, idx)) out.push({ roomUid: inst.uid, slotIdx: idx }); + } + } else { + if (p.dungeon.length < MAX_ROOMS) out.push({ roomUid: inst.uid, slotIdx: p.dungeon.length }); + for (let idx = 0; idx < p.dungeon.length; idx++) out.push({ roomUid: inst.uid, slotIdx: idx }); + } + } + return out; +} +function canBuildOver(state, seat, def, idx) { + const slot = P(state, seat).dungeon[idx]; + if (!slot) return false; + const under = roomDef(slot.room); + if (under.passive === 'noAdvancedOnTop' && def.advanced) return false; + if (!def.advanced) return true; + return CLASSES.some((c) => (def.treasure[c] || 0) > 0 && (under.treasure[c] || 0) > 0); +} + +function windowKindOk(effWindow, windowId) { + if (effWindow === 'any') return true; + if (effWindow === 'build') return windowId === 'buildStart' || windowId === 'postFlip'; + return windowId === 'advStart'; +} +function spellPhaseOk(def, windowId) { + if (def.phase === 'both') return true; + if (def.phase === 'build') return windowId === 'buildStart' || windowId === 'postFlip'; + return windowId === 'advStart'; +} + +// All actions a seat could take in the current window. +export function windowActions(state, seat) { + if (!state.window || state.gameOver) return { spells: [], rooms: [] }; + const p = P(state, seat); + if (!p.alive) return { spells: [], rooms: [] }; + const windowId = state.window.id; + const spells = []; + for (const inst of p.hand.spells) { + const def = spellDef(inst); + if (def.op.op === 'counterSpell') continue; // reaction-only + if (!spellPhaseOk(def, windowId)) continue; + const targets = opCandidates(state, seat, def.op, { source: 'spell' }); + if (targets === null || targets.length) spells.push({ uid: inst.uid, id: inst.id, targets }); + } + const rooms = []; + for (let idx = 0; idx < p.dungeon.length; idx++) { + const slot = p.dungeon[idx]; + if (slot.deactivated || slot.armed) continue; + const def = roomDef(slot.room); + for (let e = 0; e < (def.effects || []).length; e++) { + const eff = def.effects[e]; + if (eff.trigger !== 'activated') continue; + if (!windowKindOk(eff.window, windowId)) continue; + if (eff.oncePerTurn && slot.usedOnce[e]) continue; + if (!costPayable(state, seat, eff.cost, idx)) continue; + const targets = opCandidates(state, seat, eff, { source: 'room', slotIdx: idx }); + if (targets === null || targets.length) rooms.push({ slotIdx: idx, effIdx: e, targets }); + } + } + return { spells, rooms }; +} + +function costPayable(state, seat, cost, slotIdx) { + if (!cost) return true; + const p = P(state, seat); + if (cost.discardRooms && p.hand.rooms.length < cost.discardRooms) return false; + if (cost.discardMonsterRoom && !p.hand.rooms.some((c) => roomDef(c).type === 'monster')) return false; + if (cost.discardSpell && !p.hand.spells.length) return false; + if (cost.destroyOther && p.dungeon.length < 2) return false; + return true; +} + +// ── Op target candidates ──────────────────────────────────────────────────── +// Returns null when the op needs no target, else an array of targetRefs +// (possibly empty = not castable). targetRef kinds: +// {kind:'hero',uid} {kind:'room',seat,slotIdx} {kind:'player',seat} +// {kind:'card',seat,uid} {kind:'soul',seat,uid} {kind:'deckHero',uid} +// {kind:'build',roomUid,slotIdx} {kind:'swap',seat,a,b} +export function opCandidates(state, seat, eff, ctx = {}) { + const p = P(state, seat); + const opps = aliveSeats(state).filter((s) => s !== seat); + switch (eff.op) { + case 'drawRoom': case 'drawSpell': case 'drawThenDiscard': case 'handReset': + case 'noBuildRound': case 'doubleTreasure': + case 'healWound': case 'pitTrap': case 'counterSpell': + return null; + case 'extraBuild': + if (eff.cond === 'fewerRooms' && !opps.some((s) => P(state, s).dungeon.length > p.dungeon.length)) return []; + return null; + case 'recoverDiscard': { + const pool = []; + if (eff.pool === 'any' || eff.pool === 'room') pool.push(...state.decks.roomDiscard); + if (eff.pool === 'monsterRoom') pool.push(...state.decks.roomDiscard.filter((c) => roomDef(c).type === 'monster')); + if (eff.pool === 'any' || eff.pool === 'spell') pool.push(...state.decks.spellDiscard); + return pool.map((c) => ({ kind: 'card', seat: -1, uid: c.uid })); + } + case 'revealTake': { + const out = []; + for (const s of opps) for (const c of handAll(P(state, s))) out.push({ kind: 'card', seat: s, uid: c.uid }); + return out; + } + case 'stealRandom': case 'opponentDiscardRandom': { + const filter = eff.filter || 'any'; + return opps.filter((s) => { + const o = P(state, s); + if (filter === 'spell') return o.hand.spells.length > 0; + if (filter === 'room') return o.hand.rooms.length > 0; + return handAll(o).length > 0; + }).map((s) => ({ kind: 'player', seat: s })); + } + case 'wreckEachOpponent': + return null; // queues per-opponent destroyOwnRoom decisions + case 'destroyOwnRoom': + return P(state, ctx.forSeat ?? seat).dungeon.map((_, i) => ({ kind: 'room', seat: ctx.forSeat ?? seat, slotIdx: i })); + case 'swapRooms': { + const out = []; + for (const s of aliveSeats(state)) { + const d = P(state, s).dungeon; + for (let a = 0; a < d.length; a++) for (let b = a + 1; b < d.length; b++) out.push({ kind: 'swap', seat: s, a, b }); + } + return out; + } + case 'deactivateRoom': { + const out = []; + for (const s of aliveSeats(state)) { + P(state, s).dungeon.forEach((slot, i) => { if (!slot.deactivated) out.push({ kind: 'room', seat: s, slotIdx: i }); }); + } + return out; + } + case 'tutorAdvanced': { + const seen = new Set(); + const out = []; + for (const c of [...state.decks.rooms, ...state.decks.roomDiscard]) { + const def = roomDef(c); + if (!def.advanced || def.type !== eff.roomType || seen.has(c.id)) continue; + seen.add(c.id); + for (let idx = 0; idx < p.dungeon.length; idx++) { + if (canBuildOver(state, seat, def, idx)) out.push({ kind: 'build', roomUid: c.uid, slotIdx: idx }); + } + } + return out; + } + case 'dmgBoostRoom': { + const out = []; + for (const s of aliveSeats(state)) { + P(state, s).dungeon.forEach((slot, i) => { + if (!slot.deactivated && roomDef(slot.room).type === eff.filter) out.push({ kind: 'room', seat: s, slotIdx: i }); + }); + } + return out; + } + case 'damageHero': case 'teleportHero': + return p.entrance.map((h) => ({ kind: 'hero', uid: h.uid })); + case 'healHero': { + const out = []; + for (const s of opps) for (const h of P(state, s).entrance) out.push({ kind: 'hero', uid: h.uid }); + return out; + } + case 'fearHero': { + const out = []; + for (const s of aliveSeats(state)) for (const h of P(state, s).entrance) out.push({ kind: 'hero', uid: h.uid }); + return out; + } + case 'killHeroTown': + return state.town.map((h) => ({ kind: 'hero', uid: h.uid })); + case 'lureHero': { + const out = state.town + .filter((h) => eff.filter !== 'ordinary' || !heroDef(h).epic) + .map((h) => ({ kind: 'hero', uid: h.uid })); + if (eff.from === 'townOrDeck') { + for (const h of [...state.decks.heroes, ...state.decks.epics]) out.push({ kind: 'deckHero', uid: h.uid }); + } + return out; + } + case 'resurrectAttack': { + const out = []; + for (const s of opps) for (const h of P(state, s).soulCards) out.push({ kind: 'soul', seat: s, uid: h.uid }); + return out; + } + case 'blockHeroes': + return opps.filter((s) => P(state, s).souls >= p.souls + 2).map((s) => ({ kind: 'player', seat: s })); + case 'sacrificeSoulDraw': + return p.soulCards.map((h) => ({ kind: 'soul', seat, uid: h.uid })); + case 'caveIn': case 'rampTrap': case 'dmgBoostAllRooms': + // caveIn: own room to collapse. rampTrap/Crushinator: the *other* own room + // to demolish (the sacrifice), never the activating room itself. + return p.dungeon.map((_, i) => ({ kind: 'room', seat, slotIdx: i })) + .filter((r) => !((eff.op === 'rampTrap' || eff.op === 'dmgBoostAllRooms') && r.slotIdx === ctx.slotIdx)); + default: + return null; + } +} + +// ── Decisions ─────────────────────────────────────────────────────────────── +export function pendingDecision(state) { + if (state.gameOver) return null; + const q = state.effectQueue[0]; + if (q) { + if (q.kind === 'discard') return { kind: 'discard', seat: q.seat, cardType: q.cardType, n: q.n }; + if (q.kind === 'roomDraw') return { kind: 'roomDraw', seat: q.seat }; + return { kind: 'target', seat: q.seat, op: q.eff.op, candidates: q.candidates, optional: !!q.eff.optional, source: q.source }; + } + if (state.reaction) { + const r = state.reaction; + return { kind: 'react', seat: r.responders[r.idx], spellId: r.spellId, casterSeat: r.casterSeat, target: r.targetRef || null }; + } + if (state.window) { + const seat = nextWindowSeat(state); + if (seat != null) return { kind: 'window', seat, window: state.window.id, advSeat: state.window.advSeat }; + return null; // advance() will close it + } + if (state.phase === 'setupDiscard') { + const seat = aliveSeats(state).find((s) => !P(state, s).setupDiscarded); + if (seat != null) return { kind: 'setupDiscard', seat, n: 2 }; + } + if (state.phase === 'setupBuild' || state.phase === 'build') { + const seat = aliveSeats(state).find((s) => P(state, s).pendingBuild === undefined); + if (seat != null) return { kind: 'build', seat, setup: state.phase === 'setupBuild' }; + } + if (state.phase === 'extraBuild') { + const seat = aliveSeats(state).find((s) => P(state, s).extraBuilds > 0); + if (seat != null) return { kind: 'build', seat, extra: true }; + } + return null; +} + +function nextWindowSeat(state) { + const w = state.window; + if (w.casts >= WINDOW_CAST_CAP) return null; + for (const seat of aliveSeats(state)) { + if (w.passes.includes(seat)) continue; + const acts = windowActions(state, seat); + if (!acts.spells.length && !acts.rooms.length) { w.passes.push(seat); continue; } + return seat; + } + return null; +} + +// ── Mutators ──────────────────────────────────────────────────────────────── +function assertSeat(state, seat, kind) { + const d = pendingDecision(state); + if (!d || d.seat !== seat || d.kind !== kind) { + throw new Error(`illegal action: expected ${d ? `${d.kind} by seat ${d.seat}` : 'none'}, got ${kind} by ${seat}`); + } + return d; +} + +export function actSetupDiscard(state, seat, uids) { + assertSeat(state, seat, 'setupDiscard'); + if (!Array.isArray(uids) || uids.length !== 2) throw new Error('setupDiscard needs exactly 2 uids'); + const p = P(state, seat); + for (const uid of uids) { + const c = removeFromHand(p, uid); + if (!c) throw new Error('setupDiscard: card not in hand'); + discardCard(state, c); + } + p.setupDiscarded = true; + emit(state, { type: 'setupDiscard', seat }); + advance(state); +} + +export function actBuild(state, seat, choice) { + const d = assertSeat(state, seat, 'build'); + const p = P(state, seat); + if (choice) { + const legal = legalBuilds(state, seat).some((b) => b.roomUid === choice.roomUid && b.slotIdx === choice.slotIdx); + if (!legal) throw new Error('illegal build'); + } else if (d.setup && legalBuilds(state, seat).length) { + throw new Error('setup build is mandatory'); + } + if (d.extra) { + p.extraBuilds--; + if (choice && !state.roundNoBuild) placeRoom(state, seat, choice.roomUid, choice.slotIdx); + } else { + p.pendingBuild = choice || null; + emit(state, { type: 'buildSubmitted', seat, pass: !choice }); + } + advance(state); +} + +export function actWindow(state, seat, action) { + assertSeat(state, seat, 'window'); + const w = state.window; + if (!action || action.pass) { + w.passes.push(seat); + emit(state, { type: 'windowPass', seat }); + advance(state); + return; + } + w.casts++; + w.passes = []; // an action reopens responses for everyone + if (action.spellUid != null) { + castSpell(state, seat, action.spellUid, action.target); + } else { + activateRoom(state, seat, action.slotIdx, action.effIdx, action); + } + advance(state); +} + +export function actReact(state, seat, response) { + assertSeat(state, seat, 'react'); + const r = state.reaction; + if (!response) { + r.idx++; + if (r.idx >= r.responders.length) resolveReaction(state, false); + } else { + const p = P(state, seat); + if (response.type === 'counterspell') { + const inst = p.hand.spells.find((c) => c.uid === response.spellUid && spellDef(c).op.op === 'counterSpell'); + if (!inst) throw new Error('no counterspell in hand'); + removeFromHand(p, inst.uid); + discardCard(state, inst); + } else if (response.type === 'allseeingeye') { + const slot = p.dungeon[response.slotIdx]; + const def = slot && roomDef(slot.room); + const effIdx = def && (def.effects || []).findIndex((e) => e.trigger === 'reaction'); + if (!slot || slot.deactivated || effIdx < 0 || slot.usedOnce[effIdx]) throw new Error('cannot react with that room'); + const cost = p.hand.spells.find((c) => c.uid === response.discardSpellUid); + if (!cost) throw new Error('reaction needs a spell to discard'); + removeFromHand(p, cost.uid); + discardCard(state, cost); + slot.usedOnce[effIdx] = true; + } else { + throw new Error('unknown reaction'); + } + emit(state, { type: 'spellCountered', seat, casterSeat: r.casterSeat, spellId: r.spellId }); + resolveReaction(state, true); + } + advance(state); +} + +export function actChooseTarget(state, seat, targetRef) { + const d = assertSeat(state, seat, 'target'); + const q = state.effectQueue[0]; + if (!targetRef) { + if (!d.optional) throw new Error('target is required'); + state.effectQueue.shift(); + emit(state, { type: 'fizzle', seat, op: q.eff.op }); + advance(state); + return; + } + const match = q.candidates.some((c) => sameRef(c, targetRef)); + if (!match) throw new Error('target not in candidates'); + state.effectQueue.shift(); + applyOp(state, seat, q.eff, targetRef, q.ctx || {}); + advance(state); +} + +export function actDiscard(state, seat, uids) { + const d = assertSeat(state, seat, 'discard'); + if (!Array.isArray(uids) || uids.length !== d.n) throw new Error(`discard needs exactly ${d.n} uids`); + const p = P(state, seat); + for (const uid of uids) { + const pool = d.cardType === 'spell' ? p.hand.spells : p.hand.rooms; + if (!pool.some((c) => c.uid === uid)) throw new Error('discard: card not in hand'); + const c = removeFromHand(p, uid); + discardCard(state, c); + emit(state, { type: 'discard', seat, id: c.id }); + } + state.effectQueue.shift(); + advance(state); +} + +export function actRoomDraw(state, seat, pick) { + assertSeat(state, seat, 'roomDraw'); + state.effectQueue.shift(); + if (pick === 'spell') drawSpells(state, seat, 1); + else drawRooms(state, seat, 1); + advance(state); +} + +function sameRef(a, b) { + return a.kind === b.kind && a.uid === b.uid && a.seat === b.seat + && a.slotIdx === b.slotIdx && a.roomUid === b.roomUid && a.a === b.a && a.b === b.b; +} + +// ── Spells & activated rooms ──────────────────────────────────────────────── +function castSpell(state, seat, spellUid, targetRef) { + const p = P(state, seat); + const inst = p.hand.spells.find((c) => c.uid === spellUid); + if (!inst) throw new Error('spell not in hand'); + const def = spellDef(inst); + if (!spellPhaseOk(def, state.window.id)) throw new Error('wrong phase for spell'); + const cands = opCandidates(state, seat, def.op, { source: 'spell' }); + if (cands !== null) { + if (!targetRef || !cands.some((c) => sameRef(c, targetRef))) throw new Error('bad spell target'); + } + removeFromHand(p, inst.uid); + emit(state, { type: 'spellCast', seat, id: inst.id, target: targetRef || null }); + // Liger's Den: once a round, when you cast a spell, draw a spell. + p.dungeon.forEach((slot) => { + if (slot.deactivated) return; + (roomDef(slot.room).effects || []).forEach((eff, e) => { + if (eff.trigger === 'onCastSpell' && !slot.usedOnce[e]) { slot.usedOnce[e] = true; drawSpells(state, seat, eff.n); } + }); + }); + const responders = reactionResponders(state, seat); + if (responders.length) { + state.reaction = { casterSeat: seat, spellUid: inst.uid, spellId: inst.id, inst, eff: def.op, targetRef, responders, idx: 0 }; + } else { + discardCard(state, inst); + applyOp(state, seat, def.op, targetRef, { source: 'spell' }); + } +} + +function reactionResponders(state, casterSeat) { + const out = []; + for (const seat of aliveSeats(state)) { + if (seat === casterSeat) continue; + const p = P(state, seat); + const hasCounter = p.hand.spells.some((c) => spellDef(c).op.op === 'counterSpell'); + const hasEye = p.dungeon.some((slot) => { + if (slot.deactivated) return false; + return (roomDef(slot.room).effects || []).some((e, i) => e.trigger === 'reaction' && !slot.usedOnce[i]); + }) && p.hand.spells.length > 0; + if (hasCounter || hasEye) out.push(seat); + } + return out; +} + +function resolveReaction(state, countered) { + const r = state.reaction; + state.reaction = null; + discardCard(state, r.inst); + if (!countered) applyOp(state, r.casterSeat, r.eff, r.targetRef, { source: 'spell' }); +} + +function activateRoom(state, seat, slotIdx, effIdx, action) { + const p = P(state, seat); + const slot = p.dungeon[slotIdx]; + if (!slot || slot.deactivated || slot.armed) throw new Error('cannot activate that room'); + const def = roomDef(slot.room); + const eff = (def.effects || [])[effIdx]; + if (!eff || eff.trigger !== 'activated') throw new Error('no such activated ability'); + if (!windowKindOk(eff.window, state.window.id)) throw new Error('wrong window'); + if (eff.oncePerTurn && slot.usedOnce[effIdx]) throw new Error('already used this round'); + if (!costPayable(state, seat, eff.cost, slotIdx)) throw new Error('cannot pay cost'); + const cands = opCandidates(state, seat, eff, { source: 'room', slotIdx }); + if (cands !== null && (!action.target || !cands.some((c) => sameRef(c, action.target)))) throw new Error('bad ability target'); + + emit(state, { type: 'roomActivated', seat, slotIdx, id: slot.room.id }); + if (eff.oncePerTurn) slot.usedOnce[effIdx] = true; + payCost(state, seat, eff.cost, slotIdx, action); + applyOp(state, seat, eff, action.target, { source: 'room', slotIdx, slot }); + // destroySelf sacrifices resolve after the effect so index-based targets stay + // valid. pitTrap keeps its room armed instead; it is destroyed on trigger (or + // at end of round if no hero ever enters). + if (eff.cost && eff.cost.destroySelf && eff.op !== 'pitTrap') destroySlotByRef(state, seat, slot); +} + +function payCost(state, seat, cost, slotIdx, action = {}) { + if (!cost) return; + const p = P(state, seat); + if (cost.discardRooms) { + const uids = action.costUids || p.hand.rooms.slice(0, cost.discardRooms).map((c) => c.uid); + if (uids.length !== cost.discardRooms) throw new Error('cost: wrong discard count'); + for (const uid of uids) { + const c = p.hand.rooms.find((x) => x.uid === uid); + if (!c) throw new Error('cost: room not in hand'); + removeFromHand(p, uid); discardCard(state, c); + } + } + if (cost.discardMonsterRoom) { + const uid = action.costUids ? action.costUids[0] : (p.hand.rooms.find((c) => roomDef(c).type === 'monster') || {}).uid; + const c = p.hand.rooms.find((x) => x.uid === uid && roomDef(x).type === 'monster'); + if (!c) throw new Error('cost: no monster room to discard'); + removeFromHand(p, uid); discardCard(state, c); + } + if (cost.discardSpell) { + const uid = action.costUids ? action.costUids[0] : (p.hand.spells[0] || {}).uid; + const c = p.hand.spells.find((x) => x.uid === uid); + if (!c) throw new Error('cost: no spell to discard'); + removeFromHand(p, uid); discardCard(state, c); + } + // destroySelf / destroyOther are handled inside the op (pitTrap, rampTrap, + // darkaltar etc.) so ordering with the effect is explicit. +} + +// ── Effect application ────────────────────────────────────────────────────── +function queueEffect(state, seat, eff, ctx = {}) { + const candidates = opCandidates(state, seat, eff, ctx); + if (candidates === null) { applyOp(state, seat, eff, null, ctx); return; } + if (!candidates.length) { emit(state, { type: 'fizzle', seat, op: eff.op }); return; } + if (eff.auto) { applyOp(state, seat, eff, autoPick(state, eff, candidates), ctx); return; } + state.effectQueue.push({ kind: 'target', seat, eff, candidates, ctx, source: ctx.source || null }); +} + +// Deterministic auto-pick for effects flagged `auto` (Open Grave): best room by damage. +function autoPick(state, eff, candidates) { + if (eff.op === 'recoverDiscard') { + let best = candidates[0]; let bestDmg = -1; + for (const c of candidates) { + const inst = state.decks.roomDiscard.find((x) => x.uid === c.uid); + const d = inst ? roomDef(inst).dmg : -1; + if (d > bestDmg) { bestDmg = d; best = c; } + } + return best; + } + return candidates[0]; +} + +function applyOp(state, seat, eff, targetRef, ctx = {}) { + const p = P(state, seat); + switch (eff.op) { + case 'drawRoom': drawRooms(state, seat, eff.n || 1); break; + case 'drawSpell': drawSpells(state, seat, eff.n || 1); break; + case 'drawThenDiscard': + if (eff.draw === 'spell') drawSpells(state, seat, eff.n); else drawRooms(state, seat, eff.n); + if ((eff.discard === 'spell' ? p.hand.spells : p.hand.rooms).length >= eff.d) { + state.effectQueue.push({ kind: 'discard', seat, cardType: eff.discard, n: eff.d }); + } + break; + case 'recoverDiscard': { + let inst = null; + let i = state.decks.roomDiscard.findIndex((c) => c.uid === targetRef.uid); + if (i >= 0) { inst = state.decks.roomDiscard.splice(i, 1)[0]; p.hand.rooms.push(inst); } + else { + i = state.decks.spellDiscard.findIndex((c) => c.uid === targetRef.uid); + if (i >= 0) { inst = state.decks.spellDiscard.splice(i, 1)[0]; p.hand.spells.push(inst); } + } + if (inst) emit(state, { type: 'recover', seat, id: inst.id }); + break; + } + case 'revealTake': { + const o = P(state, targetRef.seat); + const c = removeFromHand(o, targetRef.uid); + if (c) { + (ROOMS[c.id] ? p.hand.rooms : p.hand.spells).push(c); + emit(state, { type: 'cardTaken', seat, from: targetRef.seat, id: c.id }); + } + break; + } + case 'stealRandom': case 'opponentDiscardRandom': { + const o = P(state, targetRef.seat); + const filter = eff.filter || 'any'; + const pool = filter === 'spell' ? o.hand.spells : filter === 'room' ? o.hand.rooms : handAll(o); + if (!pool.length) break; + const c = state.rng.pick(pool); + removeFromHand(o, c.uid); + if (eff.op === 'stealRandom') { + (ROOMS[c.id] ? p.hand.rooms : p.hand.spells).push(c); + emit(state, { type: 'cardTaken', seat, from: targetRef.seat, id: c.id }); + } else { + discardCard(state, c); + emit(state, { type: 'discard', seat: targetRef.seat, id: c.id, forced: true }); + } + break; + } + case 'handReset': + for (const s of aliveSeats(state)) { + const o = P(state, s); + for (const c of handAll(o)) discardCard(state, c); + o.hand.rooms = []; o.hand.spells = []; + drawSpells(state, s, 1); drawRooms(state, s, 2); + } + emit(state, { type: 'handReset' }); + break; + case 'wreckEachOpponent': + for (const s of state.turnOrder) { + if (s === seat || !P(state, s).alive || !P(state, s).dungeon.length) continue; + state.effectQueue.push({ + kind: 'target', seat: s, eff: { op: 'destroyOwnRoom' }, + candidates: opCandidates(state, s, { op: 'destroyOwnRoom' }), ctx: {}, + }); + } + break; + case 'destroyOwnRoom': + destroySlot(state, targetRef.seat, targetRef.slotIdx); + break; + case 'swapRooms': { + const d = P(state, targetRef.seat).dungeon; + [d[targetRef.a], d[targetRef.b]] = [d[targetRef.b], d[targetRef.a]]; + emit(state, { type: 'roomsSwapped', seat: targetRef.seat, a: targetRef.a, b: targetRef.b }); + break; + } + case 'extraBuild': + p.extraBuilds++; + break; + case 'noBuildRound': + state.roundNoBuild = true; + emit(state, { type: 'noBuild' }); + break; + case 'deactivateRoom': { + const slot = P(state, targetRef.seat).dungeon[targetRef.slotIdx]; + slot.deactivated = true; + emit(state, { type: 'roomFrozen', seat: targetRef.seat, slotIdx: targetRef.slotIdx }); + break; + } + case 'tutorAdvanced': { + let i = state.decks.rooms.findIndex((c) => c.uid === targetRef.roomUid); + let inst; + if (i >= 0) inst = state.decks.rooms.splice(i, 1)[0]; + else { + i = state.decks.roomDiscard.findIndex((c) => c.uid === targetRef.roomUid); + inst = state.decks.roomDiscard.splice(i, 1)[0]; + } + p.hand.rooms.push(inst); + placeRoom(state, seat, inst.uid, targetRef.slotIdx); + break; + } + case 'dmgBoostRoom': { + const slot = P(state, targetRef.seat).dungeon[targetRef.slotIdx]; + slot.tempDmg += eff.amount; + emit(state, { type: 'roomBoosted', seat: targetRef.seat, slotIdx: targetRef.slotIdx, amount: eff.amount }); + break; + } + case 'dmgBoostAllRooms': + destroySlot(state, seat, targetRef.slotIdx); // the sacrificed room + p.roundMods.allBoost += eff.amount; + emit(state, { type: 'allRoomsBoosted', seat, amount: eff.amount }); + break; + case 'doubleTreasure': + p.roundMods.doubleTreasure = true; + emit(state, { type: 'treasureDoubled', seat }); + break; + case 'damageHero': { + const found = findEntranceHero(state, targetRef.uid); + if (!found) break; + const amount = eff.amount === 'dungeonSize' ? p.dungeon.length : eff.amount; + found.hero.hp -= amount; + emit(state, { type: 'heroHurt', uid: found.hero.uid, amount, seat: found.seat }); + if (found.hero.hp <= 0) scoreHeroKill(state, seat, found.hero, { removeFromSeat: found.seat, noRoom: true }); + break; + } + case 'healHero': { + const found = findEntranceHero(state, targetRef.uid); + if (found) { found.hero.hp += eff.amount; emit(state, { type: 'heroHealed', uid: found.hero.uid, amount: eff.amount }); } + break; + } + case 'fearHero': { + const found = findEntranceHero(state, targetRef.uid); + if (found) { + const q = P(state, found.seat).entrance; + q.splice(q.findIndex((h) => h.uid === found.hero.uid), 1); + state.town.push(found.hero); + emit(state, { type: 'heroFeared', uid: found.hero.uid, from: found.seat }); + } + break; + } + case 'teleportHero': { + const found = findEntranceHero(state, targetRef.uid); + if (found) { found.hero.teleport = true; emit(state, { type: 'heroTeleportMarked', uid: found.hero.uid }); } + break; + } + case 'killHeroTown': { + const i = state.town.findIndex((h) => h.uid === targetRef.uid); + if (i >= 0) { + const hero = state.town.splice(i, 1)[0]; + scoreHeroKill(state, seat, hero, { noRoom: true }); + } + break; + } + case 'lureHero': { + let hero = null; + let i = state.town.findIndex((h) => h.uid === targetRef.uid); + if (i >= 0) hero = state.town.splice(i, 1)[0]; + else { + for (const deck of [state.decks.heroes, state.decks.epics]) { + i = deck.findIndex((h) => h.uid === targetRef.uid); + if (i >= 0) { hero = deck.splice(i, 1)[0]; break; } + } + } + if (hero) { p.entrance.push(hero); emit(state, { type: 'heroLured', seat, uid: hero.uid, id: hero.id }); } + break; + } + case 'resurrectAttack': { + const o = P(state, targetRef.seat); + const i = o.soulCards.findIndex((h) => h.uid === targetRef.uid); + if (i < 0) break; + const hero = o.soulCards.splice(i, 1)[0]; + o.souls -= heroSouls(heroDef(hero)); + hero.hp = hero.hpMax + (eff.hpBonus || 0); + o.entrance.push(hero); + emit(state, { type: 'heroResurrected', seat: targetRef.seat, uid: hero.uid, id: hero.id }); + break; + } + case 'blockHeroes': + P(state, targetRef.seat).roundMods.blocked = true; + emit(state, { type: 'heroesBlocked', seat: targetRef.seat }); + break; + case 'sacrificeSoulDraw': { + const i = p.soulCards.findIndex((h) => h.uid === targetRef.uid); + if (i < 0) break; + const hero = p.soulCards.splice(i, 1)[0]; + p.souls -= heroSouls(heroDef(hero)); + drawSpells(state, seat, eff.n); + emit(state, { type: 'soulBurned', seat, id: hero.id }); + break; + } + case 'healWound': + // Tabletop flips the wound into a soul; we simply heal it. + if (p.wounds > 0) { p.wounds--; emit(state, { type: 'woundHealed', seat }); } + break; + case 'pitTrap': { + const slot = ctx.slot; + slot.armed = { type: 'pit' }; + emit(state, { type: 'trapArmed', seat, kind: 'pit' }); + break; + } + case 'rampTrap': { + destroySlot(state, seat, targetRef.slotIdx); + const slot = ctx.slot; // ramp itself (survives; the target was the sacrifice) + if (P(state, seat).dungeon.includes(slot)) { + slot.armed = { type: 'ramp', bonus: 5 }; + emit(state, { type: 'trapArmed', seat, kind: 'ramp' }); + } + break; + } + case 'caveIn': { + const slot = P(state, seat).dungeon[targetRef.slotIdx]; + if (slot) { slot.armed = { type: 'cavein' }; emit(state, { type: 'trapArmed', seat, kind: 'cavein' }); } + break; + } + case 'counterSpell': + break; // handled in the reaction flow + default: + throw new Error(`unknown op ${eff.op}`); + } +} + +function findEntranceHero(state, uid) { + for (const seat of aliveSeats(state)) { + const hero = P(state, seat).entrance.find((h) => h.uid === uid); + if (hero) return { seat, hero }; + } + return null; +} + +function destroySlotByRef(state, seat, slot) { + if (!slot) return; + const idx = P(state, seat).dungeon.indexOf(slot); + if (idx >= 0) destroySlot(state, seat, idx); +} + +function destroySlot(state, seat, slotIdx) { + const p = P(state, seat); + const slot = p.dungeon[slotIdx]; + if (!slot) return; + p.dungeon.splice(slotIdx, 1); + discardCard(state, slot.room); + for (const c of slot.under) discardCard(state, c); + emit(state, { type: 'roomDestroyed', seat, slotIdx, id: slot.room.id }); + // Recycling Center: whenever ANOTHER of your rooms is destroyed, draw 2 rooms. + for (const s of p.dungeon) { + if (s.deactivated) continue; + (roomDef(s.room).effects || []).forEach((eff) => { + if (eff.trigger === 'onOwnRoomDestroyed') drawRooms(state, seat, eff.n); + }); + } +} + +// ── Build resolution ──────────────────────────────────────────────────────── +function placeRoom(state, seat, roomUid, slotIdx, { setup = false } = {}) { + const p = P(state, seat); + const inst = p.hand.rooms.find((c) => c.uid === roomUid); + removeFromHand(p, roomUid); + const def = roomDef(inst); + if (slotIdx >= p.dungeon.length) { + p.dungeon.push(newSlot(inst)); + } else { + const slot = p.dungeon[slotIdx]; + slot.under.push(slot.room); + slot.room = inst; + slot.deactivated = false; + slot.usedOnce = {}; + slot.tempDmg = 0; + slot.armed = null; + } + emit(state, { type: 'roomBuilt', seat, slotIdx: Math.min(slotIdx, p.dungeon.length - 1), id: inst.id, advanced: !!def.advanced }); + // Own Beast Menagerie: once a round, when you build another monster room, draw. + if (def.type === 'monster') { + p.dungeon.forEach((slot) => { + if (slot.deactivated || slot.room.uid === inst.uid) return; + (roomDef(slot.room).effects || []).forEach((eff, e) => { + if (eff.trigger === 'onBuildOtherMonster' && !slot.usedOnce[e]) { slot.usedOnce[e] = true; drawRooms(state, seat, eff.n); } + }); + }); + } + // onBuild effects of the room itself (suppressed for the simultaneous setup + // build so round 1 starts from a clean slate). + if (!setup) { + for (const eff of (def.effects || [])) { + if (eff.trigger === 'onBuild') queueEffect(state, seat, eff, { source: 'room', slotIdx }); + } + } + // Level up on reaching the 5-room maximum, once per game. + if (p.dungeon.length === MAX_ROOMS && !p.boss.leveledUp) { + p.boss.leveledUp = true; + emit(state, { type: 'levelUp', seat, boss: p.boss.id }); + queueEffect(state, seat, { ...BOSSES[p.boss.id].levelUp, optional: true }, { source: 'levelUp' }); + } +} + +function flipBuilds(state, { setup = false } = {}) { + for (const seat of state.turnOrder) { + const p = P(state, seat); + if (!p.alive) continue; + const b = p.pendingBuild; + p.pendingBuild = undefined; + if (!b) { emit(state, { type: 'flip', seat, pass: true }); continue; } + if (state.roundNoBuild) { emit(state, { type: 'flip', seat, cancelled: true }); continue; } // card stays in hand + // Re-validate: an earlier flip/effect may have invalidated the placement. + const stillLegal = legalBuilds(state, seat).some((x) => x.roomUid === b.roomUid && x.slotIdx === b.slotIdx); + if (!stillLegal) { emit(state, { type: 'flip', seat, cancelled: true }); continue; } + emit(state, { type: 'flip', seat }); + placeRoom(state, seat, b.roomUid, b.slotIdx, { setup }); + } +} + +// ── Bait ──────────────────────────────────────────────────────────────────── +export function baitTargets(state) { + const out = {}; + for (const hero of state.town) { + const def = heroDef(hero); + const eligible = aliveSeats(state).filter((s) => !P(state, s).roundMods.blocked); + let best = null; + if (def.fool) { + // The Fool storms the dungeon of the player with the FEWEST souls. + let min = Infinity; let ties = 0; + for (const s of eligible) { + const v = P(state, s).souls; + if (v < min) { min = v; best = s; ties = 1; } else if (v === min) ties++; + } + if (ties > 1) best = null; + } else { + let max = 0; let ties = 0; + for (const s of eligible) { + const v = treasureCount(state, s, def.cls); + if (v > max) { max = v; best = s; ties = 1; } else if (v === max && v > 0) ties++; + } + if (ties > 1) best = null; + } + out[hero.uid] = best; + } + return out; +} + +function runBait(state) { + const targets = baitTargets(state); + const staying = []; + for (const hero of state.town) { + const seat = targets[hero.uid]; + if (seat == null) { staying.push(hero); continue; } + P(state, seat).entrance.push(hero); + emit(state, { type: 'heroWalks', uid: hero.uid, id: hero.id, seat }); + } + state.town = staying; +} + +// ── Adventure ─────────────────────────────────────────────────────────────── +function startAdventure(state) { + state.phase = 'adventure'; + state.adv = { orderIdx: -1, walking: null, windowOpened: false }; + nextAdventureSeat(state); +} + +function nextAdventureSeat(state) { + if (state.gameOver || !state.adv) return; + const a = state.adv; + a.walking = null; + a.windowOpened = false; + while (true) { + a.orderIdx++; + if (a.orderIdx >= state.turnOrder.length) { state.adv = null; endRound(state); return; } + const seat = state.turnOrder[a.orderIdx]; + if (P(state, seat).alive && P(state, seat).entrance.length) { + state.window = { id: 'advStart', advSeat: seat, passes: [], casts: 0 }; + a.windowOpened = true; + emit(state, { type: 'adventureStart', seat }); + return; + } + } +} + +// Walk heroes one room-step at a time; pauses whenever effectQueue gains a decision. +function stepAdventure(state) { + const a = state.adv; + const seat = state.turnOrder[a.orderIdx]; + const p = P(state, seat); + while (true) { + if (state.effectQueue.length || state.gameOver) return; + if (!a.walking) { + if (!p.entrance.length || !p.alive) { nextAdventureSeat(state); return; } + const hero = p.entrance.shift(); + a.walking = { hero, roomIdx: p.dungeon.length - 1, bounced: false, secondPass: false }; + emit(state, { type: 'heroEnters', seat, uid: hero.uid, id: hero.id }); + } + const w = a.walking; + const hero = w.hero; + if (w.roomIdx < 0) { + if (hero.teleport && !w.secondPass) { + hero.teleport = false; + w.roomIdx = p.dungeon.length - 1; + w.secondPass = true; + w.bounced = false; + emit(state, { type: 'heroTeleported', seat, uid: hero.uid }); + continue; + } + // Survived: the boss takes wounds. + const wounds = heroWounds(heroDef(hero)); + p.wounds += wounds; + emit(state, { type: 'bossWounded', seat, uid: hero.uid, id: hero.id, wounds, total: p.wounds }); + a.walking = null; + if (p.wounds >= WOUNDS_TO_DIE) { eliminate(state, seat); nextAdventureSeat(state); return; } + continue; + } + const slot = p.dungeon[w.roomIdx]; + if (!slot) { w.roomIdx = Math.min(w.roomIdx - 1, p.dungeon.length - 1); continue; } + if (slot.deactivated) { w.roomIdx--; continue; } + const def = roomDef(slot.room); + // Armed traps resolve on entry. + if (slot.armed) { + const armed = slot.armed; + slot.armed = null; + if (armed.type === 'pit' || armed.type === 'cavein') { + const idx = p.dungeon.indexOf(slot); + destroySlot(state, seat, idx); + scoreHeroKill(state, seat, hero, { slot: null }); + a.walking = null; + continue; + } + if (armed.type === 'ramp') { + hero.hp -= armed.bonus; + emit(state, { type: 'heroHurt', uid: hero.uid, amount: armed.bonus, seat }); + if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; } + } + } + // Minotaur's Maze: the first hero through each round re-enters the room it + // just left (takes its damage again), then continues. + if (def.passive === 'minotaurBounce' && !slot.usedOnce.bounce && !w.bounced && w.roomIdx < p.dungeon.length - 1) { + slot.usedOnce.bounce = true; + w.bounced = true; + const backIdx = w.roomIdx + 1; + const dmg = roomDamage(state, seat, backIdx); + if (dmg > 0) { + hero.hp -= dmg; + emit(state, { type: 'roomHits', seat, slotIdx: backIdx, uid: hero.uid, amount: dmg, bounce: true }); + if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot: p.dungeon[backIdx] }); a.walking = null; continue; } + } + } + const dmg = roomDamage(state, seat, w.roomIdx); + if (dmg > 0) { + hero.hp -= dmg; + emit(state, { type: 'roomHits', seat, slotIdx: w.roomIdx, uid: hero.uid, amount: dmg }); + if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; } + } + w.roomIdx--; + } +} + +function scoreHeroKill(state, seat, hero, { slot = null, removeFromSeat = null, noRoom = false } = {}) { + if (removeFromSeat != null) { + const q = P(state, removeFromSeat).entrance; + const i = q.findIndex((h) => h.uid === hero.uid); + if (i >= 0) q.splice(i, 1); + } + const p = P(state, seat); + const def = heroDef(hero); + p.souls += heroSouls(def); + p.soulCards.push(hero); + emit(state, { type: 'heroDies', seat, uid: hero.uid, id: hero.id, souls: heroSouls(def), total: p.souls }); + if (!noRoom && slot && !slot.deactivated) { + const rdef = roomDef(slot.room); + (rdef.effects || []).forEach((eff, e) => { + if (eff.trigger !== 'onHeroDieHere' || slot.usedOnce[e]) return; + slot.usedOnce[e] = true; + queueEffect(state, seat, eff, { source: 'room' }); + }); + } +} + +function eliminate(state, seat) { + const p = P(state, seat); + p.alive = false; + for (const c of handAll(p)) discardCard(state, c); + p.hand.rooms = []; p.hand.spells = []; + for (const slot of p.dungeon) { discardCard(state, slot.room); for (const c of slot.under) discardCard(state, c); } + p.dungeon = []; + p.entrance = []; + p.pendingBuild = undefined; + p.extraBuilds = 0; + emit(state, { type: 'eliminated', seat }); + state.effectQueue = state.effectQueue.filter((q) => q.seat !== seat); + const alive = aliveSeats(state); + if (alive.length === 1) endGame(state, alive[0], 'lastStanding'); +} + +// ── Round flow ────────────────────────────────────────────────────────────── +function startRound(state) { + state.round++; + state.roundNoBuild = false; + for (const seat of aliveSeats(state)) { + const p = P(state, seat); + p.roundMods = freshMods(); + p.extraBuilds = 0; + for (const slot of p.dungeon) { + slot.deactivated = false; + slot.usedOnce = {}; + slot.tempDmg = 0; + if (slot.armed) slot.armed = null; + } + } + emit(state, { type: 'roundStart', round: state.round }); + // Reveal one hero per (living) player. + let exhausted = false; + for (let i = 0; i < aliveSeats(state).length; i++) { + let deck = state.decks.heroes; + if (!deck.length) { state.epicsActive = true; deck = state.decks.epics; } + if (!deck.length) { exhausted = true; break; } + const hero = deck.pop(); + state.town.push(hero); + emit(state, { type: 'heroRevealed', uid: hero.uid, id: hero.id, epic: heroDef(hero).epic }); + } + if (exhausted && !state.town.length) { + // Decks empty and nobody left to fight: highest souls wins now. + endGame(state, bestBySouls(state), 'decksExhausted'); + return; + } + // Draws — Haunted Library owners choose room-or-spell. + for (const seat of aliveSeats(state)) { + const p = P(state, seat); + const hasLibrary = p.dungeon.some((s) => !s.deactivated && roomDef(s.room).passive === 'librarySpellDraw'); + if (hasLibrary) state.effectQueue.push({ kind: 'roomDraw', seat }); + else drawRooms(state, seat, 1); + } + state.phase = 'roundStart'; +} + +function endRound(state) { + if (state.gameOver) return; + // Untriggered armed pits/cave-ins still collapse at end of round. + for (const seat of aliveSeats(state)) { + const p = P(state, seat); + for (let i = p.dungeon.length - 1; i >= 0; i--) { + const armed = p.dungeon[i].armed; + if (armed && (armed.type === 'pit' || armed.type === 'cavein')) destroySlot(state, seat, i); + } + } + const winners = aliveSeats(state).filter((s) => P(state, s).souls >= SOULS_TO_WIN); + if (winners.length) { + // Most souls; ties go to the LOWEST-XP boss. + winners.sort((a, b) => (P(state, b).souls - P(state, a).souls) + || (BOSSES[P(state, a).boss.id].xp - BOSSES[P(state, b).boss.id].xp)); + endGame(state, winners[0], 'souls'); + return; + } + emit(state, { type: 'roundEnd', round: state.round }); + startRound(state); +} + +function bestBySouls(state) { + const alive = aliveSeats(state); + alive.sort((a, b) => (P(state, b).souls - P(state, a).souls) + || (BOSSES[P(state, a).boss.id].xp - BOSSES[P(state, b).boss.id].xp)); + return alive[0]; +} + +function endGame(state, winner, reason) { + state.gameOver = true; + state.winner = winner; + state.phase = 'gameover'; + state.window = null; + state.reaction = null; + state.effectQueue = []; + state.adv = null; + emit(state, { type: 'gameOver', winner, reason }); +} + +export function isOver(state) { return state.gameOver; } + +export function finalRanking(state) { + return state.players.slice() + .sort((a, b) => (b.alive - a.alive) || (b.souls - a.souls) + || (BOSSES[a.boss.id].xp - BOSSES[b.boss.id].xp)) + .map((p) => p.seat); +} + +// ── The advance loop ──────────────────────────────────────────────────────── +function advance(state) { + let guard = 0; + while (!state.gameOver) { + if (++guard > 10000) throw new Error('advance() ran away'); + if (state.effectQueue.length || state.reaction) return; // decision needed + if (state.window) { + const seat = nextWindowSeat(state); + if (seat != null) return; + const closed = state.window; + state.window = null; + if (closed.id === 'buildStart') { state.phase = 'build'; continue; } + if (closed.id === 'postFlip') { runBait(state); startAdventure(state); continue; } + if (closed.id === 'advStart') { stepAdventure(state); continue; } + } + if (state.adv && state.adv.windowOpened && !state.window) { + // resumed mid-walk after an effect decision + stepAdventure(state); + if (state.effectQueue.length || state.window || state.gameOver) continue; + if (!state.adv) continue; + return; // shouldn't happen; stepAdventure always progresses + } + switch (state.phase) { + case 'setupDiscard': + if (aliveSeats(state).every((s) => P(state, s).setupDiscarded)) { state.phase = 'setupBuild'; continue; } + return; + case 'setupBuild': + if (aliveSeats(state).every((s) => P(state, s).pendingBuild !== undefined)) { + flipBuilds(state, { setup: true }); + state.setupBuilt = true; + startRound(state); + continue; + } + return; + case 'roundStart': + state.window = { id: 'buildStart', passes: [], casts: 0 }; + state.phase = 'windowBuildStart'; + continue; + case 'windowBuildStart': + // window handled above; when closed phase becomes 'build' + if (!state.window) { state.phase = 'build'; continue; } + return; + case 'build': + if (aliveSeats(state).every((s) => P(state, s).pendingBuild !== undefined)) { + flipBuilds(state); + state.phase = 'extraBuild'; + continue; + } + return; + case 'extraBuild': + if (state.roundNoBuild) { for (const s of aliveSeats(state)) P(state, s).extraBuilds = 0; } + if (aliveSeats(state).some((s) => P(state, s).extraBuilds > 0)) return; + state.window = { id: 'postFlip', passes: [], casts: 0 }; + state.phase = 'windowPostFlip'; + continue; + case 'windowPostFlip': + if (!state.window) { runBait(state); startAdventure(state); continue; } + return; + case 'adventure': + if (!state.window && state.adv) { stepAdventure(state); continue; } + return; + default: + return; + } + } +} + +// ── Public (redacted) view for the AI ─────────────────────────────────────── +// Own hand stays visible; opponents' hands and pending builds are hidden. +export function publicView(state, seat) { + return { + seed: state.seed, nPlayers: state.nPlayers, round: state.round, + phase: state.phase, window: state.window, turnOrder: state.turnOrder, + epicsActive: state.epicsActive, + // Deck contents are hidden (stubs keep lengths honest); discards are public. + decks: { + rooms: state.decks.rooms.map(() => ({ uid: -1, id: null, hidden: true })), + spells: state.decks.spells.map(() => ({ uid: -1, id: null, hidden: true })), + heroes: state.decks.heroes.map(() => ({ uid: -1, id: null, hidden: true })), + epics: state.decks.epics.map(() => ({ uid: -1, id: null, hidden: true })), + roomDiscard: state.decks.roomDiscard, + spellDiscard: state.decks.spellDiscard, + }, + roomDiscard: state.decks.roomDiscard, spellDiscard: state.decks.spellDiscard, + town: state.town, + players: state.players.map((p) => (p.seat === seat ? p : { + seat: p.seat, alive: p.alive, boss: p.boss, dungeon: p.dungeon, + // Hidden cards become stubs so shared helpers can still count them. + hand: { + rooms: p.hand.rooms.map(() => ({ uid: -1, id: null, hidden: true })), + spells: p.hand.spells.map(() => ({ uid: -1, id: null, hidden: true })), + }, + entrance: p.entrance, souls: p.souls, soulCards: p.soulCards, + wounds: p.wounds, pendingBuild: p.pendingBuild === undefined ? undefined : p.pendingBuild !== null, + extraBuilds: p.extraBuilds, roundMods: p.roundMods, + })), + gameOver: state.gameOver, winner: state.winner, + }; +} diff --git a/src/games/dungeonboss/sprites.md b/src/games/dungeonboss/sprites.md new file mode 100644 index 0000000..9b9c11f --- /dev/null +++ b/src/games/dungeonboss/sprites.md @@ -0,0 +1,252 @@ +# Dungeon Boss — Sprite / Art Spec + +Everything in Dungeon Boss renders procedurally out of the box (frame, border, +class-colored accents, name, rules text, treasure icons), so the game is fully +playable with **no** art. This document lists the optional sprite sheets you +can drop in to replace the placeholder art windows, with exact dimensions, +layout, and frame-by-frame maps. + +All art is wired through **`public/data/dungeonboss-artwork.json`**. The scene +checks whether each sheet's texture is loaded; if it is, it uses your frame, +otherwise it falls back to procedural drawing. You never need to touch code — +just add a PNG, set its `path` in that JSON (all four are currently empty), +and reload. + +Frame numbering is **row-major, 0-based**: frame 0 is top-left, count +left-to-right then down to the next row. + +Only paint the illustration — the card frame, border, name plate, cost/damage +badges, treasure-class icons, and rules text are all drawn procedurally on top +of your art. Art is scaled to **fit** the art window (no cropping), so match +the aspect ratio below for edge-to-edge fill; other aspects letterbox inside +the window. + +--- + +## 1. Room sheet — `dungeonboss-rooms.png` + +| | | +|---|---| +| **Path** | `public/assets/images/dungeonboss-rooms.png` | +| **Frame size** | **300 × 200 px** (aspect 1.5 : 1, landscape — matches the room art window) | +| **Sheet size** | **6 columns × 6 rows = 1800 × 1200 px** holds all 31 frames (5 spare) | +| **Status** | ⛔ Procedural until you create it (then set `roomSheet.path` in the JSON) | +| **JSON** | `roomSheet` (set `path`) + `rooms` map (already filled in for all 31 ids) | + +Rooms tile into each player's dungeon wing (shown small, ~90×60 to ~168×112 on +the board) and larger in the reward-row/inspect views (up to ~380×260). Two +room **types** exist — `monster` (a creature guards the room) and `trap` (an +environmental hazard) — lean into that flavor per room below. + +### Frame map + +| Frame | id | Name | Type | Advanced | Notes | +|---:|---|---|---|---|---| +| 0 | `darkaltar` | Dark Altar | trap | | cleric treasure | +| 1 | `opengrave` | Open Grave | trap | | cleric treasure | +| 2 | `specterssanctum` | Specter's Sanctum | monster | | cleric treasure | +| 3 | `succubusspa` | Succubus Spa | monster | | cleric treasure | +| 4 | `dracolichlair` | Dracolich Lair | monster | advanced | cleric treasure | +| 5 | `vampirebordello` | Vampire Bordello | monster | advanced | cleric treasure | +| 6 | `goblinarmory` | Goblin Armory | monster | | fighter treasure | +| 7 | `golemfactory` | Golem Factory | monster | | fighter treasure | +| 8 | `minotaursmaze` | Minotaur's Maze | monster | | fighter treasure | +| 9 | `neanderthalcave` | Neanderthal Cave | monster | | fighter treasure | +| 10 | `beastmenagerie` | Beast Menagerie | monster | advanced | fighter treasure | +| 11 | `monstersballroom` | Monster's Ballroom | monster | advanced | fighter treasure | +| 12 | `brainsuckerhive` | Brainsucker Hive | monster | | mage treasure | +| 13 | `darklaboratory` | Dark Laboratory | trap | | mage treasure | +| 14 | `hauntedlibrary` | Haunted Library | trap | | mage treasure | +| 15 | `witchskitchen` | Witch's Kitchen | monster | | mage treasure | +| 16 | `allseeingeye` | All-Seeing Eye | trap | advanced | mage treasure | +| 17 | `ligersden` | Liger's Den | monster | advanced | mage treasure | +| 18 | `bottomlesspit` | Bottomless Pit | trap | | thief treasure | +| 19 | `boulderramp` | Boulder Ramp | trap | | thief treasure | +| 20 | `dizzygashallway` | Dizzygas Hallway | trap | | thief treasure | +| 21 | `jackpotstash` | Jackpot Stash | trap | | thief treasure | +| 22 | `recyclingcenter` | Recycling Center | trap | advanced | thief treasure | +| 23 | `thecrushinator` | The Crushinator | trap | advanced | thief treasure | +| 24 | `centipedetunnel` | Centipede Tunnel | monster | | fighter+mage treasure | +| 25 | `constructionzone` | Construction Zone | trap | | fighter+thief treasure | +| 26 | `dragonhatchery` | Dragon Hatchery | monster | | all-class treasure | +| 27 | `mimicvault` | Mimic Vault | trap | | mage+thief treasure | +| 28 | `monstrousmonument` | Monstrous Monument | trap | | cleric+fighter treasure | +| 29 | `torturechamber` | Torture Chamber | trap | | cleric+thief treasure | +| 30 | `zombieprison` | Zombie Prison | monster | | cleric+mage treasure | + +> Priority: rooms 0–17 (Cleric/Fighter/Mage wings) show up most often; the +> multi-class rooms (24–30) and advanced rooms (4, 5, 10, 11, 16, 17, 22, 23) +> are lower frequency but higher visual payoff (bigger/scarier). + +--- + +## 2. Spell sheet — `dungeonboss-spells.png` + +| | | +|---|---| +| **Path** | `public/assets/images/dungeonboss-spells.png` | +| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the spell art window) | +| **Sheet size** | **4 columns × 4 rows = 880 × 1200 px** holds all 16 frames exactly | +| **Status** | ⛔ Procedural until you create it (then set `spellSheet.path` in the JSON) | +| **JSON** | `spellSheet` (set `path`) + `spells` map (already filled in for all 16 ids) | + +Spells are shown in-hand at small size and up to ~280×236 in the play/reveal +zoom. These are one-shot magical effects — go for a punchy central icon/scene +(a rune, a burst of energy, a monster silhouette) rather than a busy tableau. + +### Frame map + +| Frame | id | Name | Effect flavor | +|---:|---|---|---| +| 0 | `annihilator` | Annihilator | destructive burst | +| 1 | `assassin` | Assassin | targeted kill/damage | +| 2 | `cavein` | Cave In | collapsing rock/trap | +| 3 | `counterspell` | Counterspell | magical negation | +| 4 | `exhaustion` | Exhaustion | draining/weakening | +| 5 | `fear` | Fear | terror/scare effect | +| 6 | `freeze` | Freeze | ice/stasis | +| 7 | `giantsize` | Giant Size | growth/enlarge | +| 8 | `jeopardy` | Jeopardy | risk/gambling theme | +| 9 | `koboldstrike` | Kobold Strike | small creature ambush | +| 10 | `motivation` | Motivation | rallying/buff | +| 11 | `princessinperil` | Princess in Peril | damsel/rescue-bait theme | +| 12 | `soulharvest` | Soul Harvest | dark reaping magic | +| 13 | `teleportation` | Teleportation | portal/warp | +| 14 | `trepidation` | Trepidation | dread/hesitation | +| 15 | `zombieattack` | Zombie Attack | undead horde | + +--- + +## 3. Hero sheet — `dungeonboss-heroes.png` + +| | | +|---|---| +| **Path** | `public/assets/images/dungeonboss-heroes.png` | +| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the hero art window) | +| **Sheet size** | **7 columns × 6 rows = 1540 × 1800 px** holds all 41 frames (1 spare) | +| **Status** | ⛔ Procedural until you create it (then set `heroSheet.path` in the JSON) | +| **JSON** | `heroSheet` (set `path`) + `heroes` map (already filled in for all 41 ids) | + +Heroes are the townsfolk marching toward the dungeons — shown small on the +town track (~106×148) and up to ~280×390 in the inspect view. Class color +coding: **Fighter** red, **Mage** blue, **Thief** gold, **Cleric** teal (see +`CLASS_INFO` in `DungeonBossData.js` for exact hex). **Epic** heroes (higher +HP, named/unique-flavor) should read as visually grander than ordinary ones. + +### Frame map + +Class legend: **F** = Fighter, **M** = Mage, **T** = Thief, **C** = Cleric. + +| Frame | id | Name | Class | HP | Epic | +|---:|---|---|---|---:|---| +| 0 | `nick` | Nick the Masher | C | 4 | | +| 1 | `pugi` | Pugi the Druidess | C | 4 | | +| 2 | `acacia` | Acacia, Warrior of Light | C | 6 | | +| 3 | `charles` | Charles the Young | C | 6 | | +| 4 | `delatorious` | Delatorious | C | 8 | | +| 5 | `romero` | Romero, the Indigo Friar | C | 8 | | +| 6 | `boden` | Boden the Pantless | F | 4 | | +| 7 | `jarek` | Jarek the Squire | F | 4 | | +| 8 | `firesbreath` | Fire's Breath | F | 6 | | +| 9 | `samuraitom` | Samurai Tom | F | 6 | | +| 10 | `johnny` | Johnny of the Evening Watch | F | 8 | | +| 11 | `crystolalan` | Crystol & A'lan of Gerd | F | 8 | | +| 12 | `tieg` | Tieg and the Magic Bubble | M | 4 | | +| 13 | `brandork` | Brandork the Neverwrong | M | 4 | | +| 14 | `mitchell` | Mitchell, the Judge | M | 6 | | +| 15 | `kalish` | Kalish Ninefingers | M | 6 | | +| 16 | `dartteon` | Dartteon, Elf Pyromancer | M | 8 | | +| 17 | `koey` | Koey, the Last Dragon Mage | M | 8 | | +| 18 | `joman` | Joman Chimm, Cutpurse | T | 4 | | +| 19 | `lance` | Lance Uppercut | T | 4 | | +| 20 | `jesta` | Jesta the Rogue | T | 6 | | +| 21 | `kins` | Kins Klauski | T | 6 | | +| 22 | `digby` | Sir Digby Apple | T | 8 | | +| 23 | `jerome` | Jerome, Kung Fu Monkey | T | 8 | | +| 24 | `fool` | The Fool | — | 2 | jester, no class colors | +| 25 | `katelyn` | Katelyn, Angelic Healer | C | 11 | ✅ | +| 26 | `kerberos` | Kerberos Dirtbeard | C | 11 | ✅ | +| 27 | `vanette` | Lord Van Ette | C | 13 | ✅ | +| 28 | `jejune` | Jejune & Everlea | C | 13 | ✅ | +| 29 | `frankov` | Frankov, the Envoy | F | 11 | ✅ | +| 30 | `nate` | Nate the Squidslayer | F | 11 | ✅ | +| 31 | `antonius` | Antonius, the Rune Knight | F | 13 | ✅ | +| 32 | `asmor` | Asmor the Aweless | F | 13 | ✅ | +| 33 | `tempros` | Tempros the Time Marauder | M | 11 | ✅ | +| 34 | `chiakang` | Chia Kang, Warlock of Yu | M | 11 | ✅ | +| 35 | `terric` | Terric Warhelm | M | 13 | ✅ | +| 36 | `wayward` | Wayward, the Drifter | M | 13 | ✅ | +| 37 | `hya` | Hya, Legendary Shinobi | T | 11 | ✅ | +| 38 | `blackbeard` | Blackbeard Jake | T | 11 | ✅ | +| 39 | `cecil` | Cecil Leoran, Factotum | T | 13 | ✅ | +| 40 | `wallbanger` | Wallbanger Basketweaver | T | 13 | ✅ | + +> Priority: the 24 ordinary heroes (0–23) plus the Fool (24) are seen every +> game and every player count — paint those first. The 16 epics (25–40) are +> lower-frequency but should look noticeably more impressive/detailed. + +--- + +## 4. Boss sheet — `dungeonboss-bosses.png` + +| | | +|---|---| +| **Path** | `public/assets/images/dungeonboss-bosses.png` | +| **Frame size** | **260 × 360 px** (aspect ≈ 0.722 : 1, large portrait — matches the boss art window) | +| **Sheet size** | **4 columns × 2 rows = 1040 × 720 px** holds all 8 frames exactly | +| **Status** | ⛔ Procedural until you create it (then set `bossSheet.path` in the JSON) | +| **JSON** | `bossSheet` (set `path`) + `bosses` map (already filled in for all 8 ids) | + +Each player picks one boss at the start of the game — shown persistently on +their dungeon (~88×124 to ~168×236) and full-size (~300×430) in the reveal/win +screen. These are the marquee art assets of the game: eight distinct, iconic +dungeon-master portraits. Higher XP bosses go first in turn order, so no +in-art ranking is implied — just make each one visually distinct and readable +at the small on-board size. + +### Frame map + +| Frame | id | Name | XP | Treasure class | Level-up flavor | +|---:|---|---|---:|---|---| +| 0 | `draculord` | Draculord | 900 | cleric | vampire lord, steals a card | +| 1 | `cleopatra` | Cleopatra | 850 | thief | Egyptian queen, fetches advanced trap | +| 2 | `kingcroak` | King Croak | 800 | fighter | frog king, fetches advanced monster | +| 3 | `xyzax` | Xyzax | 750 | cleric | alien/otherworldly, recovers discards | +| 4 | `cerebellus` | Cerebellus | 650 | mage | brain/psychic horror, draws spells | +| 5 | `seducia` | Seducia | 600 | mage | succubus/temptress, lures a hero | +| 6 | `gorgona` | Gorgona | 500 | thief | gorgon/medusa, turns a hero to stone | +| 7 | `robobo` | Robobo | 400 | fighter | robot, wrecks opponents' rooms | + +--- + +## 5. Menu icon — `game-icons.png` frame **77** *(shared sheet)* + +| | | +|---|---| +| **File** | `public/assets/images/game-icons.png` (existing shared sheet) | +| **Sheet size** | 660 × 660 px | +| **Frame size** | **44 × 44 px** | +| **Layout** | 15 columns × 15 rows | +| **Dungeon Boss frame** | **77** → row 5, col 2 → pixel box **x 88–132, y 220–264** | + +Add a 44×44 icon for Dungeon Boss into cell 77 of the existing sheet (the menu +tile pulls `game-icons` frame `77`). Keep it readable at tiny size — e.g. a +grinning dungeon-entrance skull, crossed sword/wand, or a small horned boss +silhouette. This is the only sprite that goes into an **existing** sheet +rather than a new file. + +--- + +## Quick checklist + +- [ ] `dungeonboss-rooms.png` — 1800×1200 (6×6 grid), 300×200 per frame, 31 frames (0–30); then set `roomSheet.path` in `dungeonboss-artwork.json`. +- [ ] `dungeonboss-spells.png` — 880×1200 (4×4 grid), 220×300 per frame, 16 frames (0–15); then set `spellSheet.path` in `dungeonboss-artwork.json`. +- [ ] `dungeonboss-heroes.png` — 1540×1800 (7×6 grid), 220×300 per frame, 41 frames (0–40); then set `heroSheet.path` in `dungeonboss-artwork.json`. + - Priority 1: ordinary heroes + Fool (0–24) + - Priority 2: epic heroes (25–40) +- [ ] `dungeonboss-bosses.png` — 1040×720 (4×2 grid), 260×360 per frame, 8 frames (0–7); then set `bossSheet.path` in `dungeonboss-artwork.json`. +- [ ] `game-icons.png` frame 77 — 44×44 menu icon. + +The frame→id mappings for all four sheets are already filled in in +`public/data/dungeonboss-artwork.json` — once you drop the PNGs in and set the +four `path` fields, art appears with no code changes. diff --git a/src/games/dungeonboss/tutorial.md b/src/games/dungeonboss/tutorial.md new file mode 100644 index 0000000..f41b2d5 --- /dev/null +++ b/src/games/dungeonboss/tutorial.md @@ -0,0 +1,74 @@ +# Dungeon Boss + +> **A raspy voice echoes from beneath the mountain…** +> +> Ah, a new Boss. Wonderful. I am Grimble, the imp who files the paperwork for +> every dungeon in the valley. Yes, dungeons have paperwork. Whose soul do you +> think keeps the lamps lit? Sit, and I'll explain how a respectable villain +> earns a living down here: you build the deadliest dungeon in the land, lure +> in shiny-booted heroes, and *harvest their souls.* Try not to get stabbed. + +## Objective + +Every player is a **Boss** with a dungeon of up to **5 rooms**. Heroes arrive +in town each round and wander into whichever dungeon most tempts their greedy +little hearts. Kill them, and their souls are yours. Let them fight all the way +through your halls, and they'll wound *you* instead. + +- First Boss to **10 souls** at the end of a round **wins**. +- Take **5 wounds**, and your dungeon is closed down. Permanently. +- Epic heroes are worth **2 souls** — but they also deal **2 wounds**. + +## Setup + +You're dealt a Boss, **5 Room cards, and 2 Spell cards**; discard any 2 cards, +then everyone builds one starting room at the same time. Your Boss shows an +**XP value** — the highest XP acts first each round. (If the game ends in a +soul tie, the *lowest* XP wins. Underdogs get the paperwork bonus.) + +## A Round of Villainy + +1. **Heroes arrive** — one hero per player is revealed in town, and every Boss + draws a Room card. +2. **Build** — everyone secretly places one room face down (or passes), then + all flip at once. New rooms extend your dungeon toward the entrance; you can + also build *over* an old room. **Advanced rooms** (gold-gemmed) may only be + built over a room sharing a treasure type. +3. **Bait** — each hero compares dungeons and marches to the one showing the + **most treasure of its class**: Fighters follow swords, Mages tomes, Thieves + coin, Clerics holy symbols. Ties? The hero stays in town, dithering. + *The Fool* wanders to whoever has the fewest souls. +4. **Adventure** — heroes walk your dungeon room by room, from the entrance to + your throne. Each room deals its damage. Hero drops to zero? Soul harvested. + Hero survives every room? It pokes *you* — one wound (two if epic). + +## Rooms, Spells, and Dirty Tricks + +- **Monster rooms** and **trap rooms** each have damage, treasure icons, and + often an ability — some trigger when built, some when a hero dies inside, + and some you activate by *sacrificing* the room itself. +- **Spells** are cast at three moments: before builds, after the flip, and just + before heroes charge a dungeon. Build-phase spells (hammer label) shape the + board; adventure spells (adventure label) turn battles. You can boost your + own rooms, freeze a rival's, or *heal their hero* so it survives to bite them. +- Watch for **Counterspell** and the **All-Seeing Eye** — cast recklessly and + your masterstroke fizzles into the discard pile. + +## Leveling Up + +The first time your dungeon reaches **5 rooms**, your Boss **levels up** and +unleashes a one-time power — read your Boss card, every one is different. +Racing to five rooms is a fine plan. So is knocking rivals' rooms down before +they get there. + +## Grimble's Advice + +- Only bait heroes you can actually **kill** — count your damage before you + flash your treasure. A dungeon that attracts what it cannot slay is called + a *tomb*, and the paperwork is dreadful. +- Save a spell for the adventure phase. The difference between a soul and a + wound is often one well-timed boost. +- The soul leader draws everyone's knives. If that's you — build high, stay + humble, and keep the Eye open. + +Now go. Build something *horrible*. Make old Grimble proud. diff --git a/src/games/swdbg/SWDBGAI.js b/src/games/swdbg/SWDBGAI.js new file mode 100644 index 0000000..2ca6880 --- /dev/null +++ b/src/games/swdbg/SWDBGAI.js @@ -0,0 +1,290 @@ +// SWDBGAI.js — synchronous heuristics over the engine's public view (opponent +// hand/decks are stubs; the AI never sees hidden information). One handler per +// decision kind, routed through decide(). Skill 1–5 follows the house shape: +// widening top-N with score noise and a blunder chance at low skill. + +import { cardDef, baseDef, getData } from './SWDBGData.js'; +import { legalActions, entryAttack, forceWith, purchaseCost } from './SWDBGLogic.js'; + +const SKILL_PROFILES = { + 1: { topN: 4, blunder: 0.35, noise: 3.0, actChance: 0.35, bountyChance: 0.25, delay: [650, 1100] }, + 2: { topN: 3, blunder: 0.20, noise: 2.0, actChance: 0.55, bountyChance: 0.50, delay: [600, 1000] }, + 3: { topN: 3, blunder: 0.08, noise: 1.2, actChance: 0.80, bountyChance: 0.75, delay: [550, 950] }, + 4: { topN: 2, blunder: 0.02, noise: 0.6, actChance: 0.95, bountyChance: 0.90, delay: [480, 860] }, + 5: { topN: 1, blunder: 0.00, noise: 0.0, actChance: 1.00, bountyChance: 1.00, delay: [420, 800] }, +}; +function profileFor(skill) { + return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; +} +export function nextThinkDelay(skill) { + const [lo, hi] = profileFor(skill).delay; + return lo + Math.random() * (hi - lo); +} + +function pickScored(cands, skill, rnd) { + if (!cands.length) return null; + const prof = profileFor(skill); + if (rnd() < prof.blunder) return cands[Math.floor(rnd() * cands.length)].choice; + const jittered = cands.map((c) => ({ choice: c.choice, s: c.s + (rnd() * 2 - 1) * prof.noise })); + jittered.sort((a, b) => b.s - a.s); + return jittered[Math.floor(rnd() * Math.min(prof.topN, jittered.length))].choice; +} + +const me = (view, seat) => view.players[seat]; +const foe = (view, seat) => view.players[1 - seat]; + +// Deck-building quality of a card — used for buys, discards, and exiles. +export function cardValue(def) { + let v = (def.attack || 0) + (def.resources || 0) + (def.force || 0) * 0.7; + if (def.ability) v += 1.2; + if (def.type === 'capital') v += (def.hp || 0) * 0.25 + (def.resources || 0) * 1.3; + if (def.unique) v += 0.5; + return v; +} + +function worstHandCard(view, seat, { exclude = null } = {}) { + const hand = me(view, seat).hand.filter((c) => c.uid !== exclude); + if (!hand.length) return null; + return hand.slice().sort((a, b) => cardValue(cardDef(a)) - cardValue(cardDef(b)))[0]; +} + +// ── turn ───────────────────────────────────────────────────────────────────── +function chooseTurn(view, seat, skill, rnd) { + const prof = profileFor(skill); + const p = me(view, seat); + const o = foe(view, seat); + const legal = legalActions(view, seat); + + // 1. Play out the hand. Hold one cheap card back as Jabba/exile-cost fodder + // when an unused exileFromHand ability is on the table. + if (legal.plays.length) { + const needsFodder = [...p.inPlay, ...p.capitals].some((e) => { + const ab = cardDef(e.card).ability; + return ab?.timing === 'action' && ab.cost?.exileFromHand && !e.used; + }); + let playable = p.hand; + if (needsFodder && p.hand.length === 1) playable = []; + if (playable.length) { + // Worst first, so kept fodder is the cheapest card. + const pick = playable.slice().sort((a, b) => cardValue(cardDef(b)) - cardValue(cardDef(a)))[0]; + return { type: 'play', uid: pick.uid }; + } + } + + // 2. Abilities (skill-gated). + if (legal.abilities.length && rnd() < prof.actChance) { + const a = legal.abilities[0]; + const action = { type: 'ability', zone: a.zone, uid: a.uid }; + // Supply the exile cost card when needed. + const def = a.zone === 'base' ? baseDef(p.faction, p.base.id) : cardDef(findInPlay(p, a.uid).card); + if (def.ability?.cost?.exileFromHand) { + const fodder = worstHandCard(view, seat); + if (!fodder) return nextAfterAbilities(view, seat, skill, rnd, legal); + action.costUid = fodder.uid; + } + return action; + } + + return nextAfterAbilities(view, seat, skill, rnd, legal); +} + +function findInPlay(p, uid) { + return p.inPlay.find((e) => e.card.uid === uid) || p.capitals.find((e) => e.card.uid === uid); +} + +function nextAfterAbilities(view, seat, skill, rnd, legal) { + const prof = profileFor(skill); + const p = me(view, seat); + const o = foe(view, seat); + + // 3. Bounty hunting / sabotage — spend the smallest sufficient attacker set + // when the reward + denial beats the base damage forfeited. + if (legal.rowTargets.length && rnd() < prof.bountyChance) { + const options = []; + for (const t of legal.rowTargets) { + const target = view.galaxy.row.find((c) => c.uid === t.uid); + const tdef = cardDef(target); + const picked = pickBountySquad(view, seat, tdef.target); + if (!picked) continue; + const r = tdef.reward || {}; + const rewardV = (r.resources || 0) + (r.force || 0) * 1.2 + (r.draw || 0) * 1.8; + const denialV = cardValue(tdef) * 0.45; // enemy can no longer buy it + const spent = picked.total * 0.7; // base damage forfeited + const s = rewardV + denialV - spent; + if (s > -0.5) options.push({ choice: { type: 'attackRow', targetUid: t.uid, uids: picked.uids }, s }); + } + const pick = pickScored(options, skill, rnd); + if (pick) return pick; + } + + // 4. Buys, best value per resource. + if (legal.buys.length) { + const options = [{ choice: null, s: 0.4 }]; // skipping junk is allowed + for (const b of legal.buys) { + const def = b.uid === 'outerrim' ? getData().json.outerRimPilot + : cardDef(view.galaxy.row.find((c) => c.uid === b.uid)); + let s = cardValue(def) - b.cost * 0.45; + if (def.faction === p.faction) s += 0.5; // faction synergy & denial-proofing + if (def.type === 'capital' && o.lostBases >= 2) s -= 1; // late capitals rarely pay off + if (view.turnCount <= 6 && def.cost <= 2 && def.ability) s += 0.4; // early thinning/tempo + options.push({ choice: { type: 'buy', uid: b.uid }, s }); + } + const pick = pickScored(options, skill, rnd); + if (pick) return pick; + } + + // 5. Attack the base with everything still uncommitted. + if (legal.baseAttackers.length) { + const total = legal.baseAttackers.reduce((a, uid) => a + entryAttack(view, seat, findInPlay(p, uid)), 0); + if (total > 0) { + const order = o.capitals.slice() + .sort((a, b) => (cardDef(a.card).hp - a.damage) - (cardDef(b.card).hp - b.damage)) + .map((e) => e.card.uid); + return { type: 'attackBase', uids: legal.baseAttackers, capitalOrder: order }; + } + } + + return { type: 'endTurn' }; +} + +// Smallest set of free units whose row attack meets the target. +function pickBountySquad(view, seat, target) { + const p = me(view, seat); + const free = p.inPlay.filter((e) => !e.committed) + .map((e) => ({ uid: e.card.uid, atk: entryAttack(view, seat, e, { vsRow: true }) })) + .filter((e) => e.atk > 0) + .sort((a, b) => a.atk - b.atk); + const total = free.reduce((a, e) => a + e.atk, 0); + if (total < target) return null; + // Greedy from the top down, then trim from the bottom. + const picked = []; + let sum = 0; + for (let i = free.length - 1; i >= 0 && sum < target; i--) { picked.push(free[i]); sum += free[i].atk; } + for (let i = picked.length - 1; i >= 0; i--) { + if (sum - picked[i].atk >= target) { sum -= picked[i].atk; picked.splice(i, 1); } + } + return { uids: picked.map((e) => e.uid), total: sum }; +} + +// ── other decisions ────────────────────────────────────────────────────────── +function chooseBase(view, seat, skill, rnd, decision) { + const p = me(view, seat); + const options = decision.options.map((id) => { + const b = baseDef(p.faction, id); + let s = b.hp * 0.5; + const ab = b.ability; + if (ab) { + if (ab.op === 'draw') s += 2.5; + if (ab.op === 'gainResources') s += 2; + if (ab.op === 'capitalDiscount') s += view.turnCount <= 8 ? 2 : 0.5; + if (ab.op === 'onPlayTraitAttack') s += 1.5; + if (ab.op === 'damageBase') s += 2.5; + if (ab.op === 'exileCards') s += view.turnCount <= 8 ? 1.5 : 0.3; + } + // Behind on bases? Prefer raw hit points. + if (p.lostBases > foe(view, seat).lostBases) s += b.hp * 0.25; + return { choice: id, s }; + }); + return pickScored(options, skill, rnd); +} + +function chooseTarget(view, seat, skill, rnd, decision) { + const p = me(view, seat); + const cands = decision.candidates || []; + const scored = []; + const skipOk = decision.optional; + switch (decision.op) { + case 'discardRow': + for (const c of cands) { + const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid)); + const enemy = def.faction !== 'neutral' && def.faction !== p.faction; + scored.push({ choice: c, s: enemy ? cardValue(def) : cardValue(def) * 0.2 - 1 }); + } + if (skipOk) scored.push({ choice: null, s: 1.5 }); + break; + case 'destroyCapital': + for (const c of cands) { + if (c.kind === 'capital') { + const e = foe(view, seat).capitals.find((x) => x.card.uid === c.uid); + scored.push({ choice: c, s: e ? cardValue(cardDef(e.card)) + 2 : 0 }); + } else { + const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid)); + scored.push({ choice: c, s: def.faction !== p.faction && def.faction !== 'neutral' ? cardValue(def) : 0.5 }); + } + } + break; + case 'exileCards': + for (const c of cands) { + const zone = c.zone === 'hand' ? p.hand : p.discard; + const def = cardDef(zone.find((x) => x.uid === c.uid)); + // Exile starters and junk; never exile good cards. + scored.push({ choice: c, s: 2.2 - cardValue(def) }); + } + if (skipOk) scored.push({ choice: null, s: 0 }); + break; + case 'recoverDiscard': + for (const c of cands) { + const def = cardDef(p.discard.find((x) => x.uid === c.uid)); + scored.push({ choice: c, s: cardValue(def) }); + } + break; + case 'freePurchase': + for (const c of cands) { + const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid)); + scored.push({ choice: c, s: cardValue(def) }); + } + break; + case 'lookTop': case 'revealTopDiscard': { + const def = decision.peekId ? cardDef(decision.peekId) : null; + const enemy = def && def.faction !== 'neutral' && def.faction !== p.faction; + const junk = def && cardValue(def) < 2; + const discard = enemy || junk; + return cands.find((c) => c.v === (discard ? 'discard' : 'keep')) || cands[0]; + } + default: + return decision.optional ? null : cands[0] || null; + } + const pick = pickScored(scored, skill, rnd); + return pick === undefined ? null : pick; +} + +function chooseOppDiscard(view, seat) { + const worst = worstHandCard(view, seat); + return worst ? worst.uid : null; +} + +function chooseSpy(view, seat, decision) { + if (!decision.options.includes('discard')) return 'force'; + if (!decision.options.includes('force')) return 'discard'; + const worst = worstHandCard(view, seat); + return worst && cardValue(cardDef(worst)) <= 1.2 ? 'discard' : 'force'; +} + +function chooseOption(view, seat, skill, rnd, decision) { + const o = foe(view, seat); + const scored = decision.options.map((ab, i) => { + let s = 1; + if (ab.op === 'draw') s = (ab.n || 1) * 1.4; + if (ab.op === 'oppDiscard') s = o.hand.length ? 1.6 : 0; + if (ab.op === 'gainResources') s = (ab.n || 1) * 1.0; + return { choice: i, s }; + }); + return pickScored(scored, skill, rnd) ?? 0; +} + +// ── Router ────────────────────────────────────────────────────────────────── +// Returns the argument for the matching act* mutator: +// turn → action object · chooseBase → baseId · target → ref|null · +// oppDiscard → uid · spyChoice → 'discard'|'force' · chooseOption → idx +export function decide(view, decision, skill, rnd = Math.random) { + const seat = decision.seat; + switch (decision.kind) { + case 'turn': return chooseTurn(view, seat, skill, rnd); + case 'chooseBase': return chooseBase(view, seat, skill, rnd, decision); + case 'target': return chooseTarget(view, seat, skill, rnd, decision); + case 'oppDiscard': return chooseOppDiscard(view, seat); + case 'spyChoice': return chooseSpy(view, seat, decision); + case 'chooseOption': return chooseOption(view, seat, skill, rnd, decision); + default: return null; + } +} diff --git a/src/games/swdbg/SWDBGData.js b/src/games/swdbg/SWDBGData.js new file mode 100644 index 0000000..5a5bf86 --- /dev/null +++ b/src/games/swdbg/SWDBGData.js @@ -0,0 +1,91 @@ +// SWDBGData.js — data layer for Star Wars Deckbuilder. +// All card content lives in /data/swdbg-cards.json (user-editable); this module +// indexes the parsed JSON and exposes lookups. loadCardData() must be called +// before the engine is used: the Phaser scene feeds it the preloaded JSON and +// tools/verifySWDBG.js reads the file from disk. + +export const FACTIONS = ['empire', 'rebel']; + +export const FACTION_INFO = { + empire: { label: 'Galactic Empire', color: 0x4d7fd6, colorHex: '#4d7fd6', dark: 0x16233d, darkHex: '#1c3a70', symbol: '◉' }, + rebel: { label: 'Rebel Alliance', color: 0xd6604d, colorHex: '#d6604d', dark: 0x3d1a16, darkHex: '#8a3223', symbol: '✴' }, + neutral: { label: 'Neutral', color: 0xc9a54a, colorHex: '#c9a54a', dark: 0x332a14, darkHex: '#7a5c14', symbol: '◈' }, +}; + +// Every op the engine understands. verifySWDBG.js checks each card in the JSON +// against this list so a data edit can't silently no-op. +export const OPS = [ + 'draw', 'gainResources', 'gainForce', 'gainAttack', 'damageBase', 'repairBase', + 'oppDiscard', 'discardRow', 'destroyCapital', 'exileCards', 'freePurchase', + 'recoverDiscard', 'lookTop', 'revealTop', 'spyChoice', 'choose', + 'traitBoost', 'rowBonus', 'selfBoostWhileForce', 'selfBoostWithTrait', + 'capitalDiscount', 'onPlayTraitAttack', +]; +export const TIMINGS = ['action', 'constant', 'onPlay', 'onBounty', 'onReveal']; +// Ops that are passive modifiers — never activated as an action. +export const CONSTANT_OPS = ['traitBoost', 'rowBonus', 'selfBoostWhileForce', 'selfBoostWithTrait', 'capitalDiscount', 'onPlayTraitAttack']; + +let DATA = null; + +export function loadCardData(json) { + if (!json || !json.cards) throw new Error('swdbg-cards.json missing or malformed'); + const byId = new Map(); + const add = (def) => { + if (byId.has(def.id)) throw new Error(`duplicate card id ${def.id}`); + byId.set(def.id, def); + }; + for (const def of json.starterCards) add(def); + add(json.outerRimPilot); + for (const def of json.cards) add(def); + const bases = { rebel: new Map(), empire: new Map() }; + for (const f of FACTIONS) for (const b of json.bases[f]) bases[f].set(b.id, b); + DATA = { json, byId, bases, meta: json.meta }; + return DATA; +} + +export function getData() { + if (!DATA) throw new Error('loadCardData() must be called before using the engine'); + return DATA; +} + +export function cardDef(idOrInst) { + const id = typeof idOrInst === 'string' ? idOrInst : idOrInst?.id; + const def = getData().byId.get(id); + if (!def) throw new Error(`unknown card id ${id}`); + return def; +} + +export function baseDef(faction, id) { + const def = getData().bases[faction].get(id); + if (!def) throw new Error(`unknown ${faction} base ${id}`); + return def; +} + +export function basesFor(faction) { return getData().json.bases[faction]; } + +let uidCounter = 0; +export function makeInstance(id) { return { uid: ++uidCounter, id }; } +export function resetUids() { uidCounter = 0; } + +// Deck construction ---------------------------------------------------------- + +export function buildGalaxyDeck() { + const out = []; + for (const def of getData().json.cards) { + for (let i = 0; i < def.count; i++) out.push(makeInstance(def.id)); + } + return out; +} + +export function buildStarterDeck(faction) { + const out = []; + for (const entry of getData().json.starters[faction]) { + for (let i = 0; i < entry.count; i++) out.push(makeInstance(entry.id)); + } + return out; +} + +export function buildOuterRim() { + const def = getData().json.outerRimPilot; + return Array.from({ length: def.count }, () => makeInstance(def.id)); +} diff --git a/src/games/swdbg/SWDBGGame.js b/src/games/swdbg/SWDBGGame.js new file mode 100644 index 0000000..62c3694 --- /dev/null +++ b/src/games/swdbg/SWDBGGame.js @@ -0,0 +1,1333 @@ +// SWDBGGame.js — Phaser scene for Star Wars Deckbuilder (presentation only; all +// rules live in SWDBGLogic). The scene pumps the engine's decision queue: +// human decisions enable click UI, AI decisions run on a think delay, and each +// action's event stream drives a sequential animation queue before re-render. +// +// Interaction model on your turn: +// · click a hand card to play it (or Play All) +// · click a card in play to toggle it into your attack squad, then click the +// enemy base panel or a highlighted galaxy-row target to strike +// · USE pills appear under cards whose ability is available +// · click a highlighted galaxy-row card (no squad selected) to purchase it + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { api } from '../../services/api.js'; +import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js'; +import { loadCardData, cardDef, baseDef, basesFor, getData, FACTION_INFO } from './SWDBGData.js'; +import { + newGame, pendingDecision, takeEvents, publicView, legalActions, + actTurn, actChooseBase, actChooseTarget, actOppDiscard, actSpyChoice, actChooseOption, + entryAttack, forceWith, purchaseCost, FORCE_MAX, +} from './SWDBGLogic.js'; +import { decide, nextThinkDelay } from './SWDBGAI.js'; + +const C = { + bg: 0x05070f, bgTop: 0x0a0f1f, + panel: 0x0d1424, panelEdge: 0x22304f, + text: '#e8ecf5', muted: '#8a94ab', gold: 0xc9a54a, goldHex: '#c9a54a', + good: 0x3fbf6f, goodHex: '#3fbf6f', bad: 0xd6604d, badHex: '#e06c5c', + empire: 0x4d7fd6, rebel: 0xd6604d, neutral: 0xc9a54a, + artWindow: 0x111a30, plate: 0x1a2440, + // Card face background — slightly off-white, with darker ink variants for + // the small bits of text that sit directly on it (outside the plate/art- + // window/badge boxes, which stay dark and keep their existing light text). + cardBg: 0xf1ede1, + statAtkHex: '#a8362a', statResHex: '#8a6a14', statForceHex: '#1a5f96', + bountyHex: '#7a3fa8', baseTextHex: '#38414f', +}; +const DEPTH = { board: 10, row: 20, hand: 40, fx: 60, ui: 80, overlay: 90 }; + +export default class SWDBGGame extends Phaser.Scene { + constructor() { super('SWDBGGame'); } + + init(data) { + this._initData = data; + this.gameDef = data?.game ?? { slug: 'swdbg', name: 'Star Wars Deckbuilder' }; + this.opponents = data?.opponents ?? []; + this.playfield = data?.playfield ?? null; + this.aiSkill = this.opponents[0]?.skill ?? 3; + this.humanSeat = 0; + this.gs = null; + this.busy = false; + this.mode = { type: 'idle' }; + this.squad = new Set(); // uids committed to the next attack + this._recorded = false; + this._rowPos = new Map(); // uid → {x,y} + this._playPos = new Map(); // uid → {x,y} + this._actionButtons = []; + } + + create() { + try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) { /* optional */ } + this.art = this.cache.json.get('swdbg-artwork') || {}; + loadCardData(this.cache.json.get('swdbg-cards')); + + this.drawBackground(); + this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board); + this.rowLayer = this.add.container(0, 0).setDepth(DEPTH.row); + this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand); + this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx); + this.buildStaticUi(); + this.showFactionSelect(); + } + + drawBackground() { + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(0); + return; + } + const bg = this.add.graphics().setDepth(0); + bg.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1); + bg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + const rng = new Phaser.Math.RandomDataGenerator(['swdbg']); + for (let i = 0; i < 240; i++) { + const a = rng.frac() * 0.5 + 0.08; + bg.fillStyle(0xffffff, a); + bg.fillCircle(rng.between(0, GAME_WIDTH), rng.between(0, GAME_HEIGHT), rng.frac() * 1.6 + 0.4); + } + } + + buildStaticUi() { + new Button(this, 90, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'), + { width: 130, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.ui); + this.promptText = this.add.text(GAME_WIDTH / 2, 648, '', { + fontFamily: 'Righteous', fontSize: '23px', color: C.goldHex, + backgroundColor: '#05070fdd', padding: { x: 16, y: 6 }, + }).setOrigin(0.5).setDepth(DEPTH.ui); + if (this.opponents[0]) createOpponentPortrait(this, this.opponents[0], 140, 128, 44, DEPTH.ui); + createPlayerPortrait(this, 140, 812, 44, DEPTH.ui, 'SWDBGGame'); + } + + // ── faction select ───────────────────────────────────────────────────────── + showFactionSelect() { + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + root.add(this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.72).setInteractive()); + root.add(this.add.text(GAME_WIDTH / 2, 250, 'CHOOSE YOUR SIDE', { + fontFamily: 'Righteous', fontSize: '52px', color: C.text, + }).setOrigin(0.5)); + root.add(this.add.text(GAME_WIDTH / 2, 316, 'The Empire always strikes first.', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.muted, + }).setOrigin(0.5)); + const mk = (x, faction, blurb) => { + const info = FACTION_INFO[faction]; + const cont = this.add.container(x, 620); + const g = this.add.graphics(); + g.fillStyle(C.panel, 0.96); g.fillRoundedRect(-240, -190, 480, 380, 16); + g.lineStyle(4, info.color, 1); g.strokeRoundedRect(-240, -190, 480, 380, 16); + cont.add(g); + cont.add(this.add.text(0, -120, info.symbol, { fontSize: '84px', color: info.colorHex }).setOrigin(0.5)); + cont.add(this.add.text(0, -20, info.label.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '34px', color: info.colorHex, + }).setOrigin(0.5)); + cont.add(this.add.text(0, 84, blurb, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: C.text, align: 'center', + wordWrap: { width: 420 }, lineSpacing: 6, + }).setOrigin(0.5)); + cont.setSize(480, 380); + cont.setInteractive({ useHandCursor: true }); + cont.on('pointerover', () => this.tweens.add({ targets: cont, scale: 1.04, duration: 120 })); + cont.on('pointerout', () => this.tweens.add({ targets: cont, scale: 1, duration: 120 })); + cont.on('pointerdown', () => { root.destroy(); this.startGame(faction); }); + root.add(cont); + }; + mk(GAME_WIDTH / 2 - 300, 'empire', 'Overwhelming firepower.\nTIE swarms, Star Destroyers,\nand the will of the Emperor.\nYou move first.'); + mk(GAME_WIDTH / 2 + 300, 'rebel', 'Hope, grit, and X-Wings.\nThe Force begins on your side\nof the Balance track.'); + } + + startGame(faction) { + this.gs = newGame({ humanFaction: faction }); + takeEvents(this.gs); + this.renderAll(); + this.showBanner(faction === 'empire' ? 'You command the Empire — your turn first' : 'The Empire moves first — hold the line'); + this.pump(); + } + + // ── art lookup ───────────────────────────────────────────────────────────── + artFor(id, base = false) { + const sheet = base ? this.art.baseSheet : this.art.cardSheet; + const frame = (base ? this.art.bases : this.art.cards)?.[id]; + if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key)) { + return { key: sheet.key, frame }; + } + return null; + } + + factionColor(faction) { return FACTION_INFO[faction]?.color ?? C.neutral; } + + // ── card renderers ───────────────────────────────────────────────────────── + makeCard(x, y, inst, w, h, opts = {}) { + const def = cardDef(inst); + const cont = this.add.container(x, y); + const edge = opts.selected ? C.gold : this.factionColor(def.faction); + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(Math.max(2, w / 55), edge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7); + if (def.unique) { g.lineStyle(1.5, C.gold, 0.85); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 5); } + // name plate + const plateH = Math.max(15, h * 0.12); + g.fillStyle(C.plate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3); + // art window + const artH = h * (opts.showText ? 0.34 : 0.5); + const artTop = -h / 2 + plateH + 8; + g.fillStyle(C.artWindow, 1); g.fillRect(-w / 2 + 7, artTop, w - 14, artH); + cont.add(g); + const art = this.artFor(inst.id); + if (art) { + const img = this.add.image(0, artTop + artH / 2, art.key, art.frame); + img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + const glyph = def.type === 'capital' ? '🚀' : (def.traits || []).includes('Fighter') ? '🛩' + : (def.traits || []).includes('Trooper') ? '🪖' : FACTION_INFO[def.faction].symbol; + cont.add(this.add.text(0, artTop + artH / 2, glyph, { fontSize: `${Math.round(artH * 0.42)}px`, color: '#3d4d75' }).setOrigin(0.5).setAlpha(0.9)); + } + cont.add(this.add.text(2, -h / 2 + 5 + plateH / 2, def.name, { + fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 11.5))}px`, color: C.text, + }).setOrigin(0.5).setScale(Math.min(1, (w - 34) / Math.max(1, def.name.length * (w / 17))))); + // cost badge + const cr = Math.max(9, w / 12); + const cb = this.add.graphics(); + cb.fillStyle(0x0a0f1f, 1); cb.fillCircle(-w / 2 + cr + 3, -h / 2 + cr + 3, cr + 2); + cb.fillStyle(C.gold, 1); cb.fillCircle(-w / 2 + cr + 3, -h / 2 + cr + 3, cr); + cont.add(cb); + cont.add(this.add.text(-w / 2 + cr + 3, -h / 2 + cr + 3, `${def.cost}`, { + fontFamily: 'Righteous', fontSize: `${Math.round(cr * 1.15)}px`, color: '#0a0f1f', + }).setOrigin(0.5)); + // stat column under the art + let sy = artTop + artH + 13; + const stat = (glyph, val, color) => { + if (!val) return; + cont.add(this.add.text(-w / 2 + 12, sy, `${glyph}${val}`, { + fontFamily: 'Righteous', fontSize: `${Math.max(11, Math.round(w / 11))}px`, color, + }).setOrigin(0, 0.5)); + sy += Math.max(13, h * 0.085); + }; + const atk = opts.attackNow != null ? opts.attackNow : def.attack; + stat('⚔', atk, opts.attackNow != null && opts.attackNow !== def.attack ? C.statResHex : C.statAtkHex); + stat('▣', def.resources, C.statResHex); + stat('◈', def.force, C.statForceHex); + if (def.type === 'capital') { + const hr = Math.max(9, w / 13); + const hb = this.add.graphics(); + hb.fillStyle(0x0a0f1f, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr + 2); + hb.fillStyle(C.bad, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr); + cont.add(hb); + const hpLeft = opts.damage != null ? def.hp - opts.damage : def.hp; + cont.add(this.add.text(w / 2 - hr - 4, h / 2 - hr - 4, `${hpLeft}`, { + fontFamily: 'Righteous', fontSize: `${Math.round(hr * 1.1)}px`, color: '#fff', + }).setOrigin(0.5)); + } + // rules text + if (opts.showText && def.text) { + const boxTop = artTop + artH + Math.max(30, h * 0.2); + const boxH = h / 2 - boxTop - (def.target != null ? 24 : 8); + if (boxH > 18) { + const tg = this.add.graphics(); + tg.fillStyle(0x151d33, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, boxH, 3); + cont.add(tg); + cont.add(this.add.text(0, boxTop + boxH / 2, def.text, { + fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 16))}px`, color: '#c7cede', align: 'center', + wordWrap: { width: w - 22 }, + }).setOrigin(0.5)); + } + } + // bounty footer + if (def.target != null) { + const r = def.reward || {}; + const bits = []; + if (r.resources) bits.push(`${r.resources}▣`); + if (r.force) bits.push(`${r.force}◈`); + if (r.draw) bits.push(`draw ${r.draw}`); + cont.add(this.add.text(0, h / 2 - 12, `◎ ${def.target} → ${bits.join(' ')}`, { + fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 14.5))}px`, color: C.bountyHex, + }).setOrigin(0.5)); + } + this.finishCard(cont, w, h, opts); + return cont; + } + + makeCardBack(x, y, w, h, parent, label = '') { + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(0x101a33, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(2, 0x2a3c66, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(1, 0x2a3c66, 0.6); g.strokeRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 5); + cont.add(g); + cont.add(this.add.text(0, -6, '✦', { fontSize: `${Math.round(h * 0.28)}px`, color: '#33477a' }).setOrigin(0.5)); + if (label) { + cont.add(this.add.text(0, h / 2 - 16, label, { + fontFamily: 'Righteous', fontSize: '15px', color: C.muted, + }).setOrigin(0.5)); + } + (parent || this.boardLayer).add(cont); + return cont; + } + + makeBaseCard(x, y, faction, base, w, h, opts = {}) { + const def = baseDef(faction, base.id); + const cont = this.add.container(x, y); + const info = FACTION_INFO[faction]; + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 9); + g.lineStyle(3, opts.highlight ? C.gold : info.color, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 9); + const artW = w * 0.42; + g.fillStyle(C.artWindow, 1); g.fillRect(-w / 2 + 6, -h / 2 + 6, artW, h - 12); + cont.add(g); + const art = this.artFor(def.id, true); + if (art) { + const img = this.add.image(-w / 2 + 6 + artW / 2, 0, art.key, art.frame); + img.setScale(Math.min(artW / Math.max(img.width, 1), (h - 12) / Math.max(img.height, 1))); + cont.add(img); + } else { + cont.add(this.add.text(-w / 2 + 6 + artW / 2, 0, '🪐', { fontSize: `${Math.round(h * 0.4)}px` }).setOrigin(0.5).setAlpha(0.85)); + } + const tx = -w / 2 + artW + 14; + cont.add(this.add.text(tx, -h / 2 + 17, def.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: `${Math.max(12, Math.round(w / 15))}px`, color: info.darkHex, + }).setOrigin(0, 0.5)); + if (opts.showText) { + cont.add(this.add.text(tx, -h / 2 + 36, def.text, { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.baseTextHex, + wordWrap: { width: w - artW - 26 }, lineSpacing: 3, + }).setOrigin(0, 0)); + } + // hp bar + const hpLeft = Math.max(0, def.hp - base.damage); + const barW = w - artW - 28; + const bg2 = this.add.graphics(); + bg2.fillStyle(0x0a0f1f, 1); bg2.fillRoundedRect(tx, h / 2 - 26, barW, 16, 8); + bg2.fillStyle(hpLeft / def.hp > 0.4 ? C.good : C.bad, 1); + bg2.fillRoundedRect(tx, h / 2 - 26, barW * (hpLeft / def.hp), 16, 8); + cont.add(bg2); + cont.add(this.add.text(tx + barW / 2, h / 2 - 18, `${hpLeft} / ${def.hp}`, { + fontFamily: 'Righteous', fontSize: '13px', color: '#fff', + }).setOrigin(0.5)); + this.finishCard(cont, w, h, opts); + return cont; + } + + finishCard(cont, w, h, opts) { + if (opts.committed) { + const ov = this.add.graphics(); + ov.fillStyle(0x000000, 0.45); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 7); + cont.add(ov); + cont.add(this.add.text(0, 0, '✓', { fontSize: `${Math.round(h * 0.3)}px`, color: '#8a94ab' }).setOrigin(0.5)); + } + if (opts.highlightBuy) { + const hl = this.add.graphics(); + hl.lineStyle(3, C.good, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 9); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.35, duration: 520, yoyo: true, repeat: -1 }); + } + if (opts.highlightTarget) { + const hl = this.add.graphics(); + hl.lineStyle(3.5, C.bad, 1); hl.strokeRoundedRect(-w / 2 - 5, -h / 2 - 5, w + 10, h + 10, 9); + hl.lineStyle(2, C.bad, 0.8); + hl.lineBetween(-w / 2 - 12, 0, -w / 2 + 2, 0); hl.lineBetween(w / 2 - 2, 0, w / 2 + 12, 0); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.4, duration: 420, yoyo: true, repeat: -1 }); + } + if (opts.highlightGold) { + const hl = this.add.graphics(); + hl.lineStyle(3, C.gold, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 9); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 }); + } + if (opts.onClick) { + cont.setSize(w, h); + cont.setInteractive({ useHandCursor: true }); + if (opts.hover !== false) { + const baseY = cont.y; + cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 10, duration: 100 }); }); + cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 })); + } + cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); }); + } + (opts.parent || this.boardLayer).add(cont); + } + + // ── full re-render ────────────────────────────────────────────────────────── + renderAll() { + this.boardLayer.removeAll(true); + this.rowLayer.removeAll(true); + this.handLayer.removeAll(true); + this._rowPos.clear(); + this._playPos.clear(); + if (!this.gs) return; + this.renderOpponent(); + this.renderGalaxy(); + this.renderForceTrack(); + this.renderHuman(); + this.renderHand(); + } + + seatName(seat) { return seat === this.humanSeat ? 'you' : (this.opponents[0]?.name || 'Opponent'); } + + renderOpponent() { + const gs = this.gs; + const seat = 1 - this.humanSeat; + const p = gs.players[seat]; + const info = FACTION_INFO[p.faction]; + const g = this.add.graphics(); + const attackable = this.squad.size > 0 && this.myTurnMode(); + g.fillStyle(C.panel, 0.6); g.fillRoundedRect(30, 34, GAME_WIDTH - 60, 212, 12); + g.lineStyle(2, attackable ? C.bad : C.panelEdge, 1); g.strokeRoundedRect(30, 34, GAME_WIDTH - 60, 212, 12); + this.boardLayer.add(g); + if (attackable) { + this.tweens.add({ targets: g, alpha: 0.5, duration: 420, yoyo: true, repeat: -1 }); + const z = this.add.zone(GAME_WIDTH / 2, 140, GAME_WIDTH - 60, 212).setInteractive({ useHandCursor: true }); + z.on('pointerdown', () => this.launchBaseAttack()); + this.boardLayer.add(z); + } + this.boardLayer.add(this.add.text(140, 196, this.opponents[0]?.name || 'Opponent', { + fontFamily: 'Righteous', fontSize: '17px', color: info.colorHex, + }).setOrigin(0.5)); + this.boardLayer.add(this.add.text(140, 220, info.label, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted, + }).setOrigin(0.5)); + // base + if (p.base) { + this.makeBaseCard(392, 140, p.faction, p.base, 300, 190, { + parent: this.boardLayer, hover: false, showText: true, + onClick: () => { if (this.squad.size && this.myTurnMode()) this.launchBaseAttack(); else this.showInspectBase(p.faction, p.base); }, + }); + } else { + this.boardLayer.add(this.add.text(392, 140, 'BASE DESTROYED', { + fontFamily: 'Righteous', fontSize: '22px', color: C.badHex, + }).setOrigin(0.5).setAngle(-6)); + } + this._oppBasePos = { x: 392, y: 140 }; + // capitals + p.capitals.forEach((e, i) => { + const x = 620 + i * 122; + this._playPos.set(e.card.uid, { x, y: 140 }); + this.makeCard(x, 140, e.card, 108, 152, { + parent: this.boardLayer, hover: false, damage: e.damage, + onClick: () => this.showInspect(e.card), + }); + }); + // opponent cards in play (their turn) + p.inPlay.forEach((e, i) => { + const x = 1210 + (i % 5) * 96; + const y = 100 + Math.floor(i / 5) * 88; + this._playPos.set(e.card.uid, { x, y }); + this.makeCard(x, y, e.card, 84, 118, { + parent: this.boardLayer, hover: false, committed: e.committed, + attackNow: entryAttack(gs, seat, e), + onClick: () => this.showInspect(e.card), + }); + }); + // counters + const cx = GAME_WIDTH - 220; + const lines = [ + `Hand ${p.hand.length} Deck ${p.deck.length}`, + `Discard ${p.discard.length}`, + `Resources ${p.resources}`, + ]; + this.boardLayer.add(this.add.text(cx, 70, lines.join('\n'), { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: C.muted, lineSpacing: 7, + })); + // bases remaining / lost + this.renderBasePips(cx, 168, p, info); + if (gs.turnSeat === seat && !gs.over) { + this.boardLayer.add(this.add.text(GAME_WIDTH / 2, 48, '— THEIR TURN —', { + fontFamily: 'Righteous', fontSize: '15px', color: info.colorHex, + }).setOrigin(0.5)); + } + } + + renderBasePips(x, y, p, info) { + this.boardLayer.add(this.add.text(x, y - 22, `Bases lost ${p.lostBases}/${this.gs.meta.basesToWin}`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted, + })); + for (let i = 0; i < this.gs.meta.basesToWin; i++) { + const gg = this.add.graphics(); + const lost = i < p.lostBases; + gg.fillStyle(lost ? C.bad : 0x22304f, 1); + gg.fillCircle(x + 10 + i * 30, y + 8, 10); + if (lost) { gg.lineStyle(2, 0xffffff, 0.7); gg.lineBetween(x + 3 + i * 30, y + 1, x + 17 + i * 30, y + 15); } + this.boardLayer.add(gg); + } + } + + // ── galaxy band ───────────────────────────────────────────────────────────── + renderGalaxy() { + const gs = this.gs; + const y = 430; + const cw = 158, ch = 222; + // discard + deck + if (gs.galaxy.discard.length) { + const top = gs.galaxy.discard[gs.galaxy.discard.length - 1]; + this.makeCard(160, y, top, cw * 0.82, ch * 0.82, { parent: this.rowLayer, hover: false, onClick: () => this.showInspect(top) }); + this.rowLayer.add(this.add.text(160, y + ch / 2 + 4, `discard ${gs.galaxy.discard.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted, + }).setOrigin(0.5)); + } else { + this.rowLayer.add(this.add.text(160, y, 'GALAXY\nDISCARD', { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#2a3c66', align: 'center', + }).setOrigin(0.5)); + } + this.makeCardBack(330, y, cw * 0.9, ch * 0.9, this.rowLayer, `${gs.galaxy.deck.length}`); + // the row + const rowX0 = 510; + const legal = this.humanTurnLegal(); + gs.galaxy.row.forEach((card, i) => { + const def = cardDef(card); + const x = rowX0 + i * (cw + 14); + this._rowPos.set(card.uid, { x, y }); + const buyable = !this.squad.size && legal && legal.buys.some((b) => b.uid === card.uid); + const targetable = this.squad.size > 0 && this.rowTargetReachable(card); + const freeTarget = this.mode.type === 'target' && ['discardRow', 'freePurchase', 'destroyCapital'].includes(this.mode.decision?.op) + && (this.mode.candidates || []).some((c) => c.uid === card.uid); + this.makeCard(x, y, card, cw, ch, { + parent: this.rowLayer, showText: true, + highlightBuy: buyable, highlightTarget: targetable, highlightGold: freeTarget, + onClick: () => this.onRowClicked(card), + }); + }); + // Outer Rim Pilot stack + if (gs.outerRim.length) { + const orp = getData().json.outerRimPilot; + const x = rowX0 + 6 * (cw + 14) + 20; + const buyable = !this.squad.size && legal && legal.buys.some((b) => b.uid === 'outerrim'); + this.makeCard(x, y, gs.outerRim[gs.outerRim.length - 1], cw, ch, { + parent: this.rowLayer, showText: true, highlightBuy: buyable, + onClick: () => this.onOuterRimClicked(), + }); + this.rowLayer.add(this.add.text(x, y + ch / 2 + 4, `${gs.outerRim.length} left`, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted, + }).setOrigin(0.5)); + this._orpPos = { x, y }; + } + } + + renderForceTrack() { + const gs = this.gs; + const cx = GAME_WIDTH / 2, y = 590; + const cellW = 66, cellH = 34; + const total = FORCE_MAX * 2 + 1; + const x0 = cx - (total * cellW) / 2; + const g = this.add.graphics(); + for (let i = 0; i < total; i++) { + const v = i - FORCE_MAX; // -3 .. +3 (empire … rebel) + const isMark = gs.force === v; + g.fillStyle(v < 0 ? 0x14213d : v > 0 ? 0x3d1a16 : 0x1a1a22, 1); + g.fillRoundedRect(x0 + i * cellW + 2, y - cellH / 2, cellW - 4, cellH, 6); + g.lineStyle(isMark ? 3 : 1, isMark ? 0xffffff : C.panelEdge, isMark ? 1 : 0.7); + g.strokeRoundedRect(x0 + i * cellW + 2, y - cellH / 2, cellW - 4, cellH, 6); + } + this.boardLayer.add(g); + const mx = x0 + (gs.force + FORCE_MAX) * cellW + cellW / 2; + const marker = this.add.graphics(); + marker.fillStyle(0xffffff, 1); marker.fillCircle(mx, y, 9); + marker.lineStyle(2, C.gold, 1); marker.strokeCircle(mx, y, 9); + this.boardLayer.add(marker); + this._forceMarkerPos = { x: mx, y }; + this.boardLayer.add(this.add.text(x0 - 16, y, FACTION_INFO.empire.symbol, { fontSize: '26px', color: FACTION_INFO.empire.colorHex }).setOrigin(1, 0.5)); + this.boardLayer.add(this.add.text(x0 + total * cellW + 16, y, FACTION_INFO.rebel.symbol, { fontSize: '26px', color: FACTION_INFO.rebel.colorHex }).setOrigin(0, 0.5)); + const label = gs.force === 0 ? 'THE FORCE IS BALANCED' + : `THE FORCE IS WITH THE ${(gs.force > 0 ? 'REBELS' : 'EMPIRE')}${Math.abs(gs.force) === FORCE_MAX ? ' — +1 RESOURCE' : ''}`; + this.boardLayer.add(this.add.text(cx, y + 34, label, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: gs.force === 0 ? C.muted : (gs.force > 0 ? FACTION_INFO.rebel.colorHex : FACTION_INFO.empire.colorHex), + }).setOrigin(0.5)); + } + + // ── human area ────────────────────────────────────────────────────────────── + renderHuman() { + const gs = this.gs; + const seat = this.humanSeat; + const p = gs.players[seat]; + const info = FACTION_INFO[p.faction]; + const legal = this.humanTurnLegal(); + + this.boardLayer.add(this.add.text(140, 878, info.label, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: info.colorHex, + }).setOrigin(0.5)); + // resources + this.boardLayer.add(this.add.text(140, 912, `▣ ${p.resources}`, { + fontFamily: 'Righteous', fontSize: '26px', color: '#e8c860', + }).setOrigin(0.5)); + this.boardLayer.add(this.add.text(140, 940, `deck ${p.deck.length} · discard ${p.discard.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.muted, + }).setOrigin(0.5)); + this.renderBasePips(70, 975, p, info); + + // base + if (p.base) { + const useBase = legal && legal.abilities.find((a) => a.zone === 'base'); + this.makeBaseCard(392, 800, p.faction, p.base, 300, 190, { + parent: this.boardLayer, hover: false, showText: true, + onClick: () => this.showInspectBase(p.faction, p.base), + }); + this._myBasePos = { x: 392, y: 800 }; + if (useBase) this.addUsePill(392, 902, () => this.beginAbility({ zone: 'base', uid: 'base' })); + } else { + this.boardLayer.add(this.add.text(392, 800, 'CHOOSE A NEW BASE…', { + fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex, + }).setOrigin(0.5)); + } + + // capitals + p.capitals.forEach((e, i) => { + const x = 620 + i * 128; + const y = 800; + this._playPos.set(e.card.uid, { x, y }); + const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid); + const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0; + this.makeCard(x, y, e.card, 116, 162, { + parent: this.boardLayer, hover: false, damage: e.damage, + selected: this.squad.has(e.card.uid), committed: e.committed, + attackNow: entryAttack(gs, seat, e), + onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card), + }); + if (canUse) this.addUsePill(x, y + 96, () => this.beginAbility({ zone: 'capital', uid: e.card.uid })); + }); + + // units in play + const units = p.inPlay; + const uw = 122, uh = 170; + const pitch = Math.min(uw + 10, 620 / Math.max(1, units.length)); + const x0 = 1180 - ((units.length - 1) * pitch) / 2; + units.forEach((e, i) => { + const x = x0 + i * pitch; + const y = 795; + this._playPos.set(e.card.uid, { x, y }); + const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid); + const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0; + this.makeCard(x, y, e.card, uw, uh, { + parent: this.boardLayer, hover: false, + selected: this.squad.has(e.card.uid), committed: e.committed, + attackNow: entryAttack(gs, seat, e), + onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card), + }); + if (canUse) this.addUsePill(x, y + uh / 2 + 14, () => this.beginAbility({ zone: 'play', uid: e.card.uid })); + }); + if (!units.length && !p.capitals.length) { + this.boardLayer.add(this.add.text(1180, 795, 'cards you play land here', { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a3c66', + }).setOrigin(0.5)); + } + + // squad readout + if (this.squad.size) { + const total = this.squadAttack(); + this.boardLayer.add(this.add.text(1180, 700, `⚔ ${total} committed — strike the enemy base or a marked bounty`, { + fontFamily: 'Righteous', fontSize: '18px', color: C.badHex, + }).setOrigin(0.5)); + } + } + + addUsePill(x, y, cb) { + const b = this.add.container(x, y); + const bg = this.add.graphics(); + bg.fillStyle(C.gold, 1); bg.fillRoundedRect(-34, -12, 68, 24, 12); + b.add(bg); + b.add(this.add.text(0, 0, 'USE', { fontFamily: 'Righteous', fontSize: '14px', color: '#0a0f1f' }).setOrigin(0.5)); + b.setSize(68, 24); + b.setInteractive({ useHandCursor: true }); + b.on('pointerdown', () => { if (!this.busy) cb(); }); + this.tweens.add({ targets: b, alpha: 0.65, duration: 520, yoyo: true, repeat: -1 }); + this.boardLayer.add(b); + } + + renderHand() { + const gs = this.gs; + const p = gs.players[this.humanSeat]; + if (!p.hand.length) return; + const cw = 148, chh = 206; + const pitch = Math.min(cw + 8, 1250 / Math.max(1, p.hand.length)); + let x = GAME_WIDTH / 2 - ((p.hand.length - 1) * pitch) / 2; + const y = 985; + for (const inst of p.hand) { + const playable = this.myTurnMode(); + const discardable = this.mode.type === 'oppDiscard' || (this.mode.type === 'target' && this.mode.decision?.op === 'exileCards' + && (this.mode.candidates || []).some((c) => c.zone === 'hand' && c.uid === inst.uid)); + this.makeCard(x, y, inst, cw, chh, { + parent: this.handLayer, showText: true, + highlightGold: discardable, + onClick: () => this.onHandClicked(inst), + }); + if (!playable && !discardable) this.handLayer.list[this.handLayer.list.length - 1].setAlpha(0.9); + x += pitch; + } + } + + // ── interaction helpers ───────────────────────────────────────────────────── + myTurnMode() { return this.mode.type === 'turn'; } + humanTurnLegal() { + if (!this.gs || !this.myTurnMode()) return null; + return legalActions(this.gs, this.humanSeat); + } + + squadAttack(vsRow = false) { + const p = this.gs.players[this.humanSeat]; + let total = 0; + for (const uid of this.squad) { + const e = p.inPlay.find((x) => x.card.uid === uid) || p.capitals.find((x) => x.card.uid === uid); + if (e) total += entryAttack(this.gs, this.humanSeat, e, { vsRow }); + } + return total; + } + + rowTargetReachable(card) { + if (!this.myTurnMode() || !this.squad.size) return false; + const def = cardDef(card); + const p = this.gs.players[this.humanSeat]; + if (def.faction === 'neutral' || def.faction === p.faction || def.type !== 'unit' || def.target == null) return false; + // capitals cannot join bounty hunts + for (const uid of this.squad) if (p.capitals.some((e) => e.card.uid === uid)) return false; + return this.squadAttack(true) >= def.target; + } + + toggleSquad(uid) { + if (this.squad.has(uid)) this.squad.delete(uid); + else this.squad.add(uid); + this.sfx(SFX.CARD_SHOW); + this.renderAll(); + } + + launchBaseAttack() { + if (!this.myTurnMode() || !this.squad.size) return; + const o = this.gs.players[1 - this.humanSeat]; + const order = o.capitals.slice() + .sort((a, b) => (cardDef(a.card).hp - a.damage) - (cardDef(b.card).hp - b.damage)) + .map((e) => e.card.uid); + const uids = [...this.squad]; + this.squad.clear(); + this.sfx(SFX.SCIFI_LAUNCH); + this.applyDecision(this.mode.decision, { type: 'attackBase', uids, capitalOrder: order }); + } + + onRowClicked(card) { + const m = this.mode; + if (m.type === 'target') { + const ref = (m.candidates || []).find((c) => c.uid === card.uid); + if (ref) { this.applyDecision(m.decision, ref); return; } + } + if (this.myTurnMode()) { + if (this.squad.size && this.rowTargetReachable(card)) { + const uids = [...this.squad]; + this.squad.clear(); + this.sfx(SFX.SCIFI_LAUNCH); + this.applyDecision(this.mode.decision, { type: 'attackRow', targetUid: card.uid, uids }); + return; + } + const legal = this.humanTurnLegal(); + if (!this.squad.size && legal.buys.some((b) => b.uid === card.uid)) { + this.sfx(SFX.PURCHASE); + this.applyDecision(this.mode.decision, { type: 'buy', uid: card.uid }); + return; + } + } + this.showInspect(card); + } + + onOuterRimClicked() { + if (this.myTurnMode() && !this.squad.size) { + const legal = this.humanTurnLegal(); + if (legal.buys.some((b) => b.uid === 'outerrim')) { + this.sfx(SFX.PURCHASE); + this.applyDecision(this.mode.decision, { type: 'buy', uid: 'outerrim' }); + return; + } + } + const orp = this.gs.outerRim[this.gs.outerRim.length - 1]; + if (orp) this.showInspect(orp); + } + + onHandClicked(inst) { + const m = this.mode; + if (m.type === 'oppDiscard') { + this.sfx(SFX.CARD_PLACE); + this.applyDecision(m.decision, inst.uid); + return; + } + if (m.type === 'target' && m.decision?.op === 'exileCards') { + const ref = (m.candidates || []).find((c) => c.zone === 'hand' && c.uid === inst.uid); + if (ref) { this.applyDecision(m.decision, ref); return; } + } + if (this.myTurnMode()) { + this.sfx(SFX.CARD_PLACE); + this.applyDecision(m.decision, { type: 'play', uid: inst.uid }); + return; + } + this.showInspect(inst); + } + + beginAbility(target) { + const d = this.mode.decision; + const p = this.gs.players[this.humanSeat]; + const def = target.zone === 'base' ? baseDef(p.faction, p.base.id) + : cardDef((p.inPlay.find((e) => e.card.uid === target.uid) || p.capitals.find((e) => e.card.uid === target.uid)).card); + if (def.ability?.cost?.exileFromHand) { + // pick the cost card first + this.showHandPickModal('Choose a card to exile as the cost', (uid) => { + this.applyDecision(d, { type: 'ability', zone: target.zone, uid: target.uid, costUid: uid }); + }); + return; + } + this.sfx(SFX.SCIFI_REVEAL ?? SFX.CARD_SHOW); + this.applyDecision(d, { type: 'ability', zone: target.zone, uid: target.uid }); + } + + // ── pump: run engine until human input is required ───────────────────────── + pump() { + if (!this.gs || this.busy) return; + if (this.gs.over) { this.onGameOver(); return; } + const d = pendingDecision(this.gs); + if (!d) return; + if (d.seat !== this.humanSeat) { + this.setPrompt(`${this.opponents[0]?.name || 'Opponent'} is plotting…`); + this.busy = true; + this.time.delayedCall(nextThinkDelay(this.aiSkill), () => { + this.busy = false; + if (this.gs.over) { this.onGameOver(); return; } + const nd = pendingDecision(this.gs); + if (!nd || nd.seat === this.humanSeat) { this.pump(); return; } + const choice = decide(publicView(this.gs, nd.seat), nd, this.aiSkill); + this.applyDecision(nd, choice); + }); + return; + } + this.enterHumanMode(d); + } + + applyDecision(d, choice) { + try { + switch (d.kind) { + case 'turn': actTurn(this.gs, d.seat, choice); break; + case 'chooseBase': actChooseBase(this.gs, d.seat, choice); break; + case 'target': actChooseTarget(this.gs, d.seat, choice); break; + case 'oppDiscard': actOppDiscard(this.gs, d.seat, choice); break; + case 'spyChoice': actSpyChoice(this.gs, d.seat, choice); break; + case 'chooseOption': actChooseOption(this.gs, d.seat, choice); break; + default: break; + } + } catch (err) { + console.error('swdbg action rejected:', err); + this.mode = { type: 'idle' }; + this.renderAll(); + this.pump(); + return; + } + this.mode = { type: 'idle' }; + this.clearActionButtons(); + this.closeModal(); + this.playEvents(takeEvents(this.gs)); + } + + enterHumanMode(d) { + this.clearActionButtons(); + switch (d.kind) { + case 'turn': { + this.mode = { type: 'turn', decision: d }; + const legal = legalActions(this.gs, this.humanSeat); + const p = this.gs.players[this.humanSeat]; + if (p.hand.length) this.setPrompt('Your turn — play cards, buy, and attack'); + else if (legal.buys.length || legal.abilities.length || this.squad.size) this.setPrompt('Buy from the galaxy row, use abilities, or attack'); + else this.setPrompt('Attack with your forces or end your turn'); + if (p.hand.length) this.addActionButton('Play All', () => this.applyDecision(d, { type: 'playAll' })); + this.addActionButton('End Turn', () => { this.squad.clear(); this.applyDecision(d, { type: 'endTurn' }); }); + break; + } + case 'chooseBase': { + this.mode = { type: 'chooseBase', decision: d }; + this.showBaseChoiceModal(d); + break; + } + case 'target': { + this.mode = { type: 'target', decision: d, candidates: d.candidates }; + this.enterTargetMode(d); + break; + } + case 'oppDiscard': { + this.mode = { type: 'oppDiscard', decision: d }; + this.setPrompt('Enemy pressure! Choose a card to discard'); + break; + } + case 'spyChoice': { + this.mode = { type: 'spyChoice', decision: d }; + this.showSpyModal(d); + break; + } + case 'chooseOption': { + this.mode = { type: 'chooseOption', decision: d }; + this.showOptionModal(d); + break; + } + default: break; + } + this.renderAll(); + } + + enterTargetMode(d) { + switch (d.op) { + case 'discardRow': + this.setPrompt('Choose a galaxy-row card to discard — or Skip'); + if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null)); + break; + case 'freePurchase': + this.setPrompt('Choose a card to take for free'); + break; + case 'destroyCapital': + this.setPrompt('Choose a capital ship to destroy'); + this.showCapitalTargetModal(d); + break; + case 'exileCards': + this.setPrompt('Exile a card from your hand or discard — or Skip'); + if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null)); + this.addActionButton('From discard…', () => this.showDiscardPickModal(d)); + break; + case 'recoverDiscard': + this.showRecoverModal(d); + break; + case 'lookTop': + this.showPeekModal(d, 'Top of the galaxy deck', 'Leave on top', 'Discard it'); + break; + case 'revealTopDiscard': + this.showPeekModal(d, 'Revealed from the galaxy deck', 'Leave on top', 'Discard it'); + break; + default: + // Fallback: auto-pick the first candidate so nothing soft-locks. + this.applyDecision(d, d.candidates[0] || null); + break; + } + } + + // ── modals ────────────────────────────────────────────────────────────────── + modalRoot() { + this.closeModal(); + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62); + dim.setInteractive(); + root.add(dim); + this._modal = root; + return root; + } + closeModal() { if (this._modal) { this._modal.destroy(); this._modal = null; } for (const b of this._modalButtons || []) b.destroy(); this._modalButtons = []; } + modalButton(x, y, label, cb, opts = {}) { + const b = new Button(this, x, y, label, cb, { width: opts.width || 260, fontSize: opts.fontSize || 19, variant: opts.variant }); + b.setDepth(DEPTH.overlay + 1); + this._modalButtons = this._modalButtons || []; + this._modalButtons.push(b); + return b; + } + + showBaseChoiceModal(d) { + const root = this.modalRoot(); + const p = this.gs.players[this.humanSeat]; + root.add(this.add.text(GAME_WIDTH / 2, 300, 'YOUR BASE HAS FALLEN — CHOOSE THE NEXT', { + fontFamily: 'Righteous', fontSize: '30px', color: C.text, + }).setOrigin(0.5)); + const n = d.options.length; + const pitch = Math.min(360, 1500 / Math.max(1, n)); + const x0 = GAME_WIDTH / 2 - ((n - 1) * pitch) / 2; + d.options.forEach((id, i) => { + this.makeBaseCard(x0 + i * pitch, 560, p.faction, { id, damage: 0 }, 330, 210, { + parent: root, hover: false, showText: true, highlight: true, + onClick: () => this.applyDecision(d, id), + }); + }); + } + + showSpyModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + root.add(this.add.text(cx, cy - 120, 'DUROS SPY', { + fontFamily: 'Righteous', fontSize: '30px', color: C.badHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 70, 'Their agent forces a choice:', { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: C.text, + }).setOrigin(0.5)); + let y = cy; + if (d.options.includes('discard')) { + this.modalButton(cx, y, 'Discard a card from your hand', () => this.applyDecision(d, 'discard'), { width: 420 }); + y += 60; + } + if (d.options.includes('force')) { + this.modalButton(cx, y, 'Let them gain 1 Force', () => this.applyDecision(d, 'force'), { width: 420, variant: 'ghost' }); + } + } + + showOptionModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + root.add(this.add.text(cx, cy - 110, 'CHOOSE ONE', { + fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex, + }).setOrigin(0.5)); + d.options.forEach((ab, i) => { + this.modalButton(cx, cy - 30 + i * 60, this.abilityLabel(ab), () => this.applyDecision(d, i), { width: 480 }); + }); + } + + abilityLabel(ab) { + switch (ab.op) { + case 'draw': return `Draw ${ab.n || 1} card${(ab.n || 1) > 1 ? 's' : ''}`; + case 'oppDiscard': return 'Opponent discards a card'; + case 'gainResources': return `Gain ${ab.n} resources`; + case 'gainForce': return `Gain ${ab.n} Force`; + case 'damageBase': return `Deal ${ab.n} damage to the enemy base`; + default: return ab.op; + } + } + + showPeekModal(d, title, keepLabel, discardLabel) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + root.add(this.add.text(cx, cy - 230, title, { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + if (d.peekId) this.makeCard(cx, cy - 20, { uid: -1, id: d.peekId }, 220, 306, { parent: root, hover: false, showText: true }); + this.modalButton(cx - 150, cy + 200, keepLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'keep')), { width: 260, variant: 'ghost' }); + this.modalButton(cx + 150, cy + 200, discardLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'discard')), { width: 260 }); + } + + showCapitalTargetModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 300, 'DESTROY A CAPITAL SHIP', { + fontFamily: 'Righteous', fontSize: '28px', color: C.badHex, + }).setOrigin(0.5)); + const o = this.gs.players[1 - this.humanSeat]; + const cards = d.candidates.map((ref) => { + if (ref.kind === 'capital') { + const e = o.capitals.find((x) => x.card.uid === ref.uid); + return { ref, inst: e.card, note: 'in play' }; + } + const inst = this.gs.galaxy.row.find((x) => x.uid === ref.uid); + return { ref, inst, note: 'galaxy row' }; + }); + const pitch = Math.min(240, 1400 / Math.max(1, cards.length)); + const x0 = cx - ((cards.length - 1) * pitch) / 2; + cards.forEach((c, i) => { + this.makeCard(x0 + i * pitch, 540, c.inst, 190, 264, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, c.ref), + }); + root.add(this.add.text(x0 + i * pitch, 690, c.note, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted, + }).setOrigin(0.5)); + }); + } + + showRecoverModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 280, 'RETURN A CARD TO YOUR HAND', { + fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex, + }).setOrigin(0.5)); + const p = this.gs.players[this.humanSeat]; + const cards = d.candidates.map((ref) => ({ ref, inst: p.discard.find((c) => c.uid === ref.uid) })).filter((c) => c.inst); + const pitch = Math.min(220, 1400 / Math.max(1, cards.length)); + const x0 = cx - ((cards.length - 1) * pitch) / 2; + cards.forEach((c, i) => { + this.makeCard(x0 + i * pitch, 540, c.inst, 180, 250, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, c.ref), + }); + }); + } + + showDiscardPickModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 240, 'EXILE FROM YOUR DISCARD PILE', { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + const p = this.gs.players[this.humanSeat]; + const cands = (d.candidates || []).filter((c) => c.zone === 'discard'); + const cards = cands.map((ref) => ({ ref, inst: p.discard.find((c) => c.uid === ref.uid) })).filter((c) => c.inst); + const cols = Math.min(8, Math.max(4, Math.ceil(Math.sqrt(cards.length * 1.6)))); + const cw = 150, chh = 208; + cards.forEach((c, i) => { + const x = cx - ((Math.min(cols, cards.length) - 1) * (cw + 12)) / 2 + (i % cols) * (cw + 12); + const y = 430 + Math.floor(i / cols) * (chh + 14); + this.makeCard(x, y, c.inst, cw, chh, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, c.ref), + }); + }); + this.modalButton(cx, GAME_HEIGHT - 110, 'Back', () => { this.closeModal(); }, { variant: 'ghost', width: 180 }); + } + + showHandPickModal(title, onPick) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 300, title.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + const p = this.gs.players[this.humanSeat]; + const pitch = Math.min(200, 1400 / Math.max(1, p.hand.length)); + const x0 = cx - ((p.hand.length - 1) * pitch) / 2; + p.hand.forEach((inst, i) => { + this.makeCard(x0 + i * pitch, 560, inst, 170, 236, { + parent: root, hover: false, showText: true, + onClick: () => { this.closeModal(); onPick(inst.uid); }, + }); + }); + this.modalButton(cx, 760, 'Cancel', () => { this.closeModal(); }, { variant: 'ghost', width: 180 }); + } + + showInspect(inst) { + if (this._modal || !inst || inst.hidden) return; + const root = this.modalRoot(); + this.makeCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, inst, 340, 470, { parent: root, hover: false, showText: true }); + const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive(); + zone.on('pointerdown', () => this.closeModal()); + root.add(zone); + root.bringToTop(zone); + } + + showInspectBase(faction, base) { + if (this._modal) return; + const root = this.modalRoot(); + this.makeBaseCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, faction, base, 560, 340, { parent: root, hover: false, showText: true }); + const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive(); + zone.on('pointerdown', () => this.closeModal()); + root.add(zone); + root.bringToTop(zone); + } + + // ── prompt + action buttons ───────────────────────────────────────────────── + setPrompt(txt) { this.promptText.setText(txt || ''); } + addActionButton(label, cb) { + const i = this._actionButtons.length; + const b = new Button(this, 1700, 700 + i * 52, label, cb, { width: 190, fontSize: 18 }); + b.setDepth(DEPTH.ui); + this._actionButtons.push(b); + } + clearActionButtons() { + for (const b of this._actionButtons) b.destroy(); + this._actionButtons = []; + } + + // ── event playback ────────────────────────────────────────────────────────── + playEvents(events) { + this.busy = true; + this.setPrompt(''); + const queue = events.filter((e) => this.eventDelay(e) > 0); + const step = () => { + const e = queue.shift(); + if (!e) { + this.busy = false; + this.renderAll(); + this.pump(); + return; + } + this.eventFx(e); + this.time.delayedCall(this.eventDelay(e), step); + }; + this.renderAll(); + step(); + } + + eventDelay(e) { + const aiEvent = e.seat != null && e.seat !== this.humanSeat; + switch (e.type) { + case 'turnStart': return 460; + case 'play': return aiEvent ? 300 : 140; + case 'buy': return 420; + case 'bounty': return 640; + case 'attackBase': return 380; + case 'capitalDamage': return 300; + case 'capitalDestroyed': return 520; + case 'baseDamage': return 460; + case 'baseDestroyed': return 980; + case 'baseChosen': return 520; + case 'forceMove': return 240; + case 'forceBonus': return 240; + case 'reveal': return 700; + case 'exile': return 260; + case 'discard': return e.forced ? 340 : 0; + case 'recover': return 300; + case 'repair': return 300; + case 'rowDiscard': return 340; + case 'galaxyReshuffle': return 300; + default: return 0; + } + } + + eventFx(e) { + switch (e.type) { + case 'turnStart': + if (e.seat !== this.humanSeat) this.showBanner(`${this.seatName(e.seat)}'s turn`.toUpperCase()); + else this.showBanner('YOUR TURN'); + this.sfx(SFX.CARD_DEAL); + break; + case 'play': + this.sfx(SFX.CARD_PLACE); + break; + case 'buy': { + this.sfx(SFX.PURCHASE); + const from = this._rowPos.get(e.uid) || this._orpPos; + if (from) { + const tok = this.makeCardBack(from.x, from.y, 110, 154, this.fxLayer); + const to = e.seat === this.humanSeat ? { x: 140, y: 940 } : { x: GAME_WIDTH - 220, y: 90 }; + this.tweens.add({ targets: tok, x: to.x, y: to.y, scale: 0.3, alpha: 0.4, duration: 380, ease: 'Cubic.easeIn', onComplete: () => tok.destroy() }); + } + break; + } + case 'bounty': { + this.sfx(SFX.SCIFI_EXPLODE); + const at = this._rowPos.get(e.uid); + if (at) { + this.popText(at.x, at.y, '💥', '#ffffff', 44); + this.popText(at.x, at.y - 56, `${cardDef(e.id).name} defeated!`, C.goldHex, 20); + } + break; + } + case 'attackBase': + this.sfx(SFX.SCIFI_LAUNCH); + break; + case 'capitalDamage': { + const at = this._playPos.get(e.uid); + if (at) { this.popText(at.x, at.y - 20, `-${e.n}`, '#ff6b5e', 26); this.shakeAt(at.x, at.y); } + break; + } + case 'capitalDestroyed': { + this.sfx(SFX.SCIFI_EXPLODE); + const at = this._playPos.get(e.uid) || this._rowPos.get(e.uid); + if (at) this.popText(at.x, at.y, '💥', '#ffffff', 40); + this.popText(GAME_WIDTH / 2, 320, `${cardDef(e.id).name} destroyed`, '#ff6b5e', 22); + break; + } + case 'baseDamage': { + const pos = e.seat === this.humanSeat ? this._myBasePos : this._oppBasePos; + if (pos) { + this.popText(pos.x, pos.y - 40, `-${e.n}`, '#ff5348', 34); + const flash = this.add.rectangle(pos.x, pos.y, 310, 200, 0xd6604d, 0.35).setDepth(DEPTH.fx); + this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() }); + this.sfx(SFX.BATTLESHIP_HIT); + } + break; + } + case 'baseDestroyed': { + this.sfx(SFX.SCIFI_EXPLODE); + const name = baseDef(this.gs.players[e.seat].faction, e.id).name; + this.showBanner(e.seat === this.humanSeat + ? `💥 ${name} HAS FALLEN (${e.lost}/${this.gs.meta.basesToWin})` + : `💥 YOU DESTROYED ${name.toUpperCase()} (${e.lost}/${this.gs.meta.basesToWin})`); + this.cameras.main.shake(240, 0.006); + break; + } + case 'baseChosen': + this.showBanner(`${this.seatName(e.seat)} regroups at ${baseDef(this.gs.players[e.seat].faction, e.id).name}`.toUpperCase()); + break; + case 'forceMove': { + this.sfx(SFX.SCIFI_PLINK ?? SFX.CARD_SHOW); + if (this._forceMarkerPos) this.shakeAt(this._forceMarkerPos.x, this._forceMarkerPos.y); + break; + } + case 'forceBonus': + this.popText(GAME_WIDTH / 2, 560, 'The Force provides: +1 ▣', '#9fd8f0', 20); + break; + case 'reveal': { + const card = this.makeCard(GAME_WIDTH / 2, 430, { uid: -1, id: e.id }, 180, 250, { parent: this.fxLayer, hover: false, showText: true }); + card.setScale(0.4).setAlpha(0); + this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 160, ease: 'Back.easeOut' }); + this.time.delayedCall(620, () => this.fxLayer.removeAll(true)); + break; + } + case 'exile': + this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} exiled`, '#b06bd8', 18); + break; + case 'discard': + if (e.forced) this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200, `${this.seatName(e.seat)} discard${e.seat === this.humanSeat ? '' : 's'} ${cardDef(e.id).name}`, '#c7cede', 18); + break; + case 'recover': + this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} returns to hand`, '#3fbf6f', 18); + break; + case 'repair': + if (this._myBasePos && e.seat === this.humanSeat) this.popText(this._myBasePos.x, this._myBasePos.y - 40, `+${e.n} repaired`, '#3fbf6f', 24); + else if (this._oppBasePos) this.popText(this._oppBasePos.x, this._oppBasePos.y - 40, `+${e.n} repaired`, '#3fbf6f', 24); + break; + case 'rowDiscard': + this.popText(GAME_WIDTH / 2, 430, `${cardDef(e.id).name} swept from the row`, '#c7cede', 18); + break; + case 'galaxyReshuffle': + this.sfx(SFX.CARD_SHUFFLE); + break; + default: break; + } + } + + popText(x, y, txt, color, size = 20) { + const t = this.add.text(x, y, txt, { + fontFamily: 'Righteous', fontSize: `${size}px`, color, stroke: '#05070f', strokeThickness: 4, + }).setOrigin(0.5).setDepth(DEPTH.fx); + this.tweens.add({ targets: t, y: y - 44, alpha: 0, duration: 950, ease: 'Cubic.easeOut', onComplete: () => t.destroy() }); + } + + shakeAt(x, y) { + const g = this.add.graphics().setDepth(DEPTH.fx); + g.lineStyle(3, 0xffffff, 0.5); + g.strokeCircle(x, y, 24); + this.tweens.add({ targets: g, scale: 1.6, alpha: 0, duration: 300, onComplete: () => g.destroy() }); + } + + showBanner(text) { + const banner = this.add.text(GAME_WIDTH / 2, 300, text, { + fontFamily: 'Righteous', fontSize: '30px', color: C.text, + backgroundColor: '#05070fee', padding: { x: 26, y: 12 }, + }).setOrigin(0.5).setDepth(DEPTH.overlay); + this.tweens.add({ + targets: banner, y: 326, duration: 220, ease: 'Back.easeOut', + onComplete: () => this.time.delayedCall(950, () => + this.tweens.add({ targets: banner, alpha: 0, y: 300, duration: 220, onComplete: () => banner.destroy() })), + }); + } + + sfx(key) { try { playSound(this, key); } catch (_) { /* optional */ } } + + // ── game over ─────────────────────────────────────────────────────────────── + onGameOver() { + if (this._gameOverShown) return; + this._gameOverShown = true; + this.clearActionButtons(); + this.closeModal(); + this.setPrompt(''); + const gs = this.gs; + const won = gs.winner === this.humanSeat; + const human = gs.players[this.humanSeat]; + const ai = gs.players[1 - this.humanSeat]; + this.recordResult(won ? 'win' : 'loss', ai.lostBases, [human.lostBases]); + + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive()); + const g = this.add.graphics(); + g.fillStyle(C.panel, 0.97); g.fillRoundedRect(cx - 360, cy - 190, 720, 380, 16); + g.lineStyle(3, won ? C.gold : C.bad, 1); g.strokeRoundedRect(cx - 360, cy - 190, 720, 380, 16); + root.add(g); + root.add(this.add.text(cx, cy - 130, won ? '🏆 THE GALAXY IS YOURS' : 'YOUR LAST BASE HAS FALLEN', { + fontFamily: 'Righteous', fontSize: '36px', color: won ? C.goldHex : C.text, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 70, + won ? (human.faction === 'empire' ? 'Order has been restored to the galaxy.' : 'The spark has become a fire.') + : (human.faction === 'empire' ? 'The Rebellion celebrates in the streets.' : 'The dark times have begun.'), { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: C.muted, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy + 6, + `Enemy bases destroyed — you: ${ai.lostBases} · ${this.seatName(1 - this.humanSeat)}: ${human.lostBases}`, { + fontFamily: '"Julius Sans One"', fontSize: '21px', color: C.text, + }).setOrigin(0.5)); + new Button(this, cx - 115, cy + 120, 'Play Again', () => this.scene.restart(this._initData), + { width: 195, fontSize: 21 }).setDepth(DEPTH.overlay + 1); + new Button(this, cx + 115, cy + 120, 'Leave', () => this.scene.start('GameMenu'), + { width: 195, fontSize: 21, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + } + + async recordResult(result, score, opponentScores) { + if (this._recorded) return; + this._recorded = true; + try { + await api.post('/history/single-player', { slug: 'swdbg', score, opponentScores, result }); + } catch { /* best effort */ } + } +} diff --git a/src/games/swdbg/SWDBGLogic.js b/src/games/swdbg/SWDBGLogic.js new file mode 100644 index 0000000..959f364 --- /dev/null +++ b/src/games/swdbg/SWDBGLogic.js @@ -0,0 +1,872 @@ +// SWDBGLogic.js — pure (no Phaser) engine for Star Wars Deckbuilder, a faithful +// adaptation of Star Wars: The Deckbuilding Game (beginner mode: 5 bases each, +// first to destroy 3 enemy bases wins). +// +// Pull model, same shape as DungeonBossLogic: mutators (act*) validate against +// pendingDecision(state), typed events accumulate in state.events, and the +// scene/AI/verify scripts drain them with takeEvents(). There are no reaction +// windows in this game — the active player acts freely until endTurn — but +// abilities spawn sub-decisions (targets, opponent discards) via effectQueue. +// +// Force track: signed integer -3..+3. Positive = Rebel side, negative = Empire. +// "The Force is with you" = marker strictly on your side; pegged (±3) grants +// +1 resource at the start of your turn. Setup pegs it to the Rebel side. + +import { + FACTIONS, cardDef, baseDef, basesFor, getData, + buildGalaxyDeck, buildStarterDeck, buildOuterRim, makeInstance, resetUids, +} from './SWDBGData.js'; + +export const FORCE_MAX = 3; + +// ── RNG (mulberry32, seedable) ────────────────────────────────────────────── +export function makeRng(seed) { + let s = (seed >>> 0) || 1; + const fn = () => { + s |= 0; s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + fn.int = (n) => Math.floor(fn() * n); + fn.pick = (arr) => arr[fn.int(arr.length)]; + fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; }; + return fn; +} + +// ── Game creation ─────────────────────────────────────────────────────────── +// seat 0 = human (faction of their choice), seat 1 = AI. Empire moves first. +export function newGame({ humanFaction = 'rebel', seed = (Math.random() * 1e9) | 0 } = {}) { + if (!FACTIONS.includes(humanFaction)) throw new Error(`bad faction ${humanFaction}`); + resetUids(); + const rng = makeRng(seed); + const meta = getData().meta; + const players = []; + for (let seat = 0; seat < 2; seat++) { + const faction = seat === 0 ? humanFaction : (humanFaction === 'rebel' ? 'empire' : 'rebel'); + const bases = basesFor(faction); + const starting = bases.find((b) => b.starting); + players.push({ + seat, faction, + deck: rng.shuffle(buildStarterDeck(faction)), + hand: [], discard: [], + inPlay: [], // {card, tempAttack, used, committed} + capitals: [], // {card, damage, tempAttack, used, committed} + base: { id: starting.id, damage: 0 }, + baseUsed: false, + baseDeck: bases.filter((b) => !b.starting).map((b) => b.id), + lostBases: 0, + resources: 0, + }); + } + const state = { + seed, rng, meta, + force: FORCE_MAX * (meta.forceStartsWith === 'empire' ? -1 : 1), + galaxy: { deck: rng.shuffle(buildGalaxyDeck()), row: [], discard: [] }, + outerRim: buildOuterRim(), + exile: [], + players, + turnSeat: players.findIndex((p) => p.faction === 'empire'), + turnCount: 0, + effectQueue: [], + events: [], + over: false, winner: null, + }; + for (let i = 0; i < meta.galaxyRowSize; i++) refillRow(state); + for (const p of players) drawCards(state, p.seat, meta.handSize); + emit(state, { type: 'gameStart', factions: players.map((p) => p.faction) }); + beginTurn(state); + return state; +} + +function emit(state, ev) { state.events.push(ev); } +export function takeEvents(state) { const e = state.events; state.events = []; return e; } +export function isOver(state) { return state.over; } + +// ── Small helpers ─────────────────────────────────────────────────────────── +const P = (state, seat) => state.players[seat]; +const opp = (seat) => 1 - seat; +const forceSign = (faction) => (faction === 'rebel' ? 1 : -1); + +export function forceWith(state, seat) { + const p = P(state, seat); + return state.force * forceSign(p.faction) > 0; +} +export function forcePegged(state, seat) { + const p = P(state, seat); + return state.force * forceSign(p.faction) >= FORCE_MAX; +} + +function moveForce(state, seat, n) { + if (!n) return; + const dir = forceSign(P(state, seat).faction); + const before = state.force; + state.force = Math.max(-FORCE_MAX, Math.min(FORCE_MAX, state.force + dir * n)); + if (state.force !== before) emit(state, { type: 'forceMove', seat, from: before, to: state.force }); +} + +function drawCards(state, seat, n) { + const p = P(state, seat); + for (let i = 0; i < n; i++) { + if (!p.deck.length) { + if (!p.discard.length) return; + p.deck = state.rng.shuffle(p.discard); + p.discard = []; + emit(state, { type: 'reshuffle', seat }); + } + p.hand.push(p.deck.pop()); + emit(state, { type: 'draw', seat }); + } +} + +function refillRow(state) { + const g = state.galaxy; + if (g.row.length >= state.meta.galaxyRowSize) return; + if (!g.deck.length) { + if (!g.discard.length) return; // galaxy exhausted; row stays short + g.deck = state.rng.shuffle(g.discard); + g.discard = []; + emit(state, { type: 'galaxyReshuffle' }); + } + const card = g.deck.pop(); + g.row.push(card); + emit(state, { type: 'rowRefill', id: card.id, uid: card.uid }); +} + +function rowCard(state, uid) { return state.galaxy.row.find((c) => c.uid === uid) || null; } +function removeRowCard(state, uid) { + const i = state.galaxy.row.findIndex((c) => c.uid === uid); + if (i < 0) return null; + const [card] = state.galaxy.row.splice(i, 1); + refillRow(state); + return card; +} + +function findEntry(p, uid) { + return p.inPlay.find((e) => e.card.uid === uid) || p.capitals.find((e) => e.card.uid === uid) || null; +} + +// ── Attack math (shared by scene, AI, verify) ─────────────────────────────── +export function entryAttack(state, seat, entry, { vsRow = false } = {}) { + const p = P(state, seat); + const def = cardDef(entry.card); + let atk = (def.attack || 0) + (entry.tempAttack || 0); + const ab = def.ability; + const allMine = [...p.inPlay, ...p.capitals]; + if (ab && ab.timing === 'constant') { + if (ab.op === 'selfBoostWhileForce' && forceWith(state, seat)) atk += ab.amount; + if (ab.op === 'selfBoostWithTrait' + && allMine.some((e) => e !== entry && (cardDef(e.card).traits || []).includes(ab.trait))) atk += ab.amount; + if (ab.op === 'rowBonus' && vsRow) atk += ab.amount; + } + // traitBoost auras from my other cards in play. + for (const e of allMine) { + if (e === entry) continue; + const oab = cardDef(e.card).ability; + if (oab && oab.timing === 'constant' && oab.op === 'traitBoost' + && (def.traits || []).includes(oab.trait)) atk += oab.amount; + } + return atk; +} + +function capitalDiscount(state, seat) { + const p = P(state, seat); + let n = 0; + const b = p.base && baseDef(p.faction, p.base.id); + if (b?.ability?.timing === 'constant' && b.ability.op === 'capitalDiscount') n += b.ability.n; + return n; +} + +export function purchaseCost(state, seat, def) { + let cost = def.cost; + if (def.type === 'capital') cost -= capitalDiscount(state, seat); + return Math.max(0, cost); +} + +// ── Turn bookkeeping ──────────────────────────────────────────────────────── +function beginTurn(state) { + if (state.over) return; + const seat = state.turnSeat; + const p = P(state, seat); + state.turnCount++; + p.resources = 0; + p.baseUsed = false; + for (const e of p.capitals) { e.used = false; e.committed = false; e.tempAttack = 0; } + emit(state, { type: 'turnStart', seat, turn: state.turnCount }); + if (!p.base) { + state.effectQueue.push({ kind: 'chooseBase', seat }); + } + if (forcePegged(state, seat)) { + p.resources += 1; + emit(state, { type: 'forceBonus', seat }); + } + for (const e of p.capitals) { + const def = cardDef(e.card); + if (def.resources) { + p.resources += def.resources; + emit(state, { type: 'capitalIncome', seat, uid: e.card.uid, n: def.resources }); + } + } +} + +function endGame(state, winner, reason) { + state.over = true; + state.winner = winner; + state.effectQueue = []; + emit(state, { type: 'gameOver', winner, reason }); +} + +// ── Ability availability ──────────────────────────────────────────────────── +function abilityCandidates(state, seat, ab, ctx = {}) { + const p = P(state, seat); + const o = P(state, opp(seat)); + switch (ab.op) { + case 'discardRow': + return state.galaxy.row.map((c) => ({ kind: 'row', uid: c.uid })); + case 'destroyCapital': { + const out = o.capitals.map((e) => ({ kind: 'capital', seat: o.seat, uid: e.card.uid })); + for (const c of state.galaxy.row) if (cardDef(c).type === 'capital') out.push({ kind: 'rowCapital', uid: c.uid }); + return out; + } + case 'exileCards': { + const out = p.hand.filter((c) => c.uid !== ctx.excludeUid).map((c) => ({ kind: 'own', zone: 'hand', uid: c.uid })); + for (const c of p.discard) out.push({ kind: 'own', zone: 'discard', uid: c.uid }); + return out; + } + case 'recoverDiscard': + return p.discard + .filter((c) => ab.filter !== 'unique' || cardDef(c).unique) + .map((c) => ({ kind: 'own', zone: 'discard', uid: c.uid })); + case 'freePurchase': + return state.galaxy.row + .filter((c) => { + const def = cardDef(c); + if (ab.faction && def.faction !== ab.faction) return false; + if (ab.maxCost != null && def.cost > ab.maxCost) return false; + return true; + }) + .map((c) => ({ kind: 'row', uid: c.uid })); + default: + return null; // op needs no board target + } +} + +function abilityUsable(state, seat, ab, { costPoolSize = null, ctx = {} } = {}) { + const p = P(state, seat); + const o = P(state, opp(seat)); + if (ab.requiresForce && !forceWith(state, seat)) return false; + if (ab.cost?.exileFromHand) { + const spare = costPoolSize != null ? costPoolSize : p.hand.length; + if (spare < ab.cost.exileFromHand) return false; + } + switch (ab.op) { + case 'repairBase': return !!p.base && p.base.damage > 0; + case 'oppDiscard': return o.hand.length > 0; + case 'spyChoice': return o.hand.length > 0 || !forcePegged(state, seat); + case 'lookTop': return state.galaxy.deck.length > 0; + case 'revealTop': return state.galaxy.deck.length > 0; + case 'choose': return ab.options.some((sub) => abilityUsable(state, seat, sub, { ctx })); + case 'draw': case 'gainResources': case 'gainForce': case 'gainAttack': case 'damageBase': + return true; + default: { + const cands = abilityCandidates(state, seat, ab, ctx); + return cands === null || cands.length > 0; + } + } +} + +// ── Decisions ─────────────────────────────────────────────────────────────── +// Kinds: +// turn — active seat picks a main action (play/buy/ability/attack/end) +// chooseBase — pick your next base (start of turn after a loss) +// target — pick one candidate for a pending op (optional ⇒ null allowed) +// oppDiscard — the *victim* picks which of their cards to discard +// spyChoice — the victim picks 'discard' or 'force' (Duros Spy) +// chooseOption— pick a sub-ability index for a 'choose' op +// Drop/refresh queue entries whose context changed since they were queued +// (a second forced discard after the hand emptied, a second exile after the +// first consumed the last candidate, …) so a stale entry can never soft-lock. +function normalizeQueue(state) { + while (state.effectQueue.length) { + const q = state.effectQueue[0]; + if (q.kind === 'oppDiscard' && !P(state, q.seat).hand.length) { state.effectQueue.shift(); continue; } + if (q.dynamic === 'exileCards') { + q.candidates = abilityCandidates(state, q.seat, { op: 'exileCards' }); + if (!q.candidates.length) { state.effectQueue.shift(); continue; } + } + break; + } +} + +export function pendingDecision(state) { + if (state.over) return null; + normalizeQueue(state); + const q = state.effectQueue[0]; + if (q) { + if (q.kind === 'chooseBase') { + return { kind: 'chooseBase', seat: q.seat, options: P(state, q.seat).baseDeck.slice() }; + } + if (q.kind === 'oppDiscard') return { kind: 'oppDiscard', seat: q.seat }; + if (q.kind === 'spyChoice') return { kind: 'spyChoice', seat: q.seat, bySeat: q.bySeat, options: q.options }; + if (q.kind === 'chooseOption') return { kind: 'chooseOption', seat: q.seat, options: q.options }; + return { kind: 'target', seat: q.seat, op: q.op, candidates: q.candidates, optional: !!q.optional, peekId: q.peekId ?? null }; + } + return { kind: 'turn', seat: state.turnSeat }; +} + +function assertDecision(state, seat, kind) { + const d = pendingDecision(state); + if (!d || d.seat !== seat || d.kind !== kind) { + throw new Error(`illegal action: expected ${d ? `${d.kind} by seat ${d.seat}` : 'none'}, got ${kind} by ${seat}`); + } + return d; +} + +// All main-turn options for a seat — used by the AI, the scene, and verify. +export function legalActions(state, seat) { + const p = P(state, seat); + const o = P(state, opp(seat)); + const out = { plays: [], abilities: [], buys: [], rowTargets: [], baseAttackers: [], endTurn: true }; + if (state.over || state.turnSeat !== seat || state.effectQueue.length) return out; + + for (const c of p.hand) out.plays.push(c.uid); + + const tryAbility = (zone, uid, def, usedFlag) => { + const ab = def.ability; + if (!ab || usedFlag) return; + if (ab.timing !== 'action') return; + if (!abilityUsable(state, seat, ab)) return; + out.abilities.push({ zone, uid, op: ab.op }); + }; + for (const e of p.inPlay) tryAbility('play', e.card.uid, cardDef(e.card), e.used); + for (const e of p.capitals) tryAbility('capital', e.card.uid, cardDef(e.card), e.used); + if (p.base) { + const b = baseDef(p.faction, p.base.id); + if (b.ability?.timing === 'action' && !p.baseUsed && abilityUsable(state, seat, b.ability)) { + out.abilities.push({ zone: 'base', uid: 'base', op: b.ability.op }); + } + } + + for (const c of state.galaxy.row) { + const def = cardDef(c); + if (def.faction !== 'neutral' && def.faction !== p.faction) continue; + const cost = purchaseCost(state, seat, def); + if (cost <= p.resources) out.buys.push({ uid: c.uid, cost }); + } + if (state.outerRim.length) { + const orp = getData().json.outerRimPilot; + if (orp.cost <= p.resources) out.buys.push({ uid: 'outerrim', cost: orp.cost }); + } + + // Bounty hunting / sabotage: enemy-faction UNITS in the row only. + const freeUnits = p.inPlay.filter((e) => !e.committed); + const rowPower = freeUnits.reduce((a, e) => a + entryAttack(state, seat, e, { vsRow: true }), 0); + for (const c of state.galaxy.row) { + const def = cardDef(c); + if (def.faction === 'neutral' || def.faction === p.faction || def.type !== 'unit') continue; + if (def.target != null && rowPower >= def.target) out.rowTargets.push({ uid: c.uid, target: def.target }); + } + + const attackers = [...p.inPlay, ...p.capitals].filter((e) => !e.committed && entryAttack(state, seat, e) > 0); + if (attackers.length && (o.base || o.capitals.length)) { + out.baseAttackers = attackers.map((e) => e.card.uid); + } + return out; +} + +// ── Effect resolution ─────────────────────────────────────────────────────── +function queueOp(state, seat, ab, ctx = {}) { + switch (ab.op) { + case 'draw': { + let n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; + if (ab.nBase && P(state, seat).base?.id === ab.nBase.base) n = ab.nBase.n; + drawCards(state, seat, n); + return; + } + case 'gainResources': { + const n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; + P(state, seat).resources += n; + emit(state, { type: 'resources', seat, n }); + return; + } + case 'gainForce': moveForce(state, seat, ab.n); return; + case 'gainAttack': { + const n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; + if (ctx.entry) { ctx.entry.tempAttack += n; emit(state, { type: 'attackBoost', seat, uid: ctx.entry.card.uid, n }); } + return; + } + case 'damageBase': { + const n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; + dealBaseDamage(state, seat, n, { direct: true }); + return; + } + case 'repairBase': { + const p = P(state, seat); + if (p.base) { + const healed = Math.min(ab.n, p.base.damage); + p.base.damage -= healed; + emit(state, { type: 'repair', seat, n: healed }); + } + return; + } + case 'oppDiscard': { + const victim = opp(seat); + const random = ab.randomForce && forceWith(state, seat); + for (let i = 0; i < (ab.n || 1); i++) { + if (random) { + const o = P(state, victim); + if (!o.hand.length) break; + const c = state.rng.pick(o.hand); + o.hand.splice(o.hand.indexOf(c), 1); + o.discard.push(c); + emit(state, { type: 'discard', seat: victim, id: c.id, forced: true, random: true }); + } else if (P(state, victim).hand.length) { + state.effectQueue.push({ kind: 'oppDiscard', seat: victim }); + } + } + return; + } + case 'spyChoice': { + const victim = opp(seat); + const options = []; + if (P(state, victim).hand.length) options.push('discard'); + if (!forcePegged(state, seat)) options.push('force'); + if (!options.length) return; + if (options.length === 1 && options[0] === 'force') { moveForce(state, seat, 1); return; } + state.effectQueue.push({ kind: 'spyChoice', seat: victim, bySeat: seat, options }); + return; + } + case 'lookTop': { + const top = state.galaxy.deck[state.galaxy.deck.length - 1]; + if (!top) return; + state.effectQueue.push({ + kind: 'target', seat, op: 'lookTop', peekId: top.id, optional: false, + candidates: [{ kind: 'opt', v: 'keep' }, { kind: 'opt', v: 'discard' }], + }); + return; + } + case 'revealTop': { + const top = state.galaxy.deck[state.galaxy.deck.length - 1]; + if (!top) return; + const def = cardDef(top); + emit(state, { type: 'reveal', seat, id: top.id }); + const mine = P(state, seat).faction; + if (def.faction === mine) { + P(state, seat).resources += 1; + emit(state, { type: 'resources', seat, n: 1 }); + } else if (def.faction !== 'neutral') { + state.effectQueue.push({ + kind: 'target', seat, op: 'revealTopDiscard', peekId: top.id, optional: false, + candidates: [{ kind: 'opt', v: 'keep' }, { kind: 'opt', v: 'discard' }], + }); + } + return; + } + case 'exileCards': { + for (let i = 0; i < (ab.n || 1); i++) { + state.effectQueue.push({ + kind: 'target', seat, op: 'exileCards', optional: ab.optional !== false, + dynamic: 'exileCards', + candidates: abilityCandidates(state, seat, { op: 'exileCards' }), + }); + } + return; + } + case 'choose': { + const usable = ab.options.filter((sub) => abilityUsable(state, seat, sub)); + if (!usable.length) return; + if (usable.length === 1) { queueOp(state, seat, usable[0], ctx); return; } + state.effectQueue.push({ kind: 'chooseOption', seat, options: usable, ctx }); + return; + } + case 'discardRow': case 'destroyCapital': case 'recoverDiscard': case 'freePurchase': { + const candidates = abilityCandidates(state, seat, ab); + if (!candidates.length) return; + state.effectQueue.push({ + kind: 'target', seat, op: ab.op, ab, optional: ab.op === 'discardRow', + candidates, + }); + return; + } + default: + throw new Error(`unsupported op ${ab.op}`); + } +} + +// Direct base damage (card abilities): ignores capital ships. +function dealBaseDamage(state, seat, n, { direct = false } = {}) { + const o = P(state, opp(seat)); + if (!o.base || n <= 0) return; + o.base.damage += n; + emit(state, { type: 'baseDamage', seat: o.seat, n, direct }); + checkBaseDestroyed(state, seat); +} + +function checkBaseDestroyed(state, attackerSeat) { + const o = P(state, opp(attackerSeat)); + if (!o.base) return; + const def = baseDef(o.faction, o.base.id); + if (o.base.damage < def.hp) return; + o.lostBases++; + emit(state, { type: 'baseDestroyed', seat: o.seat, id: o.base.id, lost: o.lostBases }); + o.base = null; + o.baseUsed = false; + if (o.lostBases >= state.meta.basesToWin) endGame(state, attackerSeat, 'bases'); +} + +function fireOnBounty(state, seat) { + const p = P(state, seat); + for (const e of [...p.inPlay, ...p.capitals]) { + const ab = cardDef(e.card).ability; + if (ab && ab.timing === 'onBounty') queueOp(state, seat, ab, { entry: e }); + } +} + +// ── Mutators ──────────────────────────────────────────────────────────────── +export function actTurn(state, seat, action) { + assertDecision(state, seat, 'turn'); + const p = P(state, seat); + switch (action?.type) { + case 'play': { + const i = p.hand.findIndex((c) => c.uid === action.uid); + if (i < 0) throw new Error('card not in hand'); + playCard(state, seat, p.hand.splice(i, 1)[0]); + return; + } + case 'playAll': { + while (p.hand.length && !state.effectQueue.length) playCard(state, seat, p.hand.shift()); + return; + } + case 'ability': return useAbility(state, seat, action); + case 'buy': return buyCard(state, seat, action.uid); + case 'attackRow': return attackRow(state, seat, action); + case 'attackBase': return attackBase(state, seat, action); + case 'endTurn': return endTurn(state, seat); + default: throw new Error(`unknown turn action ${action?.type}`); + } +} + +function playCard(state, seat, card) { + const p = P(state, seat); + const def = cardDef(card); + const entry = { card, tempAttack: 0, used: false, committed: false }; + if (def.type === 'capital') { entry.damage = 0; p.capitals.push(entry); } + else p.inPlay.push(entry); + emit(state, { type: 'play', seat, id: card.id, uid: card.uid, capital: def.type === 'capital' }); + if (def.resources) { p.resources += def.resources; emit(state, { type: 'resources', seat, n: def.resources }); } + if (def.force) moveForce(state, seat, def.force); + // Base auras that trigger on play (Yavin IV). + if (p.base && def.type === 'unit') { + const b = baseDef(p.faction, p.base.id); + if (b.ability?.timing === 'constant' && b.ability.op === 'onPlayTraitAttack' + && (def.traits || []).includes(b.ability.trait)) { + entry.tempAttack += b.ability.amount; + emit(state, { type: 'attackBoost', seat, uid: card.uid, n: b.ability.amount, from: 'base' }); + } + } + if (def.ability?.timing === 'onPlay') queueOp(state, seat, def.ability, { entry }); +} + +function useAbility(state, seat, action) { + const p = P(state, seat); + let ab, entry = null; + if (action.zone === 'base') { + if (!p.base) throw new Error('no base'); + const b = baseDef(p.faction, p.base.id); + ab = b.ability; + if (!ab || ab.timing !== 'action' || p.baseUsed) throw new Error('base ability unavailable'); + } else { + entry = findEntry(p, action.uid); + if (!entry) throw new Error('card not in play'); + ab = cardDef(entry.card).ability; + if (!ab || ab.timing !== 'action' || entry.used) throw new Error('ability unavailable'); + } + if (!abilityUsable(state, seat, ab)) throw new Error('ability not usable now'); + + // Pay costs first (rulebook: exileSelf cards keep their printed values — + // those were already collected at play time). + if (ab.cost?.exileFromHand) { + const c = p.hand.find((x) => x.uid === action.costUid); + if (!c) throw new Error('exileFromHand cost needs a hand card (costUid)'); + p.hand.splice(p.hand.indexOf(c), 1); + state.exile.push(c); + emit(state, { type: 'exile', seat, id: c.id, from: 'hand', cost: true }); + } + if (ab.cost?.exileSelf && entry) { + const pool = cardDef(entry.card).type === 'capital' ? p.capitals : p.inPlay; + pool.splice(pool.indexOf(entry), 1); + state.exile.push(entry.card); + emit(state, { type: 'exile', seat, id: entry.card.id, from: 'play', cost: true }); + } + if (action.zone === 'base') p.baseUsed = true; + else entry.used = true; + emit(state, { type: 'ability', seat, id: action.zone === 'base' ? p.base?.id ?? 'base' : entry.card.id, op: ab.op }); + queueOp(state, seat, ab, { entry }); +} + +function buyCard(state, seat, uid) { + const p = P(state, seat); + let card, def; + if (uid === 'outerrim') { + if (!state.outerRim.length) throw new Error('Outer Rim Pilots exhausted'); + def = getData().json.outerRimPilot; + if (p.resources < def.cost) throw new Error('cannot afford'); + p.resources -= def.cost; + card = state.outerRim.pop(); + } else { + card = rowCard(state, uid); + if (!card) throw new Error('card not in row'); + def = cardDef(card); + if (def.faction !== 'neutral' && def.faction !== p.faction) throw new Error('enemy faction card'); + const cost = purchaseCost(state, seat, def); + if (p.resources < cost) throw new Error('cannot afford'); + p.resources -= cost; + removeRowCard(state, uid); + } + p.discard.push(card); + emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: def.cost }); +} + +function attackRow(state, seat, action) { + const p = P(state, seat); + const target = rowCard(state, action.targetUid); + if (!target) throw new Error('target not in row'); + const tdef = cardDef(target); + if (tdef.faction === 'neutral' || tdef.faction === p.faction || tdef.type !== 'unit' || tdef.target == null) { + throw new Error('not a legal bounty target'); + } + const entries = (action.uids || []).map((uid) => { + const e = p.inPlay.find((x) => x.card.uid === uid); + if (!e) throw new Error('attacker not among your units in play'); + if (e.committed) throw new Error('attacker already committed'); + return e; + }); + if (!entries.length) throw new Error('no attackers'); + const total = entries.reduce((a, e) => a + entryAttack(state, seat, e, { vsRow: true }), 0); + if (total < tdef.target) throw new Error(`attack ${total} < target ${tdef.target}`); + for (const e of entries) e.committed = true; + removeRowCard(state, target.uid); + state.galaxy.discard.push(target); + emit(state, { type: 'bounty', seat, id: target.id, uid: target.uid, total, target: tdef.target }); + const r = tdef.reward || {}; + if (r.resources) { p.resources += r.resources; emit(state, { type: 'resources', seat, n: r.resources, reward: true }); } + if (r.force) moveForce(state, seat, r.force); + if (r.draw) drawCards(state, seat, r.draw); + fireOnBounty(state, seat); +} + +function attackBase(state, seat, action) { + const p = P(state, seat); + const o = P(state, opp(seat)); + const entries = (action.uids || []).map((uid) => { + const e = findEntry(p, uid); + if (!e) throw new Error('attacker not in play'); + if (e.committed) throw new Error('attacker already committed'); + return e; + }); + if (!entries.length) throw new Error('no attackers'); + let pool = entries.reduce((a, e) => a + entryAttack(state, seat, e), 0); + if (pool <= 0) throw new Error('no attack power'); + for (const e of entries) e.committed = true; + emit(state, { type: 'attackBase', seat, total: pool }); + + // Capital ships absorb first. Attacker may order them via capitalOrder. + const order = []; + for (const uid of action.capitalOrder || []) { + const e = o.capitals.find((x) => x.card.uid === uid); + if (e && !order.includes(e)) order.push(e); + } + for (const e of o.capitals) if (!order.includes(e)) order.push(e); + for (const e of order) { + if (pool <= 0) break; + const hp = cardDef(e.card).hp; + const dealt = Math.min(pool, hp - e.damage); + e.damage += dealt; + pool -= dealt; + emit(state, { type: 'capitalDamage', seat: o.seat, uid: e.card.uid, n: dealt, hp, damage: e.damage }); + if (e.damage >= hp) { + o.capitals.splice(o.capitals.indexOf(e), 1); + o.discard.push(e.card); + emit(state, { type: 'capitalDestroyed', seat: o.seat, id: e.card.id, uid: e.card.uid }); + } + } + if (pool > 0 && o.capitals.length === 0 && o.base) { + o.base.damage += pool; + emit(state, { type: 'baseDamage', seat: o.seat, n: pool, direct: false }); + checkBaseDestroyed(state, seat); + } +} + +function endTurn(state, seat) { + const p = P(state, seat); + for (const e of p.inPlay) p.discard.push(e.card); + p.inPlay = []; + for (const c of p.hand) p.discard.push(c); + p.hand = []; + p.resources = 0; + drawCards(state, seat, state.meta.handSize); + emit(state, { type: 'turnEnd', seat }); + state.turnSeat = opp(seat); + beginTurn(state); +} + +export function actChooseBase(state, seat, baseId) { + assertDecision(state, seat, 'chooseBase'); + const p = P(state, seat); + const i = p.baseDeck.indexOf(baseId); + if (i < 0) throw new Error(`base ${baseId} not available`); + p.baseDeck.splice(i, 1); + p.base = { id: baseId, damage: 0 }; + p.baseUsed = false; + state.effectQueue.shift(); + emit(state, { type: 'baseChosen', seat, id: baseId }); + const b = baseDef(p.faction, baseId); + if (b.ability?.timing === 'onReveal') queueOp(state, seat, b.ability, {}); +} + +export function actChooseTarget(state, seat, ref) { + const d = assertDecision(state, seat, 'target'); + const q = state.effectQueue[0]; + if (!ref) { + if (!d.optional) throw new Error('target required'); + state.effectQueue.shift(); + emit(state, { type: 'skip', seat, op: q.op }); + return; + } + const match = q.candidates.find((c) => c.kind === ref.kind && c.uid === ref.uid && c.v === ref.v && c.zone === ref.zone && c.seat === ref.seat); + if (!match) throw new Error('target not among candidates'); + state.effectQueue.shift(); + applyTarget(state, seat, q, match); +} + +function applyTarget(state, seat, q, ref) { + const p = P(state, seat); + switch (q.op) { + case 'discardRow': { + const card = removeRowCard(state, ref.uid); + if (card) { state.galaxy.discard.push(card); emit(state, { type: 'rowDiscard', seat, id: card.id }); } + return; + } + case 'destroyCapital': { + if (ref.kind === 'rowCapital') { + const card = removeRowCard(state, ref.uid); + if (card) { state.galaxy.discard.push(card); emit(state, { type: 'capitalDestroyed', row: true, id: card.id, uid: card.uid }); } + } else { + const o = P(state, ref.seat); + const i = o.capitals.findIndex((e) => e.card.uid === ref.uid); + if (i >= 0) { + const [e] = o.capitals.splice(i, 1); + o.discard.push(e.card); + emit(state, { type: 'capitalDestroyed', seat: o.seat, id: e.card.id, uid: e.card.uid }); + } + } + return; + } + case 'exileCards': { + const zone = ref.zone === 'hand' ? p.hand : p.discard; + const i = zone.findIndex((c) => c.uid === ref.uid); + if (i >= 0) { + const [c] = zone.splice(i, 1); + state.exile.push(c); + emit(state, { type: 'exile', seat, id: c.id, from: ref.zone }); + } + return; + } + case 'recoverDiscard': { + const i = p.discard.findIndex((c) => c.uid === ref.uid); + if (i >= 0) { + const [c] = p.discard.splice(i, 1); + p.hand.push(c); + emit(state, { type: 'recover', seat, id: c.id }); + } + return; + } + case 'freePurchase': { + const card = removeRowCard(state, ref.uid); + if (!card) return; + const topdeck = q.ab?.forceTopdeck && forceWith(state, seat); + if (topdeck) p.deck.push(card); else p.discard.push(card); + emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: 0, free: true, topdeck: !!topdeck }); + return; + } + case 'lookTop': { + if (ref.v === 'discard') { + const c = state.galaxy.deck.pop(); + if (c) { state.galaxy.discard.push(c); emit(state, { type: 'galaxyTopDiscard', seat, id: c.id }); } + } else { + emit(state, { type: 'galaxyTopKept', seat }); + } + return; + } + case 'revealTopDiscard': { + if (ref.v === 'discard') { + const c = state.galaxy.deck.pop(); + if (c) { state.galaxy.discard.push(c); emit(state, { type: 'galaxyTopDiscard', seat, id: c.id }); } + } else { + emit(state, { type: 'galaxyTopKept', seat }); + } + return; + } + default: + throw new Error(`unhandled target op ${q.op}`); + } +} + +export function actOppDiscard(state, seat, uid) { + assertDecision(state, seat, 'oppDiscard'); + const p = P(state, seat); + const i = p.hand.findIndex((c) => c.uid === uid); + if (i < 0) throw new Error('card not in hand'); + const [c] = p.hand.splice(i, 1); + p.discard.push(c); + state.effectQueue.shift(); + emit(state, { type: 'discard', seat, id: c.id, forced: true }); +} + +export function actSpyChoice(state, seat, choice) { + const d = assertDecision(state, seat, 'spyChoice'); + if (!d.options.includes(choice)) throw new Error(`spyChoice must be one of ${d.options}`); + const q = state.effectQueue.shift(); + if (choice === 'force') { + moveForce(state, q.bySeat, 1); + } else { + // Victim still picks which card — queue their discard. + state.effectQueue.unshift({ kind: 'oppDiscard', seat }); + } +} + +export function actChooseOption(state, seat, idx) { + const d = assertDecision(state, seat, 'chooseOption'); + if (idx == null || idx < 0 || idx >= d.options.length) throw new Error('bad option index'); + const q = state.effectQueue.shift(); + queueOp(state, seat, q.options[idx], q.ctx || {}); +} + +// ── Public (redacted) view ────────────────────────────────────────────────── +// Own hand visible; opponent hand, both decks, the galaxy deck and the Outer +// Rim stack become stubs (lengths stay honest). Discards and exile are public. +export function publicView(state, seat) { + const stub = () => ({ uid: -1, id: null, hidden: true }); + return { + seed: state.seed, meta: state.meta, force: state.force, + turnSeat: state.turnSeat, turnCount: state.turnCount, + effectQueue: state.effectQueue.map((q) => ({ kind: q.kind, seat: q.seat })), + galaxy: { + deck: state.galaxy.deck.map(stub), + row: state.galaxy.row, + discard: state.galaxy.discard, + }, + outerRim: state.outerRim.map(stub), + exile: state.exile, + players: state.players.map((p) => (p.seat === seat ? p : { + seat: p.seat, faction: p.faction, + deck: p.deck.map(stub), + hand: p.hand.map(stub), + discard: p.discard, + inPlay: p.inPlay, capitals: p.capitals, + base: p.base, baseUsed: p.baseUsed, baseDeck: p.baseDeck, + lostBases: p.lostBases, resources: p.resources, + })), + over: state.over, winner: state.winner, + }; +} diff --git a/src/games/swdbg/sprites.md b/src/games/swdbg/sprites.md new file mode 100644 index 0000000..cc7f7f7 --- /dev/null +++ b/src/games/swdbg/sprites.md @@ -0,0 +1,171 @@ +# Star Wars Deckbuilder — Sprite / Art Spec + +Everything in SWDBG renders procedurally out of the box (faction-colored frame, +cost badge, stats, name plate, rules text), so the game is fully playable with +**no** art. This document lists the optional sprite sheets you can drop in to +replace the placeholder art windows, with exact dimensions, layout, and +frame-by-frame maps. + +All art is wired through **`public/data/swdbg-artwork.json`**. The scene checks +whether each sheet's texture is loaded; if it is, it uses your frame, otherwise +it falls back to procedural drawing. You never need to touch code — just add a +PNG, set its `path` in that JSON (it's currently empty for both sheets), and +reload. + +Frame numbering is **row-major, 0-based**: frame 0 is top-left, count +left-to-right then down to the next row. + +Only paint the illustration — the card frame, border, faction edge color, +unique-card gold inset, cost/stat badges, name plate, and rules text are all +drawn procedurally on top of your art. Art is scaled to **fit** the art window +(no cropping), so match the aspect ratio below for edge-to-edge fill; other +aspects letterbox inside the window. + +--- + +## 1. Card art sheet — `swdbg-cards-art.png` + +| | | +|---|---| +| **Path** | `public/assets/images/swdbg-cards-art.png` | +| **Frame size** | **300 × 230 px** (aspect ≈ 1.30 : 1, matches the card art window) | +| **Sheet size** | **8 columns × 7 rows = 2400 × 1610 px** holds all 55 frames (1 spare) | +| **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) | +| **JSON** | `cardSheet` (set `path`) + `cards` map (already filled in for all 55 ids) | + +The art window sits just below the name plate on every unit/ship/character +card. It's shown at roughly 100×77 px on a resting hand card and up to +~280×215 px in the inspect/reveal zoom, so design for readability at the +smaller size — bold silhouettes and clear color-blocking over fine detail. + +### Frame map + +Faction legend: **E** = Empire, **R** = Rebel, **N** = neutral/outer rim. + +| Frame | id | Name | Faction | Notes | +|---:|---|---|---|---| +| 0 | `allianceshuttle` | Alliance Shuttle | R | | +| 1 | `rebeltrooper` | Rebel Trooper | R | | +| 2 | `templeguardian` | Temple Guardian | R | | +| 3 | `imperialshuttle` | Imperial Shuttle | E | | +| 4 | `stormtrooper` | Stormtrooper | E | | +| 5 | `inquisitor` | Inquisitor | E | | +| 6 | `outerrimpilot` | Outer Rim Pilot | N | shared neutral card | +| 7 | `tiefighter` | TIE Fighter | E | | +| 8 | `scouttrooper` | Scout Trooper | E | | +| 9 | `deathtrooper` | Death Trooper | E | | +| 10 | `tiebomber` | TIE Bomber | E | | +| 11 | `tieinterceptor` | TIE Interceptor | E | | +| 12 | `atst` | AT-ST | E | | +| 13 | `atat` | AT-AT | E | | +| 14 | `generalveers` | General Veers | E | unique | +| 15 | `grandmofftarkin` | Grand Moff Tarkin | E | unique | +| 16 | `directorkrennic` | Director Krennic | E | unique | +| 17 | `darthvader` | Darth Vader | E | unique | +| 18 | `emperorpalpatine` | Emperor Palpatine | E | unique | +| 19 | `gozanticruiser` | Gozanti Cruiser | E | | +| 20 | `imperialcarrier` | Imperial Carrier | E | | +| 21 | `stardestroyer` | Star Destroyer | E | | +| 22 | `rebelpathfinder` | Rebel Pathfinder | R | | +| 23 | `ywing` | Y-Wing | R | | +| 24 | `snowspeeder` | Snowspeeder | R | | +| 25 | `durosspy` | Duros Spy | R | | +| 26 | `xwing` | X-Wing | R | | +| 27 | `rebelcommando` | Rebel Commando | R | | +| 28 | `cassianandor` | Cassian Andor | R | unique | +| 29 | `k2so` | K-2SO | R | unique | +| 30 | `chirrutimwe` | Chirrut Îmwe | R | unique | +| 31 | `jynerso` | Jyn Erso | R | unique | +| 32 | `chewbacca` | Chewbacca | R | unique | +| 33 | `hansolo` | Han Solo | R | unique | +| 34 | `princessleia` | Princess Leia | R | unique | +| 35 | `lukeskywalker` | Luke Skywalker | R | unique | +| 36 | `blockaderunner` | Blockade Runner | R | | +| 37 | `hammerheadcorvette` | Hammerhead Corvette | R | | +| 38 | `medicalfrigate` | Medical Frigate | R | | +| 39 | `moncalamaricruiser` | Mon Calamari Cruiser | R | | +| 40 | `millenniumfalcon` | Millennium Falcon | R | unique | +| 41 | `z95headhunter` | Z-95 Headhunter | N | | +| 42 | `jawascavenger` | Jawa Scavenger | N | | +| 43 | `keldormystic` | Kel Dor Mystic | N | | +| 44 | `rodiangunslinger` | Rodian Gunslinger | N | | +| 45 | `hwk290` | HWK-290 | N | | +| 46 | `twileksmuggler` | Twi'lek Smuggler | N | | +| 47 | `fangfighter` | Fang Fighter | N | | +| 48 | `quarrenmercenary` | Quarren Mercenary | N | | +| 49 | `bossk` | Bossk | N | unique bounty hunter | +| 50 | `ig88` | IG-88 | N | unique bounty hunter | +| 51 | `bobafett` | Boba Fett | N | unique bounty hunter | +| 52 | `jabbathehutt` | Jabba the Hutt | N | unique | +| 53 | `crocruiser` | Crolute Cruiser | N | | +| 54 | `khetanna` | Khetanna (Jabba's Sail Barge) | N | unique | + +> Priority: uniques (14–18, 28–35, 40, 49–52, 54) and starters/commons that +> show up every game (0–5, 7–9, 22–24, 26–27) read best painted first. + +--- + +## 2. Base art sheet — `swdbg-bases-art.png` + +| | | +|---|---| +| **Path** | `public/assets/images/swdbg-bases-art.png` | +| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the base art window) | +| **Sheet size** | **5 columns × 2 rows = 1100 × 600 px** holds all 10 frames | +| **Status** | ⛔ Procedural until you create it (then set `baseSheet.path` in the JSON) | +| **JSON** | `baseSheet` (set `path`) + `bases` map (already filled in for all 10 ids) | + +Bases are the large planet/location cards planted on each player's side of the +board (shown at ~300×190 landscape during play — note the base *card* itself +is wider-than-tall on screen, but the art window inside it is a portrait +crop, so compose art with a strong vertical focal point that still reads when +letterboxed). Also shown up to 560×340 in the win/lose reveal. + +### Frame map + +| Frame | id | Name | Notes | +|---:|---|---|---| +| 0 | `dantooine` | Dantooine | Rebel base, grassy plains | +| 1 | `hoth` | Hoth | Rebel base, ice world | +| 2 | `sullust` | Sullust | Rebel base, volcanic | +| 3 | `moncala` | Mon Cala | Rebel base, ocean world | +| 4 | `yavin4` | Yavin 4 | Rebel base, jungle moon/temple | +| 5 | `lothal` | Lothal | Imperial base, plains/wheat | +| 6 | `corellia` | Corellia | Imperial base, shipyards | +| 7 | `coruscant` | Coruscant | Imperial base, cityscape | +| 8 | `deathstar` | Death Star | Imperial base, superweapon | +| 9 | `endor` | Endor | Imperial base, forest moon | + +--- + +## 3. Menu icon — `game-icons.png` frame **78** *(shared sheet)* + +| | | +|---|---| +| **File** | `public/assets/images/game-icons.png` (existing shared sheet) | +| **Sheet size** | 660 × 660 px | +| **Frame size** | **44 × 44 px** | +| **Layout** | 15 columns × 15 rows | +| **SWDBG frame** | **78** → row 5, col 3 → pixel box **x 132–176, y 220–264** | + +Add a 44×44 icon for the Star Wars Deckbuilder into cell 78 of the existing +sheet (the menu tile pulls `game-icons` frame `78`). Keep it readable at tiny +size — e.g. a crossed rebel/imperial insignia, a small TIE fighter or X-Wing +silhouette, or the Force-track balance symbol. This is the only sprite that +goes into an **existing** sheet rather than a new file. + +--- + +## Quick checklist + +- [ ] `swdbg-cards-art.png` — 2400×1610 (8×7 grid), 300×230 per frame, 55 frames (0–54); then set `cardSheet.path` in `swdbg-artwork.json`. + - Priority 1 (uniques/leaders): 14–18, 28–35, 40, 49–52, 54 + - Priority 2 (common starters): 0–5, 7–9, 22–24, 26–27 + - Priority 3 (remaining Empire/Rebel commons): 10–13, 19–21, 36–39 + - Priority 4 (neutral/outer rim): 6, 41–48, 53 +- [ ] `swdbg-bases-art.png` — 1100×600 (5×2 grid), 220×300 per frame, 10 frames (0–9); then set `baseSheet.path` in `swdbg-artwork.json`. +- [ ] `game-icons.png` frame 78 — 44×44 menu icon. + +The frame→id mappings for both sheets are already filled in in +`public/data/swdbg-artwork.json` — once you drop the PNGs in and set the two +`path` fields, art appears with no code changes. diff --git a/src/games/swdbg/tutorial.md b/src/games/swdbg/tutorial.md new file mode 100644 index 0000000..018bb93 --- /dev/null +++ b/src/games/swdbg/tutorial.md @@ -0,0 +1,72 @@ +# Star Wars Deckbuilder + +> **A protocol droid shuffles forward and bows stiffly…** +> +> Greetings! I am T-3PO, tactical liaison droid, fluent in over six million +> forms of warfare. You and your opponent will each take a side — the Galactic +> Empire or the Rebel Alliance — and wage war across the galaxy one card at a +> time. The first commander to **destroy three enemy bases** wins. The odds of +> surviving all three? I'd really rather not say. + +## Your Turn, Commander + +Each turn you may do any of the following, in any order, as often as you like: + +1. **Play cards** from your hand. Each card grants its printed **⚔ attack**, + **▣ resources**, and **◈ Force** the moment it hits the table. +2. **Purchase cards** from the six-card **galaxy row** with resources. You may + buy cards of **your faction** and **neutral** cards — never the enemy's. + The **Outer Rim Pilot** stack is always for sale. Purchases go to your + discard pile and join your deck when it reshuffles. +3. **Use abilities** — each card in play may use its ability once per turn. +4. **Attack!** Group your units and strike (see below). + +At the end of your turn, everything you played and everything left in your +hand is discarded, unspent resources evaporate, and you draw five fresh cards. + +## Two Ways to Attack + +- **Strike the enemy base.** Total the ⚔ of your committed cards. The enemy's + **capital ships** must be destroyed before a single point reaches their + base — but damage on ships and bases *sticks* between turns. Grind them down! +- **Bounty hunting / sabotage.** Enemy-faction cards in the galaxy row show a + **◎ target value**. Meet it with committed units and the card is destroyed — + denying it to your rival — and you pocket the printed **reward**. Capital + ships can't join these missions; they have a war to fight. + +Each card may join only one attack per turn. + +## The Balance of the Force + +Cards with **◈** slide the Force marker toward your side of the track. + +- While the marker is on **your side**, "If the Force is with you…" abilities + switch on and several cards grow stronger. +- If it is **all the way** to your side at the start of your turn: **+1 + resource**. The Rebels begin the game with the Force fully behind them — + the Empire begins with the first move. Fair? War rarely is. + +## Bases and Capital Ships + +You defend **five bases**, one at a time, starting with your outpost. When a +base falls, you pick your next stronghold at the start of your following turn — +each has more hit points and a fresh ability. Lose three and the war is over. + +**Capital ships** stay in play turn after turn, producing resources every +round and adding their guns to every assault. They are magnificent — and they +make magnificent targets. + +## T-3PO's Tactical Memoranda + +- **Thin your deck.** Exiling starter shuttles (Jawas and Mystics oblige) + means drawing your good cards twice as often. This is mathematics, not + treason. +- **Deny the row.** If a card would be splendid in your enemy's deck, bounty + hunt it before they can afford it. +- **Mind the Balance.** Ceding the Force to a foe full of "if the Force is + with you" cards is how bases end up as craters. +- **Ships before base.** Attacking into a wall of capital ships wastes + firepower — unless you soften them one turn and finish the job the next. + Damage, delightfully, is permanent. + +Now then — the galaxy won't conquer itself. Do try to keep me in one piece. diff --git a/src/main.js b/src/main.js index f0967d0..989ea05 100644 --- a/src/main.js +++ b/src/main.js @@ -84,6 +84,8 @@ import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js'; import SpireClimbGame from './games/spireclimb/SpireClimbGame.js'; import AzulGame from './games/azul/AzulGame.js'; import JumbleGame from './games/jumble/JumbleGame.js'; +import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js'; +import SWDBGGame from './games/swdbg/SWDBGGame.js'; const config = { type: Phaser.AUTO, @@ -181,6 +183,8 @@ const config = { SpireClimbGame, AzulGame, JumbleGame, + DungeonBossGame, + SWDBGGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 8e439b5..a809393 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/src/scenes/OpponentSelectScene.js b/src/scenes/OpponentSelectScene.js index 9883971..c8d2803 100644 --- a/src/scenes/OpponentSelectScene.js +++ b/src/scenes/OpponentSelectScene.js @@ -403,7 +403,7 @@ export default class OpponentSelectScene extends Phaser.Scene { // Skill control: pips always show the level; the +/- buttons appear only // when this opponent is selected. Enabled for games with a 1–5 AI skill. - if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare', 'azul'].includes(this.gameDef.slug)) { + if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare', 'azul', 'dungeonboss', 'swdbg'].includes(this.gameDef.slug)) { bio.style.webkitLineClamp = '1'; const skillRow = document.createElement('div'); diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 45745fa..2750f91 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -73,6 +73,9 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('katamino', 'data/katamino.json'); this.load.json('bookwork', 'data/bookwork.json'); this.load.json('spireclimb-artwork', 'data/spireclimb-artwork.json'); + this.load.json('dungeonboss-artwork', 'data/dungeonboss-artwork.json'); + this.load.json('swdbg-cards', 'data/swdbg-cards.json'); + this.load.json('swdbg-artwork', 'data/swdbg-artwork.json'); this.load.json('jumble', 'data/jumble.json'); this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png'); this.load.image('spireclimb-act2', 'assets/images/spireclimb-act2.png'); @@ -225,9 +228,19 @@ export default class PreloadScene extends Phaser.Scene { const scSheets = [scArt?.cardSheet, scArt?.creatureSheet, ...Object.values(scArt?.playerSheets || {})] .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); - if (toLoad.length > 0 || scSheets.length > 0) { + // Dungeon Boss drop-in spritesheets, same contract as Spire Climb's. + const dbArt = this.cache.json.get('dungeonboss-artwork'); + const dbSheets = [dbArt?.roomSheet, dbArt?.heroSheet, dbArt?.spellSheet, dbArt?.bossSheet] + .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); + + // Star Wars Deckbuilder drop-in spritesheets, same contract. + const swArt = this.cache.json.get('swdbg-artwork'); + const swSheets = [swArt?.cardSheet, swArt?.baseSheet] + .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); + + if (toLoad.length > 0 || scSheets.length > 0 || dbSheets.length > 0 || swSheets.length > 0) { for (const asset of toLoad) this.load.image(asset.key, asset.path); - for (const s of scSheets) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight }); + for (const s of [...scSheets, ...dbSheets, ...swSheets]) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight }); await new Promise((resolve) => { this.load.once('complete', resolve); this.load.start(); diff --git a/tools/verifyDungeonBoss.js b/tools/verifyDungeonBoss.js new file mode 100644 index 0000000..6845347 --- /dev/null +++ b/tools/verifyDungeonBoss.js @@ -0,0 +1,322 @@ +// verifyDungeonBoss.js — headless checks for the Dungeon Boss engine. +// Run from repo root: node tools/verifyDungeonBoss.js +// Imports only Data/Logic/AI (never the Phaser scene). + +import { + CLASSES, BOSSES, ROOMS, SPELLS, HEROES, OPS, PASSIVES, + HERO_DECK_SIZING, heroSouls, heroWounds, roomDef, spellDef, heroDef, +} from '../src/games/dungeonboss/DungeonBossData.js'; +import { + newGame, makeRng, pendingDecision, takeEvents, publicView, + actSetupDiscard, actBuild, actWindow, actReact, actChooseTarget, actDiscard, actRoomDraw, + legalBuilds, windowActions, treasureCount, dungeonDamage, baitTargets, + isOver, finalRanking, SOULS_TO_WIN, WOUNDS_TO_DIE, MAX_ROOMS, +} from '../src/games/dungeonboss/DungeonBossLogic.js'; +import { decide } from '../src/games/dungeonboss/DungeonBossAI.js'; + +let pass = 0; +let fail = 0; +function ok(cond, msg) { + if (cond) { pass++; return true; } + fail++; + console.error(` ✗ ${msg}`); + return false; +} + +// ── 1. Content sanity ──────────────────────────────────────────────────────── +console.log('Content sanity…'); +{ + ok(Object.keys(BOSSES).length === 8, 'exactly 8 bosses'); + const xps = Object.values(BOSSES).map((b) => b.xp); + ok(new Set(xps).size === 8, 'boss XP values unique'); + for (const [id, b] of Object.entries(BOSSES)) { + ok(CLASSES.includes(b.treasure), `boss ${id} treasure class valid`); + ok(OPS.includes(b.levelUp.op), `boss ${id} level-up op supported`); + ok(!!b.name && !!b.text, `boss ${id} name/text present`); + } + + let roomCards = 0; + for (const [id, r] of Object.entries(ROOMS)) { + roomCards += r.count; + ok(['monster', 'trap'].includes(r.type), `room ${id} type valid`); + ok(r.dmg >= 0, `room ${id} damage >= 0`); + ok(Object.keys(r.treasure || {}).every((c) => CLASSES.includes(c)), `room ${id} treasure classes valid`); + for (const eff of (r.effects || [])) ok(OPS.includes(eff.op), `room ${id} op ${eff.op} supported`); + if (r.passive) ok(PASSIVES.includes(r.passive), `room ${id} passive ${r.passive} supported`); + if (r.advanced) { + ok(Object.keys(r.treasure).length > 0, `advanced room ${id} has a treasure icon (buildable)`); + } + ok(!!r.name && !!r.text, `room ${id} name/text present`); + } + ok(roomCards === 75, `room deck is 75 cards (got ${roomCards})`); + + let spellCards = 0; + for (const [id, s] of Object.entries(SPELLS)) { + spellCards += s.count; + ok(['build', 'adventure', 'both'].includes(s.phase), `spell ${id} phase valid`); + ok(OPS.includes(s.op.op), `spell ${id} op supported`); + ok(!!s.name && !!s.text, `spell ${id} name/text present`); + } + ok(spellCards === 31, `spell deck is 31 cards (got ${spellCards})`); + + const ordinary = Object.values(HEROES).filter((h) => !h.epic); + const epics = Object.values(HEROES).filter((h) => h.epic); + ok(ordinary.length === 25, `25 ordinary heroes (got ${ordinary.length})`); + ok(epics.length === 16, `16 epic heroes (got ${epics.length})`); + for (const cls of CLASSES) { + const o = ordinary.filter((h) => h.cls === cls); + ok(o.length === 6, `6 ordinary ${cls} heroes`); + ok(o.some((h) => h.hp <= 4), `${cls} has a killable low-hp hero`); + ok(epics.filter((h) => h.cls === cls).length === 4, `4 epic ${cls} heroes`); + } + ok(Object.values(HEROES).filter((h) => h.fool).length === 1, 'exactly one Fool'); +} + +// ── 2. Setup invariants ────────────────────────────────────────────────────── +console.log('Setup invariants…'); +{ + for (const n of [2, 3, 4]) { + const st = newGame(n, 12345 + n); + ok(st.players.length === n, `${n}p: player count`); + for (const p of st.players) { + ok(p.hand.rooms.length === 5 && p.hand.spells.length === 2, `${n}p: dealt 5 rooms + 2 spells`); + } + const sizing = HERO_DECK_SIZING[n]; + ok(st.decks.heroes.length === sizing.ordinaryPerClass * 4 + 1, + `${n}p: ordinary hero deck sized (${st.decks.heroes.length})`); + ok(st.decks.epics.length === sizing.epicPerClass * 4, `${n}p: epic deck sized`); + const d = pendingDecision(st); + ok(d && d.kind === 'setupDiscard', `${n}p: first decision is setupDiscard`); + // Turn order sorted by XP descending. + const xps = st.turnOrder.map((s) => BOSSES[st.players[s].boss.id].xp); + ok(xps.every((x, i) => i === 0 || xps[i - 1] >= x), `${n}p: turn order by XP desc`); + } +} + +// ── AI driver ──────────────────────────────────────────────────────────────── +function driveDecision(st, d, skill, rnd, tally) { + const view = publicView(st, d.seat); + const choice = decide(view, d, skill, rnd); + switch (d.kind) { + case 'setupDiscard': actSetupDiscard(st, d.seat, choice); break; + case 'build': { + if (choice) { + const legal = legalBuilds(st, d.seat); + ok(legal.some((b) => b.roomUid === choice.roomUid && b.slotIdx === choice.slotIdx), + 'AI build choice is legal'); + } + actBuild(st, d.seat, choice); + break; + } + case 'window': actWindow(st, d.seat, choice); if (tally && choice && !choice.pass) tally.casts++; break; + case 'react': actReact(st, d.seat, choice); break; + case 'target': actChooseTarget(st, d.seat, choice); break; + case 'discard': actDiscard(st, d.seat, choice); break; + case 'roomDraw': actRoomDraw(st, d.seat, choice); break; + default: throw new Error(`unknown decision kind ${d.kind}`); + } +} + +function playGame(nPlayers, seed, skills, collect = null) { + const st = newGame(nPlayers, seed); + const rnd = makeRng(seed ^ 0x5eed); + const tally = { casts: 0 }; + let steps = 0; + const eventLog = []; + while (!isOver(st)) { + if (++steps > 20000) throw new Error(`game ${seed} exceeded step guard (round ${st.round})`); + const d = pendingDecision(st); + if (!d) throw new Error(`game ${seed}: no decision but game not over (phase ${st.phase}, round ${st.round})`); + if (!st.players[d.seat].alive) throw new Error(`game ${seed}: decision for dead seat ${d.seat}`); + driveDecision(st, d, skills[d.seat] ?? 3, rnd, tally); + checkInvariants(st, seed); + if (collect !== null) eventLog.push(...takeEvents(st).map((e) => JSON.stringify(e))); + else takeEvents(st); + } + if (collect !== null) collect.push(...eventLog); + return { st, tally, steps }; +} + +function checkInvariants(st, seed) { + for (const p of st.players) { + if (p.dungeon.length > MAX_ROOMS) throw new Error(`game ${seed}: dungeon > ${MAX_ROOMS}`); + if (p.alive && p.wounds >= WOUNDS_TO_DIE) throw new Error(`game ${seed}: alive at ${p.wounds} wounds`); + if (!p.alive && p.dungeon.length) throw new Error(`game ${seed}: dead seat kept a dungeon`); + const soulSum = p.soulCards.reduce((a, h) => a + heroSouls(heroDef(h)), 0); + if (soulSum !== p.souls) throw new Error(`game ${seed}: souls ${p.souls} != soul cards ${soulSum}`); + } +} + +// ── 3. Determinism ─────────────────────────────────────────────────────────── +console.log('Determinism…'); +{ + const logA = []; + const logB = []; + playGame(4, 777, [3, 3, 3, 3], logA); + playGame(4, 777, [3, 3, 3, 3], logB); + ok(logA.length === logB.length && logA.every((e, i) => e === logB[i]), + `same seed reproduces the same event log (${logA.length} events)`); +} + +// ── 4. Self-play soak ──────────────────────────────────────────────────────── +console.log('Self-play soak (320 games)…'); +{ + let totalRounds = 0; + let totalCasts = 0; + let eliminations = 0; + const winReasons = {}; + const winsBySeat = {}; + let games = 0; + for (let g = 0; g < 320; g++) { + const nPlayers = 2 + (g % 3); + const skills = Array.from({ length: nPlayers }, (_, i) => 1 + ((g + i) % 5)); + const { st, tally } = playGame(nPlayers, 1000 + g * 7, skills); + games++; + totalRounds += st.round; + totalCasts += tally.casts; + eliminations += st.players.filter((p) => !p.alive).length; + const over = st.events; // drained already; use final state + ok(st.winner != null, `game ${g}: has a winner`); + const w = st.players[st.winner]; + const reason = w.souls >= SOULS_TO_WIN ? 'souls' + : st.players.filter((p) => p.alive).length === 1 ? 'lastStanding' : 'decksExhausted'; + winReasons[reason] = (winReasons[reason] || 0) + 1; + winsBySeat[st.winner] = (winsBySeat[st.winner] || 0) + 1; + ok(w.alive, `game ${g}: winner is alive`); + ok(w.souls >= SOULS_TO_WIN || st.players.filter((p) => p.alive).length === 1 + || (st.decks.heroes.length === 0 && st.decks.epics.length === 0), + `game ${g}: valid end condition`); + // Epic activation implies the ordinary deck is empty. + if (st.epicsActive) ok(st.decks.heroes.length === 0, `game ${g}: epics only after ordinary deck empty`); + const ranking = finalRanking(st); + ok(ranking[0] === st.winner || !w.alive === false, `game ${g}: winner ranks first`); + } + console.log(` ${games} games · avg rounds ${(totalRounds / games).toFixed(1)}` + + ` · spell/ability activations ${totalCasts}` + + ` · eliminations ${eliminations}` + + ` · endings ${JSON.stringify(winReasons)}` + + ` · wins by seat ${JSON.stringify(winsBySeat)}`); + ok(totalCasts > games, 'AI actually casts spells / activates rooms'); + ok((winReasons.souls || 0) > games * 0.25, 'a healthy share of mixed-skill games end on 10 souls'); +} + +// Skilled play should mostly end on souls, weak play on elimination. +console.log('Skill gradient (60 games @ skill 5)…'); +{ + let souls = 0; + for (let g = 0; g < 60; g++) { + const nPlayers = 2 + (g % 3); + const { st } = playGame(nPlayers, 5000 + g, Array(nPlayers).fill(5)); + if (st.players[st.winner].souls >= SOULS_TO_WIN) souls++; + } + ok(souls >= 40, `skill-5 games end on souls (${souls}/60)`); +} + +// ── 5. Scripted rule spot-checks ───────────────────────────────────────────── +console.log('Rule spot-checks…'); + +// Drive a fresh game up to the first build phase with scripted choices. +function driveTo(st, predicate, maxSteps = 5000) { + const rnd = makeRng(99); + let steps = 0; + while (!isOver(st)) { + const d = pendingDecision(st); + if (!d) throw new Error('stalled'); + if (predicate(d, st)) return d; + driveDecision(st, d, 3, rnd, null); + takeEvents(st); + if (++steps > maxSteps) throw new Error('driveTo exceeded step guard'); + } + return null; +} + +{ + // Bait: strict max wins the hero, ties stay in town. + const st = newGame(2, 4242); + driveTo(st, (d) => d.kind === 'build' && !d.setup); + const bt = baitTargets(st); + for (const hero of st.town) { + const def = heroDef(hero); + if (def.fool) { + const [a, b] = [st.players[0].souls, st.players[1].souls]; + ok((a === b) === (bt[hero.uid] == null), 'fool bait: tie stays, otherwise fewest souls'); + } else { + const counts = st.players.map((p) => treasureCount(st, p.seat, def.cls)); + const max = Math.max(...counts); + const winners = counts.filter((c) => c === max && c > 0).length; + if (winners === 1 && max > 0) ok(counts[bt[hero.uid]] === max, `bait goes to strict max for ${def.cls}`); + else ok(bt[hero.uid] == null, 'bait tie (or all zero) stays in town'); + } + } +} + +{ + // Advanced rooms only over a shared treasure icon; ordinary anywhere. + const st = newGame(2, 555); + driveTo(st, (d) => d.kind === 'build' && !d.setup); + const seat = pendingDecision(st).seat; + const p = st.players[seat]; + // Inject a known hand/dungeon to make the check exact. + const mk = (id) => ({ uid: 900000 + Math.floor(Math.random() * 100000), id }); + p.hand.rooms = [mk('beastmenagerie'), mk('goblinarmory')]; // advanced fighter / ordinary + p.dungeon = [ + { room: mk('brainsuckerhive'), under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }, // mage + { room: mk('golemfactory'), under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }, // fighter + ]; + const legal = legalBuilds(st, seat); + const adv = p.hand.rooms[0]; + const advSlots = legal.filter((b) => b.roomUid === adv.uid).map((b) => b.slotIdx); + ok(!advSlots.includes(0), 'advanced room cannot go over a non-matching room'); + ok(advSlots.includes(1), 'advanced room can go over a matching (fighter) room'); + ok(!advSlots.includes(2), 'advanced room cannot open a new slot'); + const ordSlots = legal.filter((b) => b.roomUid === p.hand.rooms[1].uid).map((b) => b.slotIdx); + ok(ordSlots.includes(0) && ordSlots.includes(1) && ordSlots.includes(2), 'ordinary room can build anywhere'); +} + +{ + // Level-up fires exactly once, on the 5th room. + let leveledEvents = 0; + const st = newGame(2, 31337); + const rnd = makeRng(1); + let steps = 0; + while (!isOver(st) && steps < 20000) { + steps++; + const d = pendingDecision(st); + if (!d) break; + driveDecision(st, d, 5, rnd, null); + for (const e of takeEvents(st)) { + if (e.type === 'levelUp') { + leveledEvents++; + ok(st.players[e.seat].dungeon.length === MAX_ROOMS, 'level-up at exactly 5 rooms'); + ok(st.players[e.seat].boss.leveledUp, 'level-up flag set'); + } + } + } + ok(leveledEvents <= st.players.length, 'level-up at most once per player'); +} + +{ + // Surviving heroes wound the boss; epic heroes wound twice. + const st = newGame(2, 90210); + driveTo(st, (d) => d.kind === 'build' && !d.setup); + const seat = st.turnOrder[0]; + const p = st.players[seat]; + p.dungeon = []; // empty dungeon: any hero walks straight through + st.town = []; // keep the bait phase from adding extra heroes to the walk + const tough = Object.keys(HEROES).find((id) => HEROES[id].epic); + p.entrance = [{ uid: 777777, id: tough, hp: HEROES[tough].hp, hpMax: HEROES[tough].hp }]; + const before = p.wounds; + // Run just the adventure by driving until the entrance empties. + const rnd = makeRng(5); + let steps = 0; + while (p.entrance.length && !isOver(st) && steps < 5000) { + steps++; + const d = pendingDecision(st); + driveDecision(st, d, 1, rnd, null); + takeEvents(st); + } + ok(p.wounds - before === 2 || !p.alive, `epic hero deals 2 wounds (got ${p.wounds - before})`); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tools/verifySWDBG.js b/tools/verifySWDBG.js new file mode 100644 index 0000000..2f98692 --- /dev/null +++ b/tools/verifySWDBG.js @@ -0,0 +1,380 @@ +// verifySWDBG.js — headless checks for the Star Wars Deckbuilder engine. +// Run from repo root: node tools/verifySWDBG.js +// Imports only Data/Logic/AI (never the Phaser scene). Card content is read +// from data/swdbg-cards.json, the same file the game loads at runtime. + +import { readFileSync } from 'node:fs'; +import { + loadCardData, getData, cardDef, baseDef, basesFor, + OPS, TIMINGS, CONSTANT_OPS, FACTIONS, +} from '../src/games/swdbg/SWDBGData.js'; +import { + newGame, makeRng, pendingDecision, takeEvents, publicView, legalActions, + actTurn, actChooseBase, actChooseTarget, actOppDiscard, actSpyChoice, actChooseOption, + entryAttack, forceWith, forcePegged, purchaseCost, isOver, FORCE_MAX, +} from '../src/games/swdbg/SWDBGLogic.js'; +import { decide } from '../src/games/swdbg/SWDBGAI.js'; + +const json = JSON.parse(readFileSync(new URL('../data/swdbg-cards.json', import.meta.url), 'utf8')); +loadCardData(json); + +let pass = 0; +let fail = 0; +function ok(cond, msg) { + if (cond) { pass++; return true; } + fail++; + console.error(` ✗ ${msg}`); + return false; +} + +// ── 1. Content sanity ──────────────────────────────────────────────────────── +console.log('Content sanity…'); +{ + const counts = { empire: 0, rebel: 0, neutral: 0 }; + let total = 0; + const checkAbility = (id, ab, allowed = TIMINGS) => { + if (!ab) return; + ok(allowed.includes(ab.timing || 'action') || ab.timing === undefined, `${id}: ability timing valid`); + const ops = ab.op === 'choose' ? ab.options.map((o) => o.op) : [ab.op]; + for (const op of ops) ok(OPS.includes(op), `${id}: op ${op} supported`); + if (ab.timing === 'constant') ok(CONSTANT_OPS.includes(ab.op), `${id}: constant op is passive`); + if (ab.timing === 'action' || !ab.timing) ok(!CONSTANT_OPS.includes(ab.op), `${id}: action op is active`); + }; + for (const def of json.cards) { + total += def.count; + counts[def.faction] += def.count; + ok(FACTIONS.includes(def.faction) || def.faction === 'neutral', `${def.id}: faction valid`); + ok(['unit', 'capital'].includes(def.type), `${def.id}: type valid`); + ok(def.cost >= 0 && def.count >= 1, `${def.id}: cost/count sane`); + ok(!!def.name && !!def.text, `${def.id}: name/text present`); + if (def.type === 'capital') { + ok(def.hp > 0, `${def.id}: capital has hp`); + ok(def.target == null, `${def.id}: capital ships cannot be bounty targets`); + } + if (def.faction === 'neutral') { + ok(def.target == null && def.reward == null, `${def.id}: neutral has no target/reward`); + } else if (def.type === 'unit') { + ok(def.target != null && def.target > 0, `${def.id}: faction unit has a target value`); + ok(!!def.reward, `${def.id}: faction unit has a reward`); + } + if (def.unique) ok(def.count === 1, `${def.id}: unique is a 1-of`); + checkAbility(def.id, def.ability); + } + ok(total === 90, `galaxy deck is 90 cards (got ${total})`); + ok(counts.empire === 30, `30 Empire cards (got ${counts.empire})`); + ok(counts.rebel === 30, `30 Rebel cards (got ${counts.rebel})`); + ok(counts.neutral === 30, `30 neutral cards (got ${counts.neutral})`); + + for (const f of FACTIONS) { + const starters = json.starters[f].reduce((a, s) => a + s.count, 0); + ok(starters === 10, `${f} starter deck is 10 cards`); + for (const s of json.starters[f]) ok(!!cardDef(s.id), `${f} starter ${s.id} defined`); + const bases = basesFor(f); + ok(bases.length >= 5, `${f} has at least 5 bases`); + ok(bases.filter((b) => b.starting).length === 1, `${f} has exactly one starting base`); + for (const b of bases) { + ok(b.hp > 0 && !!b.name && !!b.text, `${f} base ${b.id} fields`); + checkAbility(`base ${b.id}`, b.ability, ['action', 'constant', 'onReveal']); + } + } + ok(json.outerRimPilot.count === 10, 'Outer Rim Pilot stack is 10'); + checkAbility('outerrimpilot', json.outerRimPilot.ability); + ok(json.meta.basesToWin === 3 && json.meta.handSize === 5 && json.meta.galaxyRowSize === 6, 'meta matches the beginner rules'); +} + +// ── 2. Setup invariants ────────────────────────────────────────────────────── +console.log('Setup invariants…'); +{ + for (const faction of FACTIONS) { + const st = newGame({ humanFaction: faction, seed: 424242 }); + ok(st.players[0].faction === faction, `human plays ${faction}`); + ok(st.players[1].faction !== faction, 'AI plays the other side'); + ok(st.players.every((p) => p.hand.length === 5), 'both draw 5'); + ok(st.players.every((p) => p.deck.length === 5), '5 left in each deck'); + ok(st.galaxy.row.length === 6, 'galaxy row is 6'); + ok(st.galaxy.deck.length === 84, '84 cards left in the galaxy deck'); + ok(st.outerRim.length === 10, '10 Outer Rim Pilots'); + ok(st.force === FORCE_MAX, 'Force starts pegged to the Rebel side'); + ok(st.players[st.turnSeat].faction === 'empire', 'Empire takes the first turn'); + ok(st.players.every((p) => baseDef(p.faction, p.base.id).starting), 'both start on their starting base'); + const d = pendingDecision(st); + ok(d && d.kind === 'turn' && d.seat === st.turnSeat, 'first decision is the Empire turn'); + const rebelSeat = st.players.findIndex((p) => p.faction === 'rebel'); + ok(forceWith(st, rebelSeat) && forcePegged(st, rebelSeat), 'the Force is with the Rebels at setup'); + ok(!forceWith(st, 1 - rebelSeat), 'and not with the Empire'); + } +} + +// ── AI driver ──────────────────────────────────────────────────────────────── +function driveDecision(st, d, skill, rnd, tally = null) { + const view = publicView(st, d.seat); + const choice = decide(view, d, skill, rnd); + switch (d.kind) { + case 'turn': + if (tally) tally[choice.type] = (tally[choice.type] || 0) + 1; + actTurn(st, d.seat, choice); + break; + case 'chooseBase': actChooseBase(st, d.seat, choice); break; + case 'target': actChooseTarget(st, d.seat, choice); break; + case 'oppDiscard': actOppDiscard(st, d.seat, choice); break; + case 'spyChoice': actSpyChoice(st, d.seat, choice); break; + case 'chooseOption': actChooseOption(st, d.seat, choice); break; + default: throw new Error(`unknown decision kind ${d.kind}`); + } +} + +function countAll(st) { + // Every card instance must live in exactly one zone. + let n = st.galaxy.deck.length + st.galaxy.row.length + st.galaxy.discard.length + + st.outerRim.length + st.exile.length; + for (const p of st.players) { + n += p.deck.length + p.hand.length + p.discard.length + p.inPlay.length + p.capitals.length; + } + return n; +} + +function checkInvariants(st, seed) { + if (st.force < -FORCE_MAX || st.force > FORCE_MAX) throw new Error(`game ${seed}: force off track (${st.force})`); + for (const p of st.players) { + if (p.resources < 0) throw new Error(`game ${seed}: negative resources`); + if (p.base) { + const def = baseDef(p.faction, p.base.id); + if (p.base.damage >= def.hp) throw new Error(`game ${seed}: destroyed base still in play`); + } + if (!st.over && p.lostBases >= st.meta.basesToWin) throw new Error(`game ${seed}: lost ${p.lostBases} bases but game continues`); + for (const e of p.capitals) { + if (e.damage >= cardDef(e.card).hp) throw new Error(`game ${seed}: destroyed capital still in play`); + } + } +} + +function playGame(seed, skills, collect = null) { + const st = newGame({ humanFaction: seed % 2 ? 'rebel' : 'empire', seed }); + const rnd = makeRng(seed ^ 0x5eed); + const expectTotal = countAll(st); + const tally = {}; + let steps = 0; + while (!isOver(st)) { + if (++steps > 40000) throw new Error(`game ${seed} exceeded step guard (turn ${st.turnCount})`); + const d = pendingDecision(st); + if (!d) throw new Error(`game ${seed}: no decision but game not over (turn ${st.turnCount})`); + driveDecision(st, d, skills[d.seat] ?? 3, rnd, tally); + checkInvariants(st, seed); + if (countAll(st) !== expectTotal) throw new Error(`game ${seed}: card count drifted (${countAll(st)} != ${expectTotal})`); + if (collect !== null) collect.push(...takeEvents(st).map((e) => JSON.stringify(e))); + else takeEvents(st); + } + return { st, tally, steps }; +} + +// ── 3. Determinism ─────────────────────────────────────────────────────────── +console.log('Determinism…'); +{ + const logA = []; + const logB = []; + playGame(777, [3, 3], logA); + playGame(777, [3, 3], logB); + ok(logA.length === logB.length && logA.every((e, i) => e === logB[i]), + `same seed reproduces the same event log (${logA.length} events)`); +} + +// ── 4. Self-play soak ──────────────────────────────────────────────────────── +console.log('Self-play soak (300 games)…'); +{ + let games = 0; + let totalTurns = 0; + const tally = {}; + const winsByFaction = { empire: 0, rebel: 0 }; + let maxTurns = 0; + for (let g = 0; g < 300; g++) { + const skills = [1 + (g % 5), 1 + ((g + 2) % 5)]; + const { st, tally: t } = playGame(1000 + g * 7, skills); + games++; + totalTurns += st.turnCount; + maxTurns = Math.max(maxTurns, st.turnCount); + for (const [k, v] of Object.entries(t)) tally[k] = (tally[k] || 0) + v; + ok(st.winner != null, `game ${g}: has a winner`); + const w = st.players[st.winner]; + const l = st.players[1 - st.winner]; + ok(l.lostBases >= st.meta.basesToWin, `game ${g}: loser lost ${st.meta.basesToWin}+ bases`); + ok(w.lostBases < st.meta.basesToWin, `game ${g}: winner kept a base standing`); + winsByFaction[w.faction]++; + } + console.log(` ${games} games · avg turns ${(totalTurns / games).toFixed(1)} (max ${maxTurns})` + + ` · wins ${JSON.stringify(winsByFaction)}` + + ` · actions ${JSON.stringify(tally)}`); + ok(tally.buy > games * 3, 'AI buys cards'); + ok((tally.attackRow || 0) > games * 0.3, 'AI bounty hunts / sabotages'); + ok((tally.ability || 0) > games, 'AI uses abilities'); + ok(totalTurns / games < 80, 'games end in a reasonable number of turns'); + const skew = Math.abs(winsByFaction.empire - winsByFaction.rebel) / games; + ok(skew < 0.35, `faction win rates not wildly skewed (skew ${(skew * 100).toFixed(0)}%)`); +} + +// Skill gradient: a 5 should beat a 1 far more often than not. +console.log('Skill gradient (80 games, 5 vs 1)…'); +{ + let strongWins = 0; + for (let g = 0; g < 80; g++) { + const strongSeat = g % 2; + const skills = strongSeat === 0 ? [5, 1] : [1, 5]; + const { st } = playGame(9000 + g * 13, skills); + if (st.winner === strongSeat) strongWins++; + } + console.log(` skill-5 wins ${strongWins}/80`); + ok(strongWins >= 52, `skill 5 dominates skill 1 (${strongWins}/80)`); +} + +// ── 5. Scripted rule spot-checks ───────────────────────────────────────────── +console.log('Rule spot-checks…'); + +function freshGame(seed = 31337) { + return newGame({ humanFaction: 'rebel', seed }); +} +function seatOf(st, faction) { return st.players.findIndex((p) => p.faction === faction); } +function give(st, seat, id) { + // Inject a known card into a player's play area for scripted checks. + const inst = { uid: 900000 + Math.floor(Math.random() * 99999), id }; + const entry = { card: inst, tempAttack: 0, used: false, committed: false }; + if (cardDef(id).type === 'capital') { entry.damage = 0; st.players[seat].capitals.push(entry); } + else st.players[seat].inPlay.push(entry); + return entry; +} + +{ + // Faction purchase restriction: Empire cannot buy Rebel cards, anyone buys neutral. + const st = freshGame(101); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + st.players[emp].resources = 20; + const rebelRow = st.galaxy.row.find((c) => cardDef(c).faction === 'rebel'); + if (rebelRow) { + let threw = false; + try { actTurn(st, emp, { type: 'buy', uid: rebelRow.uid }); } catch { threw = true; } + ok(threw, 'Empire cannot purchase a Rebel card'); + } + const legal = legalActions(st, emp); + ok(legal.buys.every((b) => { + if (b.uid === 'outerrim') return true; + const f = cardDef(st.galaxy.row.find((c) => c.uid === b.uid)).faction; + return f === 'empire' || f === 'neutral'; + }), 'legal buys are own-faction or neutral only'); + ok(legal.buys.some((b) => b.uid === 'outerrim'), 'Outer Rim Pilot is always purchasable'); +} + +{ + // Capital ships absorb an attack before the base; damage persists. + const st = freshGame(202); + const emp = seatOf(st, 'empire'); + const reb = 1 - emp; + st.turnSeat = reb; + st.players[reb].resources = 0; + const cap = give(st, emp, 'gozanticruiser'); // hp 3 + const atk = give(st, reb, 'quarrenmercenary'); // 4 attack + const baseBefore = st.players[emp].base.damage; + actTurn(st, reb, { type: 'attackBase', uids: [atk.card.uid] }); + ok(st.players[emp].capitals.length === 0, 'capital destroyed by the attack'); + ok(st.players[emp].discard.some((c) => c.uid === cap.card.uid), 'destroyed capital goes to owner discard'); + ok(st.players[emp].base.damage === baseBefore + 1, 'excess damage rolls onto the base'); + // A second attacker cannot re-commit the same unit. + let threw = false; + try { actTurn(st, reb, { type: 'attackBase', uids: [atk.card.uid] }); } catch { threw = true; } + ok(threw, 'a committed unit cannot attack twice'); +} + +{ + // Direct ability damage ignores capital ships; base destruction wins at 3. + const st = freshGame(303); + const emp = seatOf(st, 'empire'); + const reb = 1 - emp; + st.turnSeat = emp; + give(st, reb, 'moncalamaricruiser'); // big capital that would otherwise absorb + const bomber = give(st, emp, 'tiebomber'); + st.force = -FORCE_MAX; // Force with the Empire → bomber deals 2 + const dmgBefore = st.players[reb].base.damage; + actTurn(st, emp, { type: 'ability', zone: 'play', uid: bomber.card.uid }); + ok(st.players[reb].base.damage === dmgBefore + 2, 'ability damage hits the base directly (Force-boosted)'); + ok(st.players[reb].capitals.length === 1, 'capital untouched by direct damage'); + + st.players[reb].base.damage = baseDef('rebel', st.players[reb].base.id).hp - 1; + st.players[reb].lostBases = 2; + const bomber2 = give(st, emp, 'tiebomber'); + actTurn(st, emp, { type: 'ability', zone: 'play', uid: bomber2.card.uid }); + ok(st.over && st.winner === emp, 'third base destroyed ends the game immediately'); +} + +{ + // Bounty hunting: target value gate, rewards, and galaxy discard. + const st = freshGame(404); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + // Put a known Empire unit in the row. + const target = { uid: 888001, id: 'deathtrooper' }; // target 4, reward 2 resources + st.galaxy.row[0] = target; + const a1 = give(st, reb, 'rebelcommando'); // 3 attack + let threw = false; + try { actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [a1.card.uid] }); } catch { threw = true; } + ok(threw, 'attack below target value is rejected'); + const a2 = give(st, reb, 'xwing'); // 3 attack + const resBefore = st.players[reb].resources; + actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [a1.card.uid, a2.card.uid] }); + ok(st.galaxy.discard.some((c) => c.uid === target.uid), 'defeated card goes to the galaxy discard'); + ok(st.players[reb].resources === resBefore + 2, 'bounty reward granted'); + ok(st.galaxy.row.length === 6, 'row refilled after the bounty'); + ok(a1.committed && a2.committed, 'bounty attackers are committed'); +} + +{ + // Constant boosts: Imperial Carrier aura + TIE swarm + Rodian row bonus. + const st = freshGame(505); + const emp = seatOf(st, 'empire'); + give(st, emp, 'imperialcarrier'); + const tie1 = give(st, emp, 'tiefighter'); + const tie2 = give(st, emp, 'tiefighter'); + // TIE: 1 base + 1 swarm (another Fighter) + 1 carrier aura = 3 + ok(entryAttack(st, emp, tie1) === 3, `TIE Fighter attack with swarm+carrier is 3 (got ${entryAttack(st, emp, tie1)})`); + const rodian = give(st, emp, 'rodiangunslinger'); + ok(entryAttack(st, emp, rodian) === 2, 'Rodian is 2 attack vs bases'); + ok(entryAttack(st, emp, rodian, { vsRow: true }) === 4, 'Rodian is 4 attack vs the galaxy row'); +} + +{ + // Force mechanics: pegged bonus resource, spy choice restrictions. + const st = freshGame(606); + const emp = seatOf(st, 'empire'); + st.force = -FORCE_MAX; + st.turnSeat = 1 - emp; // let empire's beginTurn run via endTurn + actTurn(st, 1 - emp, { type: 'endTurn' }); + ok(st.players[emp].resources >= 1, 'pegged Force grants a start-of-turn resource'); + + // Duros Spy with victim hand empty auto-resolves to Force gain. + const st2 = freshGame(607); + const reb2 = seatOf(st2, 'rebel'); + st2.turnSeat = reb2; + st2.force = 0; + st2.players[1 - reb2].hand = []; + const spy = give(st2, reb2, 'durosspy'); + actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: spy.card.uid }); + ok(st2.force === 1, 'Duros Spy auto-resolves to Force when victim hand is empty'); +} + +{ + // End of turn: units + hand discarded, capitals persist, hand refills to 5. + const st = freshGame(707); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + const p = st.players[emp]; + give(st, emp, 'stardestroyer'); + actTurn(st, emp, { type: 'playAll' }); + const played = p.inPlay.length; + ok(played > 0, 'units in play before end of turn'); + actTurn(st, emp, { type: 'endTurn' }); + ok(p.inPlay.length === 0, 'units discarded at end of turn'); + ok(p.capitals.length === 1, 'capital ship stays in play'); + ok(p.hand.length === 5, 'hand refilled to 5'); + ok(p.resources === 0, 'unspent resources returned'); + ok(st.turnSeat === 1 - emp, 'turn passes'); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);