From 1169102d46742474bf0f0189d467c791984d9e05 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 16:13:56 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feedback5=20R4:=20one=20file=20per=20operat?= =?UTF-8?q?or=20kit=20=E2=80=94=20kits/tier1..6.js=20split=20into=20kits/o?= =?UTF-8?q?ps/-.js=20(129=20kits),=20kits/shared/tier1.?= =?UTF-8?q?.6.js=20(the=20helpers=20two=20or=20more=20kits=20of=20a=20tier?= =?UTF-8?q?=20use,=20and=20each=20tier's=20header=20notes)=20and=20kits/in?= =?UTF-8?q?dex.js=20(the=20registry,=20same=20keys=20in=20the=20same=20ord?= =?UTF-8?q?er);=20the=20tier=20files=20become=20re-export=20shims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure move: every line of a kit, of a helper and of the tier headers is copied verbatim (only the headers' first line names the new file); a helper used by one kit moved into that kit's file. Glue only: the generated imports, `export default { … }` around each registry entry, `export { … }` lists in shared/tier2..6.js, and tier4's `(bb, chess, def)` default-argument wrapper as shared/tier4.js `withDefaults` (applied in each tier-4 kit file). kits/index.js loads every kit file with a guarded dynamic import (browser-safe, no directory listing); tierN.js re-export the tier registry (TIER_KITS) and their old named exports (tier1 helpers + tinmanKit, tier6 PITHST_ELEMENTS). The merged registry, every tier default and named export have the same keys, order, frozenness and function sources as before. test/docs-consistency.test.js reads the moved code at its new paths. Golden: GOLDEN_FULL=1 node --test test/golden.test.js unchanged. --- server/sim/content/kits/index.js | 72 + .../kits/ops/chess_char_1_01-inside.js | 61 + .../content/kits/ops/chess_char_1_02-yak.js | 19 + .../content/kits/ops/chess_char_1_03-leizi.js | 27 + .../kits/ops/chess_char_1_04-udflow.js | 45 + .../content/kits/ops/chess_char_1_05-vigna.js | 33 + .../kits/ops/chess_char_1_06-vendla.js | 73 + .../content/kits/ops/chess_char_1_07-prove.js | 64 + .../content/kits/ops/chess_char_1_08-texas.js | 43 + .../content/kits/ops/chess_char_1_09-caper.js | 32 + .../content/kits/ops/chess_char_1_10-sunbr.js | 85 + .../kits/ops/chess_char_1_11-skgoat.js | 15 + .../kits/ops/chess_char_1_12-estell.js | 34 + .../kits/ops/chess_char_1_13-podego.js | 64 + .../content/kits/ops/chess_char_1_14-greyy.js | 22 + .../kits/ops/chess_char_1_15-pithst.js | 54 + .../kits/ops/chess_char_1_16-tinman.js | 67 + .../kits/ops/chess_char_1_17-indigo.js | 71 + .../content/kits/ops/chess_char_1_18-utage.js | 57 + .../kits/ops/chess_char_1_19-wildmn.js | 67 + .../kits/ops/chess_char_1_20-liskam.js | 81 + .../content/kits/ops/chess_char_2_01-excu.js | 21 + .../kits/ops/chess_char_2_02-silent.js | 36 + .../kits/ops/chess_char_2_03-slchan.js | 39 + .../kits/ops/chess_char_2_04-grabds.js | 49 + .../kits/ops/chess_char_2_05-harold.js | 56 + .../kits/ops/chess_char_2_06-papyrs.js | 85 + .../content/kits/ops/chess_char_2_07-ghost.js | 35 + .../kits/ops/chess_char_2_08-bubble.js | 33 + .../content/kits/ops/chess_char_2_09-humus.js | 60 + .../content/kits/ops/chess_char_2_10-rockr.js | 68 + .../kits/ops/chess_char_2_11-kazema.js | 79 + .../kits/ops/chess_char_2_12-gravel.js | 65 + .../content/kits/ops/chess_char_2_13-tippi.js | 78 + .../kits/ops/chess_char_2_14-flower.js | 29 + .../kits/ops/chess_char_2_15-akkord.js | 43 + .../kits/ops/chess_char_2_16-whitew.js | 47 + .../kits/ops/chess_char_2_17-branch.js | 46 + .../kits/ops/chess_char_2_18-ashlok.js | 37 + .../kits/ops/chess_char_2_19-tinman.js | 49 + .../content/kits/ops/chess_char_3_01-angel.js | 49 + .../content/kits/ops/chess_char_3_02-ayer.js | 67 + .../content/kits/ops/chess_char_3_03-swire.js | 36 + .../kits/ops/chess_char_3_04-swire2.js | 230 ++ .../content/kits/ops/chess_char_3_05-skadi.js | 62 + .../kits/ops/chess_char_3_06-philae.js | 102 + .../kits/ops/chess_char_3_07-forcer.js | 72 + .../content/kits/ops/chess_char_3_08-mint.js | 79 + .../content/kits/ops/chess_char_3_09-haini.js | 57 + .../kits/ops/chess_char_3_10-pinecn.js | 54 + .../kits/ops/chess_char_3_11-snhunt.js | 74 + .../kits/ops/chess_char_3_12-blemsh.js | 127 + .../kits/ops/chess_char_3_13-malist.js | 25 + .../kits/ops/chess_char_3_14-slbell.js | 55 + .../kits/ops/chess_char_3_15-vodfox.js | 29 + .../kits/ops/chess_char_3_16-snakek.js | 26 + .../kits/ops/chess_char_3_17-shotst.js | 41 + .../kits/ops/chess_char_3_18-vulpis.js | 127 + .../content/kits/ops/chess_char_3_19-vigil.js | 240 ++ .../content/kits/ops/chess_char_3_20-kjera.js | 58 + .../kits/ops/chess_char_3_21-archet.js | 122 + .../kits/ops/chess_char_4_01-rmixer.js | 144 + .../kits/ops/chess_char_4_02-mostma.js | 75 + .../content/kits/ops/chess_char_4_03-kjera.js | 77 + .../content/kits/ops/chess_char_4_04-ines.js | 168 + .../kits/ops/chess_char_4_05-beewax.js | 66 + .../kits/ops/chess_char_4_06-kroos2.js | 50 + .../content/kits/ops/chess_char_4_07-bpipe.js | 59 + .../kits/ops/chess_char_4_08-rosesa.js | 47 + .../kits/ops/chess_char_4_09-mizuki.js | 62 + .../content/kits/ops/chess_char_4_10-aroma.js | 81 + .../content/kits/ops/chess_char_4_11-cathy.js | 38 + .../content/kits/ops/chess_char_4_12-glady.js | 100 + .../kits/ops/chess_char_4_13-gnosis.js | 112 + .../kits/ops/chess_char_4_14-lionhd.js | 37 + .../kits/ops/chess_char_4_15-reckpr.js | 41 + .../kits/ops/chess_char_4_16-texas2.js | 115 + .../kits/ops/chess_char_4_17-hsguma.js | 50 + .../kits/ops/chess_char_4_18-mudrok.js | 101 + .../kits/ops/chess_char_4_19-flamtl.js | 118 + .../kits/ops/chess_char_4_20-fartth.js | 91 + .../kits/ops/chess_char_4_21-plosis.js | 19 + .../kits/ops/chess_char_4_22-svrash.js | 52 + .../kits/ops/chess_char_4_23-gvial2.js | 78 + .../kits/ops/chess_char_4_24-billro.js | 148 + .../kits/ops/chess_char_4_25-cetsyr.js | 150 + .../content/kits/ops/chess_char_4_26-bldsk.js | 66 + .../content/kits/ops/chess_char_5_01-excu2.js | 124 + .../content/kits/ops/chess_char_5_02-titi.js | 177 + .../kits/ops/chess_char_5_03-blaze2.js | 166 + .../content/kits/ops/chess_char_5_04-bldsk.js | 59 + .../content/kits/ops/chess_char_5_05-ulpia.js | 146 + .../kits/ops/chess_char_5_06-etlchi.js | 183 + .../content/kits/ops/chess_char_5_07-surtr.js | 108 + .../content/kits/ops/chess_char_5_08-horn.js | 143 + .../kits/ops/chess_char_5_09-cetsyr.js | 125 + .../content/kits/ops/chess_char_5_10-lisa.js | 64 + .../kits/ops/chess_char_5_11-demkni.js | 111 + .../content/kits/ops/chess_char_5_12-dusk.js | 118 + .../kits/ops/chess_char_5_13-ghost2.js | 104 + .../kits/ops/chess_char_5_14-svash2.js | 191 + .../kits/ops/chess_char_5_15-thorn2.js | 209 + .../kits/ops/chess_char_5_16-plosis.js | 22 + .../kits/ops/chess_char_5_17-f12yin.js | 73 + .../kits/ops/chess_char_5_18-gvial2.js | 48 + .../kits/ops/chess_char_5_19-mlynar.js | 128 + .../kits/ops/chess_char_5_20-aglina.js | 72 + .../kits/ops/chess_char_5_21-sntlla.js | 78 + .../content/kits/ops/chess_char_5_22-nymph.js | 103 + .../kits/ops/chess_char_5_23-reckpr.js | 50 + .../kits/ops/chess_char_6_01-lemuen.js | 227 ++ .../kits/ops/chess_char_6_02-sbell2.js | 228 ++ .../content/kits/ops/chess_char_6_03-yu.js | 155 + .../kits/ops/chess_char_6_04-skadi2.js | 190 + .../kits/ops/chess_char_6_05-pasngr.js | 111 + .../content/kits/ops/chess_char_6_06-pepe.js | 121 + .../kits/ops/chess_char_6_07-siege2.js | 133 + .../content/kits/ops/chess_char_6_08-reed2.js | 132 + .../content/kits/ops/chess_char_6_09-cello.js | 146 + .../content/kits/ops/chess_char_6_10-nymph.js | 69 + .../content/kits/ops/chess_char_6_11-mlyss.js | 316 ++ .../kits/ops/chess_char_6_12-rosmon.js | 105 + .../kits/ops/chess_char_6_13-angel2.js | 180 + .../content/kits/ops/chess_char_6_14-lumen.js | 126 + .../kits/ops/chess_char_6_15-qiubai.js | 103 + .../content/kits/ops/chess_char_6_16-halo2.js | 146 + .../kits/ops/chess_char_6_17-nearl2.js | 149 + .../kits/ops/chess_char_6_18-whitw2.js | 306 ++ .../kits/ops/chess_char_6_19-blkkgt.js | 137 + .../kits/ops/chess_char_6_20-agoat2.js | 144 + server/sim/content/kits/shared/tier1.js | 242 ++ server/sim/content/kits/shared/tier2.js | 26 + server/sim/content/kits/shared/tier3.js | 224 ++ server/sim/content/kits/shared/tier4.js | 162 + server/sim/content/kits/shared/tier5.js | 204 + server/sim/content/kits/shared/tier6.js | 282 ++ server/sim/content/kits/tier1.js | 1086 +----- server/sim/content/kits/tier2.js | 868 +---- server/sim/content/kits/tier3.js | 1805 +-------- server/sim/content/kits/tier4.js | 2086 +--------- server/sim/content/kits/tier5.js | 2601 +------------ server/sim/content/kits/tier6.js | 3367 +---------------- test/docs-consistency.test.js | 11 +- 143 files changed, 12924 insertions(+), 11784 deletions(-) create mode 100644 server/sim/content/kits/index.js create mode 100644 server/sim/content/kits/ops/chess_char_1_01-inside.js create mode 100644 server/sim/content/kits/ops/chess_char_1_02-yak.js create mode 100644 server/sim/content/kits/ops/chess_char_1_03-leizi.js create mode 100644 server/sim/content/kits/ops/chess_char_1_04-udflow.js create mode 100644 server/sim/content/kits/ops/chess_char_1_05-vigna.js create mode 100644 server/sim/content/kits/ops/chess_char_1_06-vendla.js create mode 100644 server/sim/content/kits/ops/chess_char_1_07-prove.js create mode 100644 server/sim/content/kits/ops/chess_char_1_08-texas.js create mode 100644 server/sim/content/kits/ops/chess_char_1_09-caper.js create mode 100644 server/sim/content/kits/ops/chess_char_1_10-sunbr.js create mode 100644 server/sim/content/kits/ops/chess_char_1_11-skgoat.js create mode 100644 server/sim/content/kits/ops/chess_char_1_12-estell.js create mode 100644 server/sim/content/kits/ops/chess_char_1_13-podego.js create mode 100644 server/sim/content/kits/ops/chess_char_1_14-greyy.js create mode 100644 server/sim/content/kits/ops/chess_char_1_15-pithst.js create mode 100644 server/sim/content/kits/ops/chess_char_1_16-tinman.js create mode 100644 server/sim/content/kits/ops/chess_char_1_17-indigo.js create mode 100644 server/sim/content/kits/ops/chess_char_1_18-utage.js create mode 100644 server/sim/content/kits/ops/chess_char_1_19-wildmn.js create mode 100644 server/sim/content/kits/ops/chess_char_1_20-liskam.js create mode 100644 server/sim/content/kits/ops/chess_char_2_01-excu.js create mode 100644 server/sim/content/kits/ops/chess_char_2_02-silent.js create mode 100644 server/sim/content/kits/ops/chess_char_2_03-slchan.js create mode 100644 server/sim/content/kits/ops/chess_char_2_04-grabds.js create mode 100644 server/sim/content/kits/ops/chess_char_2_05-harold.js create mode 100644 server/sim/content/kits/ops/chess_char_2_06-papyrs.js create mode 100644 server/sim/content/kits/ops/chess_char_2_07-ghost.js create mode 100644 server/sim/content/kits/ops/chess_char_2_08-bubble.js create mode 100644 server/sim/content/kits/ops/chess_char_2_09-humus.js create mode 100644 server/sim/content/kits/ops/chess_char_2_10-rockr.js create mode 100644 server/sim/content/kits/ops/chess_char_2_11-kazema.js create mode 100644 server/sim/content/kits/ops/chess_char_2_12-gravel.js create mode 100644 server/sim/content/kits/ops/chess_char_2_13-tippi.js create mode 100644 server/sim/content/kits/ops/chess_char_2_14-flower.js create mode 100644 server/sim/content/kits/ops/chess_char_2_15-akkord.js create mode 100644 server/sim/content/kits/ops/chess_char_2_16-whitew.js create mode 100644 server/sim/content/kits/ops/chess_char_2_17-branch.js create mode 100644 server/sim/content/kits/ops/chess_char_2_18-ashlok.js create mode 100644 server/sim/content/kits/ops/chess_char_2_19-tinman.js create mode 100644 server/sim/content/kits/ops/chess_char_3_01-angel.js create mode 100644 server/sim/content/kits/ops/chess_char_3_02-ayer.js create mode 100644 server/sim/content/kits/ops/chess_char_3_03-swire.js create mode 100644 server/sim/content/kits/ops/chess_char_3_04-swire2.js create mode 100644 server/sim/content/kits/ops/chess_char_3_05-skadi.js create mode 100644 server/sim/content/kits/ops/chess_char_3_06-philae.js create mode 100644 server/sim/content/kits/ops/chess_char_3_07-forcer.js create mode 100644 server/sim/content/kits/ops/chess_char_3_08-mint.js create mode 100644 server/sim/content/kits/ops/chess_char_3_09-haini.js create mode 100644 server/sim/content/kits/ops/chess_char_3_10-pinecn.js create mode 100644 server/sim/content/kits/ops/chess_char_3_11-snhunt.js create mode 100644 server/sim/content/kits/ops/chess_char_3_12-blemsh.js create mode 100644 server/sim/content/kits/ops/chess_char_3_13-malist.js create mode 100644 server/sim/content/kits/ops/chess_char_3_14-slbell.js create mode 100644 server/sim/content/kits/ops/chess_char_3_15-vodfox.js create mode 100644 server/sim/content/kits/ops/chess_char_3_16-snakek.js create mode 100644 server/sim/content/kits/ops/chess_char_3_17-shotst.js create mode 100644 server/sim/content/kits/ops/chess_char_3_18-vulpis.js create mode 100644 server/sim/content/kits/ops/chess_char_3_19-vigil.js create mode 100644 server/sim/content/kits/ops/chess_char_3_20-kjera.js create mode 100644 server/sim/content/kits/ops/chess_char_3_21-archet.js create mode 100644 server/sim/content/kits/ops/chess_char_4_01-rmixer.js create mode 100644 server/sim/content/kits/ops/chess_char_4_02-mostma.js create mode 100644 server/sim/content/kits/ops/chess_char_4_03-kjera.js create mode 100644 server/sim/content/kits/ops/chess_char_4_04-ines.js create mode 100644 server/sim/content/kits/ops/chess_char_4_05-beewax.js create mode 100644 server/sim/content/kits/ops/chess_char_4_06-kroos2.js create mode 100644 server/sim/content/kits/ops/chess_char_4_07-bpipe.js create mode 100644 server/sim/content/kits/ops/chess_char_4_08-rosesa.js create mode 100644 server/sim/content/kits/ops/chess_char_4_09-mizuki.js create mode 100644 server/sim/content/kits/ops/chess_char_4_10-aroma.js create mode 100644 server/sim/content/kits/ops/chess_char_4_11-cathy.js create mode 100644 server/sim/content/kits/ops/chess_char_4_12-glady.js create mode 100644 server/sim/content/kits/ops/chess_char_4_13-gnosis.js create mode 100644 server/sim/content/kits/ops/chess_char_4_14-lionhd.js create mode 100644 server/sim/content/kits/ops/chess_char_4_15-reckpr.js create mode 100644 server/sim/content/kits/ops/chess_char_4_16-texas2.js create mode 100644 server/sim/content/kits/ops/chess_char_4_17-hsguma.js create mode 100644 server/sim/content/kits/ops/chess_char_4_18-mudrok.js create mode 100644 server/sim/content/kits/ops/chess_char_4_19-flamtl.js create mode 100644 server/sim/content/kits/ops/chess_char_4_20-fartth.js create mode 100644 server/sim/content/kits/ops/chess_char_4_21-plosis.js create mode 100644 server/sim/content/kits/ops/chess_char_4_22-svrash.js create mode 100644 server/sim/content/kits/ops/chess_char_4_23-gvial2.js create mode 100644 server/sim/content/kits/ops/chess_char_4_24-billro.js create mode 100644 server/sim/content/kits/ops/chess_char_4_25-cetsyr.js create mode 100644 server/sim/content/kits/ops/chess_char_4_26-bldsk.js create mode 100644 server/sim/content/kits/ops/chess_char_5_01-excu2.js create mode 100644 server/sim/content/kits/ops/chess_char_5_02-titi.js create mode 100644 server/sim/content/kits/ops/chess_char_5_03-blaze2.js create mode 100644 server/sim/content/kits/ops/chess_char_5_04-bldsk.js create mode 100644 server/sim/content/kits/ops/chess_char_5_05-ulpia.js create mode 100644 server/sim/content/kits/ops/chess_char_5_06-etlchi.js create mode 100644 server/sim/content/kits/ops/chess_char_5_07-surtr.js create mode 100644 server/sim/content/kits/ops/chess_char_5_08-horn.js create mode 100644 server/sim/content/kits/ops/chess_char_5_09-cetsyr.js create mode 100644 server/sim/content/kits/ops/chess_char_5_10-lisa.js create mode 100644 server/sim/content/kits/ops/chess_char_5_11-demkni.js create mode 100644 server/sim/content/kits/ops/chess_char_5_12-dusk.js create mode 100644 server/sim/content/kits/ops/chess_char_5_13-ghost2.js create mode 100644 server/sim/content/kits/ops/chess_char_5_14-svash2.js create mode 100644 server/sim/content/kits/ops/chess_char_5_15-thorn2.js create mode 100644 server/sim/content/kits/ops/chess_char_5_16-plosis.js create mode 100644 server/sim/content/kits/ops/chess_char_5_17-f12yin.js create mode 100644 server/sim/content/kits/ops/chess_char_5_18-gvial2.js create mode 100644 server/sim/content/kits/ops/chess_char_5_19-mlynar.js create mode 100644 server/sim/content/kits/ops/chess_char_5_20-aglina.js create mode 100644 server/sim/content/kits/ops/chess_char_5_21-sntlla.js create mode 100644 server/sim/content/kits/ops/chess_char_5_22-nymph.js create mode 100644 server/sim/content/kits/ops/chess_char_5_23-reckpr.js create mode 100644 server/sim/content/kits/ops/chess_char_6_01-lemuen.js create mode 100644 server/sim/content/kits/ops/chess_char_6_02-sbell2.js create mode 100644 server/sim/content/kits/ops/chess_char_6_03-yu.js create mode 100644 server/sim/content/kits/ops/chess_char_6_04-skadi2.js create mode 100644 server/sim/content/kits/ops/chess_char_6_05-pasngr.js create mode 100644 server/sim/content/kits/ops/chess_char_6_06-pepe.js create mode 100644 server/sim/content/kits/ops/chess_char_6_07-siege2.js create mode 100644 server/sim/content/kits/ops/chess_char_6_08-reed2.js create mode 100644 server/sim/content/kits/ops/chess_char_6_09-cello.js create mode 100644 server/sim/content/kits/ops/chess_char_6_10-nymph.js create mode 100644 server/sim/content/kits/ops/chess_char_6_11-mlyss.js create mode 100644 server/sim/content/kits/ops/chess_char_6_12-rosmon.js create mode 100644 server/sim/content/kits/ops/chess_char_6_13-angel2.js create mode 100644 server/sim/content/kits/ops/chess_char_6_14-lumen.js create mode 100644 server/sim/content/kits/ops/chess_char_6_15-qiubai.js create mode 100644 server/sim/content/kits/ops/chess_char_6_16-halo2.js create mode 100644 server/sim/content/kits/ops/chess_char_6_17-nearl2.js create mode 100644 server/sim/content/kits/ops/chess_char_6_18-whitw2.js create mode 100644 server/sim/content/kits/ops/chess_char_6_19-blkkgt.js create mode 100644 server/sim/content/kits/ops/chess_char_6_20-agoat2.js create mode 100644 server/sim/content/kits/shared/tier1.js create mode 100644 server/sim/content/kits/shared/tier2.js create mode 100644 server/sim/content/kits/shared/tier3.js create mode 100644 server/sim/content/kits/shared/tier4.js create mode 100644 server/sim/content/kits/shared/tier5.js create mode 100644 server/sim/content/kits/shared/tier6.js diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js new file mode 100644 index 00000000..d6eb9292 --- /dev/null +++ b/server/sim/content/kits/index.js @@ -0,0 +1,72 @@ +// server/sim/content/kits/index.js — the operator kit registry (DESIGN §7, docs/SIM.md §7.2): baseChessId → +// (bb, chess, def) => Kit. One file per kit in ops/ (README.md: what a kit file holds, naming, registration, tests). +// +// KIT_FILES lists every kit file, grouped by tier — the former kits/tier1.js … tier6.js, each group in that file's +// order — so the merged registry keeps the key order it always had (content/index.js resolves kits by id; the tier +// shims, tools/kit-coverage.mjs and the golden corpus see the same registry as before). A new kit file is appended to +// its tier's group. +// Each file is loaded with a guarded dynamic import, so this runs unchanged in the browser (/sim/, no Node-only +// module, no directory listing): a file that fails to load (syntax error, throwing top-level code, missing file) is +// logged and skipped — its chess falls back to the generic kit — instead of breaking the server or the page. + +export const KIT_FILES = Object.freeze([ + // tier 1 + ['chess_char_1_01-inside.js', 'chess_char_1_02-yak.js', 'chess_char_1_03-leizi.js', 'chess_char_1_04-udflow.js', + 'chess_char_1_05-vigna.js', 'chess_char_1_06-vendla.js', 'chess_char_1_07-prove.js', 'chess_char_1_08-texas.js', + 'chess_char_1_09-caper.js', 'chess_char_1_10-sunbr.js', 'chess_char_1_11-skgoat.js', 'chess_char_1_12-estell.js', + 'chess_char_1_13-podego.js', 'chess_char_1_14-greyy.js', 'chess_char_1_16-tinman.js', 'chess_char_1_17-indigo.js', + 'chess_char_1_18-utage.js', 'chess_char_1_19-wildmn.js', 'chess_char_1_20-liskam.js'], + // tier 2 + ['chess_char_2_01-excu.js', 'chess_char_2_02-silent.js', 'chess_char_2_03-slchan.js', 'chess_char_2_04-grabds.js', + 'chess_char_2_05-harold.js', 'chess_char_2_06-papyrs.js', 'chess_char_2_07-ghost.js', 'chess_char_2_08-bubble.js', + 'chess_char_2_09-humus.js', 'chess_char_2_10-rockr.js', 'chess_char_2_11-kazema.js', 'chess_char_2_12-gravel.js', + 'chess_char_2_13-tippi.js', 'chess_char_2_14-flower.js', 'chess_char_2_15-akkord.js', 'chess_char_2_16-whitew.js', + 'chess_char_2_17-branch.js', 'chess_char_2_18-ashlok.js', 'chess_char_2_19-tinman.js'], + // tier 3 + ['chess_char_3_01-angel.js', 'chess_char_3_02-ayer.js', 'chess_char_3_03-swire.js', 'chess_char_3_04-swire2.js', + 'chess_char_3_05-skadi.js', 'chess_char_3_06-philae.js', 'chess_char_3_07-forcer.js', 'chess_char_3_08-mint.js', + 'chess_char_3_09-haini.js', 'chess_char_3_10-pinecn.js', 'chess_char_3_11-snhunt.js', 'chess_char_3_12-blemsh.js', + 'chess_char_3_13-malist.js', 'chess_char_3_14-slbell.js', 'chess_char_3_15-vodfox.js', 'chess_char_3_16-snakek.js', + 'chess_char_3_17-shotst.js', 'chess_char_3_18-vulpis.js', 'chess_char_3_19-vigil.js', 'chess_char_3_20-kjera.js', + 'chess_char_3_21-archet.js'], + // tier 4 + ['chess_char_4_01-rmixer.js', 'chess_char_4_02-mostma.js', 'chess_char_4_03-kjera.js', 'chess_char_4_04-ines.js', + 'chess_char_4_05-beewax.js', 'chess_char_4_06-kroos2.js', 'chess_char_4_07-bpipe.js', 'chess_char_4_08-rosesa.js', + 'chess_char_4_09-mizuki.js', 'chess_char_4_10-aroma.js', 'chess_char_4_11-cathy.js', 'chess_char_4_12-glady.js', + 'chess_char_4_13-gnosis.js', 'chess_char_4_14-lionhd.js', 'chess_char_4_15-reckpr.js', 'chess_char_4_16-texas2.js', + 'chess_char_4_17-hsguma.js', 'chess_char_4_18-mudrok.js', 'chess_char_4_19-flamtl.js', 'chess_char_4_20-fartth.js', + 'chess_char_4_21-plosis.js', 'chess_char_4_22-svrash.js', 'chess_char_4_23-gvial2.js', 'chess_char_4_24-billro.js', + 'chess_char_4_25-cetsyr.js', 'chess_char_4_26-bldsk.js'], + // tier 5 + ['chess_char_5_01-excu2.js', 'chess_char_5_02-titi.js', 'chess_char_5_03-blaze2.js', 'chess_char_5_04-bldsk.js', + 'chess_char_5_05-ulpia.js', 'chess_char_5_06-etlchi.js', 'chess_char_5_07-surtr.js', 'chess_char_5_08-horn.js', + 'chess_char_5_09-cetsyr.js', 'chess_char_5_10-lisa.js', 'chess_char_5_11-demkni.js', 'chess_char_5_12-dusk.js', + 'chess_char_5_13-ghost2.js', 'chess_char_5_14-svash2.js', 'chess_char_5_15-thorn2.js', 'chess_char_5_16-plosis.js', + 'chess_char_5_17-f12yin.js', 'chess_char_5_18-gvial2.js', 'chess_char_5_19-mlynar.js', 'chess_char_5_20-aglina.js', + 'chess_char_5_21-sntlla.js', 'chess_char_5_22-nymph.js', 'chess_char_5_23-reckpr.js'], + // tier 6 (and 盟约·辅助干员 chess_char_1_15, a hidden tier-1 chess always registered with the tier-6 kits) + ['chess_char_1_15-pithst.js', 'chess_char_6_01-lemuen.js', 'chess_char_6_02-sbell2.js', 'chess_char_6_03-yu.js', + 'chess_char_6_04-skadi2.js', 'chess_char_6_05-pasngr.js', 'chess_char_6_06-pepe.js', 'chess_char_6_07-siege2.js', + 'chess_char_6_08-reed2.js', 'chess_char_6_09-cello.js', 'chess_char_6_10-nymph.js', 'chess_char_6_11-mlyss.js', + 'chess_char_6_12-rosmon.js', 'chess_char_6_13-angel2.js', 'chess_char_6_14-lumen.js', 'chess_char_6_15-qiubai.js', + 'chess_char_6_16-halo2.js', 'chess_char_6_17-nearl2.js', 'chess_char_6_18-whitw2.js', 'chess_char_6_19-blkkgt.js', + 'chess_char_6_20-agoat2.js'], +].map((group) => Object.freeze(group))); + +async function loadKitFile(file) { + try { + return await import(`./ops/${file}`); + } catch (e) { + console.error(`[content] failed to load kits/ops/${file}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); + return {}; + } +} + +const MODULES = await Promise.all(KIT_FILES.map((group) => Promise.all(group.map(loadKitFile)))); +const registryOf = (m) => (m && m.default && typeof m.default === 'object' ? m.default : {}); + +/** The registry of each tier group (index 0 = tier 1): baseChessId → kit builder, in KIT_FILES order. */ +export const TIER_KITS = Object.freeze(MODULES.map((group) => Object.assign({}, ...group.map(registryOf)))); + +/** The merged kit registry: baseChessId → (bb, chess, def) => Kit, tier 1 … tier 6. */ +export const KITS = Object.freeze(Object.assign({}, ...TIER_KITS)); diff --git a/server/sim/content/kits/ops/chess_char_1_01-inside.js b/server/sim/content/kits/ops/chess_char_1_01-inside.js new file mode 100644 index 00000000..167a2f0e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_01-inside.js @@ -0,0 +1,61 @@ +// server/sim/content/kits/ops/chess_char_1_01-inside.js — 隐现 (char_498_inside) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, up, once, skillBbOf, batMod } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_01 隐现 “解决麻烦”: ammo (attack@trigger_time) ATK+atk, base attack time +base_attack_time s, prefers ranged + // enemies, taunt −1. 火力支援: after `duration` s on the field own ammo +self_ammo (from the next cast); elite: another + // random 【拉特兰】 op's ammo +ally_ammo while that deployment lasts (one grant per source). + // Alternate S1 “不惹麻烦” (AUTO, attack SP): attack@trigger_time rounds of attack@atk_scale × ATK physical (an ammo + // skill, so 火力支援's larger magazine applies to it too). + chess_char_1_01_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const dur = num(t.duration), selfAmmo = num(t.self_ammo), allyAmmo = num(t.ally_ammo); + const s1 = skillBbOf(chess, 'skchr_inside_1'); + return { + skill: { + kind: 'ammo', ammo: num(bb['attack@trigger_time'], 1), + mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess, def?.skill?.description ?? chess?.skill?.desc), taunt: -1 }, + targeting: { priority: 'ranged' }, + }, + skills: { + skchr_inside_1: { kind: 'ammo', ammo: Math.max(1, Math.floor(num(s1['attack@trigger_time'], 4))), attack: { atkScale: num(s1['attack@atk_scale'], 1) } }, + }, + talents: [{ install(battle, unit) { + const onField = () => up(unit) && battle.time - unit.deployedAt + 1e-9 >= dur; + battle.on('skillStart', ({ unit: u, skill }) => { + if (u === unit && skill.kind === 'ammo' && onField() && selfAmmo > 0) skill.addAmmo(selfAmmo); + }, { owner: unit }); + if (!(allyAmmo > 0)) return; + // elite: once per deployment, `duration` s after it, one random other 【拉特兰】 operator with an ammo skill gets + // +ally_ammo for as long as this deployment lasts (one grant per source — redeploying never stacks it) + battle.on('deploy', ({ unit: u }) => { + if (u !== unit) return; + const seq = unit.deploySeq; + battle.after(dur, () => { + // (the larger magazine applies from the next activation: a running skill keeps its rounds) + if (!up(unit) || unit.deploySeq !== seq) return; + const pool = battle.allyUnits.filter((a) => a !== unit && a.kind === 'op' && a.ownerId === unit.ownerId && a.alive + && (a.def.bonds || []).includes('lateranoShip') && a.skill && a.skill.kind === 'ammo'); + const pick = battle.rng.pick(pool); + if (!pick) return; + (pick.mem.insiderAmmo ??= new Map()).set(unit.id, { src: unit, seq, n: allyAmmo }); + battle.fx('buff', { x: pick.x, y: pick.y, id: pick.id, kind: 'ammo' }); + }, { owner: unit }); + }, { owner: unit }); + once(battle, 'insider:bonusAmmo', () => battle.on('skillStart', ({ unit: u, skill }) => { + const m = u.mem.insiderAmmo; + if (!m || skill.kind !== 'ammo') return; + let n = 0; + for (const [k, g] of m) { + if (up(g.src) && g.src.deploySeq === g.seq) n += g.n; + else m.delete(k); + } + if (n > 0) skill.addAmmo(n); + })); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_02-yak.js b/server/sim/content/kits/ops/chess_char_1_02-yak.js new file mode 100644 index 00000000..a6df6b7a --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_02-yak.js @@ -0,0 +1,19 @@ +// server/sim/content/kits/ops/chess_char_1_02-yak.js — 角峰 (char_199_yak) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, statBuff, skillBbOf } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_02 角峰 抗寒体质: HP +max_hp, DEF +def, RES ×(1+magic_resistance). 雪原卫士: RES +magic_resistance (flat). + // Alternate S1 体能强化 (重装 ⇒ TAKE_DAMAGE trigger from data): HP +max_hp, +hp_recovery_per_sec HP per second + // (the 生命回复速度 attribute). Elite module PRO-Y (block 4) is a stat of the module (attr blockCnt). + chess_char_1_02_a: (bb, chess) => { + const s1 = skillBbOf(chess, 'skchr_yak_1'); + return { + skill: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def), resMul: 1 + num(bb.magic_resistance) } }, + skills: { skchr_yak_1: { kind: 'duration', mods: { hpPct: num(s1.max_hp), hpRegen: num(s1.hp_recovery_per_sec) } } }, + talents: [{ install(battle, unit) { statBuff(battle, unit, 'talent:yak', { resFlat: num(talentBb(chess, 0).magic_resistance) }); } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_03-leizi.js b/server/sim/content/kits/ops/chess_char_1_03-leizi.js new file mode 100644 index 00000000..1569b322 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_03-leizi.js @@ -0,0 +1,27 @@ +// server/sim/content/kits/ops/chess_char_1_03-leizi.js — 惊蛰 (char_306_leizi) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { CHAIN_RADIUS } from '../../../constants.js'; +import { num, talentBb, traitBb, onHitBy, skillBbOf } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_03 惊蛰 初雷: ATK +atk, chain jumps lose no damage. 通流无阻: vs unblocked enemies ATK ×atk_scale (every jump). + // Alternate S1 攻击力强化·γ型: ATK +atk (the chain keeps its falloff). Elite module CHA-X (4 jumps, −10 %, 0.8 s 停顿) + // is the chain profile of the module trait (professions.js TUNE.chain). + chess_char_1_03_a: (bb, chess) => { + const scale = num(talentBb(chess, 0).atk_scale, 1); + return { + skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: {} }, + skills: { 'skcom_atk_up[3]': { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, 'skcom_atk_up[3]').atk) } } }, + talents: [{ install(battle, unit) { + if (scale !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && !target.blockedBy) dmg.amount *= scale; }); + } }], + install(battle, unit) { + // the chain shape (count / sluggish, elite module included) comes from the resolved chain profile + const ch = unit.profile.chain || { count: num(traitBb(chess)['attack@max_target'], 3), radius: CHAIN_RADIUS, sluggish: num(traitBb(chess)['attack@sluggish'], 0.5) }; + if (unit.skill && unit.skill.spec.attack) unit.skill.spec.attack.chain = { ...ch, falloff: 0 }; + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_04-udflow.js b/server/sim/content/kits/ops/chess_char_1_04-udflow.js new file mode 100644 index 00000000..de48dfbc --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_04-udflow.js @@ -0,0 +1,45 @@ +// server/sim/content/kits/ops/chess_char_1_04-udflow.js — 深巡 (char_4137_udflow) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, moduleOn, installReveal, skillBbOf } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_04 深巡 行动能力剥夺: longer line range (skill grid), ATK +atk, ASPD +attack_speed, fin darts pierce + // attack@max_target enemies on the line and cause attack@sluggish s of 停顿. + // 技能策略 → DEFAULT (PR #12; DESIGN §21.29): the official 下半 class row hands every MANUAL 重装 skill TAKE_DAMAGE, + // which makes a 2-2 ranged 哨戒铁卫 wait until something hits her — in practice until she blocks (GitHub issue #4). By + // the owner's deliberate deviation from that row (2026-10-03, community feedback) this offensive ranged skill takes + // the basic strategy (SP ready + about to attack + an enemy inside the initial range); her data says DEFAULT too + // (rawRule keeps the official TAKE_DAMAGE). Only the rule changes: spCost / initSp / spType still come from data. + // 细胞活性抑制剂: attacks inflict `damage` arts per `interval` s for `duration` s (damage_seamonster vs 【海怪】). + // Elite module (SPT-X): stealth of enemies inside the range is cancelled. + // Alternate S1 侵袭破坏应对 (重装 ⇒ TAKE_DAMAGE trigger from data): ATK +atk, DEF +def. + chess_char_1_04_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const s1 = skillBbOf(chess, 'skchr_udflow_1'); + return { + skill: { + trigger: 'DEFAULT', + kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, + targeting: { rangeGrid: def?.skill?.rangeGrid ?? null, maxTargets: num(bb['attack@max_target'], 1) }, + attack: { onHitStatus: { key: 'sluggish', duration: num(bb['attack@sluggish'], 1) } }, + }, + skills: { skchr_udflow_1: { kind: 'duration', mods: { atkPct: num(s1.atk), defPct: num(s1.def) } } }, + talents: [{ install(battle, unit) { + const dur = num(t.duration), iv = num(t.interval, 1); + if (!(dur > 0) || !(num(t.damage) > 0)) return; + battle.on('damaged', (ctx) => { + if (ctx.source !== unit || !ctx.dmg?.isAttack || ctx.target.side !== 'enemy' || !ctx.target.alive) return; + const sea = (ctx.target.def?.tags || []).includes('seamonster'); + const amount = sea ? num(t.damage_seamonster, num(t.damage) * 2) : num(t.damage); + battle.addBuff(ctx.target, { + key: `udflow:dot:${unit.id}`, duration: dur, interval: iv, refresh: 'extend', source: unit, visible: false, + onTick: ({ battle: b, unit: e }) => b.dealDamage(unit, e, { amount, type: 'arts', canDodge: false, tags: ['dot'] }), + }); + }, { owner: unit }); + } }], + install(battle, unit) { if (moduleOn(chess) && chess.isGolden) installReveal(battle, unit); }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_05-vigna.js b/server/sim/content/kits/ops/chess_char_1_05-vigna.js new file mode 100644 index 00000000..62953232 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_05-vigna.js @@ -0,0 +1,33 @@ +// server/sim/content/kits/ops/chess_char_1_05-vigna.js — 红豆 (char_290_vigna) kit, tier 1 (hidden). +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, onHitBy, batMod } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_05 红豆 (hidden) 槌音: base attack time +base_attack_time s, ATK +atk. 蛮力穿刺: each attack prob1 (prob2 while the skill + // runs) for ATK +atk on that attack. Elite module (CHG-Y): vs enemies below hp_ratio HP, ATK ×atk_scale. + chess_char_1_05_a: (bb, chess) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + return { + skill: { kind: 'duration', mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) } }, + talents: [{ install(battle, unit) { + const key = 'vigna:proc'; + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit) return; + const p = unit.skill?.active ? num(t.prob2) : num(t.prob1); + if (p > 0 && battle.rng.chance(p)) { + battle.addBuff(unit, { key, duration: 0.1, mods: { atkPct: num(t.atk) } }); + battle.fx('crit', { x: unit.x, y: unit.y, id: unit.id }); + } + }, { owner: unit }); + // melee hits resolve synchronously inside the attack: the one-attack buff is dropped right after it + battle.on('attack', (ctx) => { if (ctx.attacker === unit && unit.findBuff(key)) battle.removeBuff(unit, key); }, { owner: unit }); + if (tb.hp_ratio != null && num(tb.atk_scale, 1) !== 1) { + onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.hpRatio < num(tb.hp_ratio)) dmg.amount *= num(tb.atk_scale, 1); }); + } + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_06-vendla.js b/server/sim/content/kits/ops/chess_char_1_06-vendla.js new file mode 100644 index 00000000..c4ac4513 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_06-vendla.js @@ -0,0 +1,73 @@ +// server/sim/content/kits/ops/chess_char_1_06-vendla.js — 刺玫 (char_494_vendla) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, up, byEnemyAttack, alliesInGridOf, skillBbOf } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_06 刺玫 荆藤庇荫: ATK +atk; the ally with the highest max HP in range gets taunt +taunt_level; whenever that ally is + // attacked, 刺玫 deals atk_scale × ATK arts to the attacker, and her trait heal (scale × damage) only goes to that ally. + // 土壤基肥改良: the highest-max-HP ally in range receives heal_scale × healing ("受到的治疗效果提升") — never its natural / + // skill HP regeneration (the 生命回复速度 attribute is "不受治疗加成影响": 宴 分神, 角峰 体能强化; PRTS). + // Alternate S1 战术咏唱·γ型: ASPD +attack_speed (the trait heal keeps going to the most injured ally in range). + // Elite module INC-X (heal 60 % of the damage) is the incantation profile of the module trait (TUNE.incantationmedic). + chess_char_1_06_a: (bb, chess) => { + const hs = num(talentBb(chess, 0).heal_scale, 1); + const tauntKey = 'vendla:taunt'; + // the protégé only exists while 荆藤庇荫 runs (mem.protege is set by its onStart only) + const protege = (u) => (u.skill?.active && up(u.mem.protege) ? u.mem.protege : null); + return { + skills: { 'skcom_magic_rage[3]': { kind: 'duration', mods: { aspd: num(skillBbOf(chess, 'skcom_magic_rage[3]').attack_speed) } } }, + trait: { + // 咒愈师 trait: EVERY damage she deals heals an ally for 50 % of it (professions.js `installIncantation`, + // buff_template_data `vendla_tr` = ON_AFTER_OUTPUT_DAMAGE) — while 荆藤庇荫 runs her S2 says "仅对该角色触发刺玫 + // 特性", so her protégé is the target then; otherwise it is the lowest-HP ally in range. + install(battle, u) { + battle.on('damaged', (c) => { + const t = c.target; + if (c.source !== u || !u.alive || !t || t.side !== 'enemy' || !(c.amount > 0)) return; + if (c.type === 'element' || c.type === 'elemental') return; + const ally = (c.dmg && c.dmg.traitAlly) || protege(u) || battle.lowestHpAllyInRange(u); + if (ally) battle.heal(u, ally, c.amount * (u.profile.healRatio ?? 0.5), { tags: ['incantation'] }); + }, { owner: u }); + }, + }, + skill: { + kind: 'duration', mods: { atkPct: num(bb.atk) }, + onStart({ battle, unit, skill }) { + const cands = alliesInGridOf(battle, unit).sort((a, b) => b.s.maxHp - a.s.maxHp || a.deploySeq - b.deploySeq); + const p = cands[0] ?? null; + unit.mem.protege = p; + if (!p) return; + battle.addBuff(p, { key: tauntKey, duration: skill.duration + 0.1, mods: { taunt: num(bb.taunt_level, 1) }, visible: true, source: unit }); + battle.fx('taunt', { x: p.x, y: p.y, id: p.id }); + }, + onEnd({ battle, unit }) { + if (unit.mem.protege) battle.removeBuff(unit.mem.protege, tauntKey); + unit.mem.protege = null; + }, + }, + talents: [{ install(battle, unit) { + battle.on('damaged', (ctx) => { + const p = protege(unit); + if (!p || ctx.target !== p || !byEnemyAttack(ctx) || !ctx.source.alive || !unit.canAct) return; + // "并仅对该角色触发刺玫特性": this counter damage is healed by the trait (install above) for the protégé — the + // damage instance names her, so no separate heal here (it would double) + battle.dealDamage(unit, ctx.source, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, canDodge: false, tags: ['counter'], traitAlly: p }); + battle.fx('counter', { x: ctx.source.x, y: ctx.source.y, id: unit.id }); + }, { owner: unit }); + if (hs !== 1) { + let cacheT = -1, top = null; + battle.on('heal', (ctx) => { + if (!up(unit) || ctx.opts?.regen) return; + if (cacheT !== battle.time) { + cacheT = battle.time; + top = alliesInGridOf(battle, unit).sort((a, b) => b.s.maxHp - a.s.maxHp || a.deploySeq - b.deploySeq)[0] ?? null; + } + if (ctx.target === top) ctx.amount *= hs; + }, { owner: unit }); + } + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_07-prove.js b/server/sim/content/kits/ops/chess_char_1_07-prove.js new file mode 100644 index 00000000..34e588d0 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_07-prove.js @@ -0,0 +1,64 @@ +// server/sim/content/kits/ops/chess_char_1_07-prove.js — 普罗旺斯 (char_145_prove) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets } from '../../../targeting.js'; +import { frontOf } from '../../../dir.js'; +import { bodyOnTile } from '../../../body.js'; +import { num, talentBb, isMainHit, onHitBy, skillBbOf } from '../shared/tier1.js'; + +/** 普罗旺斯 杀戮嗅觉 "普通攻击不再以生命值高于80%的敌人作为目标" — the 80 % exists only in the skill text. */ +const PROVE_S2_MAX_HP = 0.8; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_07 普罗旺斯 狼眼 (passive): per hp_ratio_drop of HP the target has lost, ATK +atk_scale_up against it — CONTINUOUS, + // not in 20 % steps (PRTS note: "并非以每20%为固定节点 … 攻击力倍率提升至[100%+(100%-目标生命比例)×加成量×5]", i.e. + // ×(1 + lost / hp_ratio_drop × atk_scale_up)). + // 狩猎箭头: each attack prob (prob2 when the enemy is on the tile right in front) for ATK ×atk_scale. + // 狼眼 is an ATK increase ⇒ it scales her (ATK-based) attacks only, never fixed-value damage she sources + // (叙拉古 5000+50·L true proc, fixed item/DoT ticks…). + // Alternate S2 杀戮嗅觉: ATK +atk, but normal attacks never target an enemy above 80 % HP (with only such enemies in + // range she holds her fire). Her attacks stay normal attacks. (狼眼 is S1 only: absent when S2 is carried.) + // Elite module ARC-X only changes stats (ATK/DEF, faster redeploy). + chess_char_1_07_a: (bb, chess) => { + const t = talentBb(chess, 0); + const drop = num(bb.hp_ratio_drop), upPer = num(bb.atk_scale_up); + const S2 = 'skchr_prove_2'; + const huntable = (e) => e.hpRatio <= PROVE_S2_MAX_HP + 1e-9; + const s2On = (u) => !!(u.skill && u.skill.active && u.skill.id === S2); + return { + skill: { kind: 'passive' }, + skills: { [S2]: { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, S2).atk) } } }, + trait: { + canAttack: (battle, u) => !s2On(u) || battle.enemiesInKeys(u.rangeKeys, u, u.profile).some(huntable), + }, + install(battle, unit) { + if (unit.skill?.id !== S2) return; + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !s2On(unit) || ctx.targets.every(huntable)) return; + const prof = ctx.profile || unit.profile; + const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof).filter(huntable); + sortEnemyTargets(battle, unit, cands, prof.priority); + ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length)); + }, { owner: unit }); + }, + talents: [{ install(battle, unit) { + onHitBy(battle, unit, ({ target, dmg }) => { + if (!dmg.isAttack) return; + if (drop > 0 && upPer > 0) { + const lost = Math.max(0, Math.min(1, 1 - target.hpRatio)); + if (lost > 0) dmg.amount *= 1 + (lost / drop) * upPer; + } + if (!isMainHit(dmg)) return; + const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir); + const front = bodyOnTile(target, fr, fc); + const p = front ? num(t.prob2) : num(t.prob); + if (p > 0 && battle.rng.chance(p)) { + dmg.amount *= num(t.atk_scale, 1); + battle.fx('crit', { x: target.x, y: target.y, id: unit.id }); + } + }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_08-texas.js b/server/sim/content/kits/ops/chess_char_1_08-texas.js new file mode 100644 index 00000000..aa6516b0 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_08-texas.js @@ -0,0 +1,43 @@ +// server/sim/content/kits/ops/chess_char_1_08-texas.js — 德克萨斯 (char_102_texas) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, enemiesInGrid, skillBbOf, RING1 } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_08 德克萨斯 剑雨: +cost DP; every enemy around (skill grid) takes two hits of atk_scale × ATK arts and is + // stunned `stun` s — air units too (PRTS 备注 "※可对空"). 战术快递: +cost initial DP. (Elite hidden runtime_cost −4 + // "首次部署时部署费用-4": the initial deployment is free in battle ⇒ no effect.) + // Alternate S1 冲锋号令·γ型 (AUTO, no target): +cost DP at once. An AUTO skill keeps its normal rule in this mode + // (research 03 §1.4: only MANUAL skills are converted by the trigger table) ⇒ it fires as soon as SP is full. + chess_char_1_08_a: (bb, chess, def) => ({ + skills: { + 'skcom_charge_cost[3]': { + kind: 'instant', trigger: 'SP_FULL', + onStart({ battle, unit }) { + const n = num(skillBbOf(chess, 'skcom_charge_cost[3]').cost); + battle.addDp(unit.ownerId, n); + battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id }); + }, + }, + }, + skill: { + kind: 'instant', + onStart({ battle, unit }) { + battle.addDp(unit.ownerId, num(bb.cost)); + battle.fx('dp', { x: unit.x, y: unit.y, n: num(bb.cost), id: unit.id }); + const grid = def?.skill?.rangeGrid; + const foes = grid ? enemiesInGrid(battle, unit, grid) : battle.foesInRadius(unit.x, unit.y, RING1).filter((e) => !e.s.flags.untargetable); + battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'swordRain' }); + for (const e of foes) { + for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill'] }); + if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit }); + } + }, + }, + talents: [{ install(battle, unit) { + const n = num(talentBb(chess, 0).cost); + if (n > 0) battle.on('battleStart', () => battle.addDp(unit.ownerId, n), { owner: unit }); + } }], + }), +}; diff --git a/server/sim/content/kits/ops/chess_char_1_09-caper.js b/server/sim/content/kits/ops/chess_char_1_09-caper.js new file mode 100644 index 00000000..88c00942 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_09-caper.js @@ -0,0 +1,32 @@ +// server/sim/content/kits/ops/chess_char_1_09-caper.js — 跃跃 (char_4100_caper) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, cheb, onHitBy, skillBbOf } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_09 跃跃 (回环射手: every attack is a boomerang out to the target and back — she attacks again only once it is + // caught, professions.js loopshooter / ai.js throwBoomerang). 乐趣加倍: ATK +atk, each attack throws `cnt` + // boomerangs at the target (cnt hits); they fly the same path, so the flight carries cnt hits and all of them are + // back together ("必须回收全部回旋投掷物才可以进行下一次攻击"). 戏耍随心: prob per hit (each boomerang on its own) for + // ATK ×atk_scale. Elite module (LPS-X, trait atk_scale): vs enemies on the 8 surrounding tiles ATK ×atk_scale. + // Alternate S1 强力击·β型 (AUTO, attack SP): the next attack at atk_scale × ATK. + chess_char_1_09_a: (bb, chess) => { + const t = talentBb(chess, 0); + const near = num(traitBb(chess).atk_scale, 1); + return { + skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: { hits: Math.max(1, Math.floor(num(bb.cnt, 1))) } }, + skills: { skchr_caper_1: { kind: 'instant', attack: { atkScale: num(skillBbOf(chess, 'skchr_caper_1').atk_scale, 1) } } }, + talents: [{ install(battle, unit) { + onHitBy(battle, unit, ({ target, dmg }) => { + if (!dmg.isAttack) return; + if (near !== 1 && cheb(unit, target) <= 1) dmg.amount *= near; + if (!dmg.isSplash && num(t.prob) > 0 && battle.rng.chance(num(t.prob))) { + dmg.amount *= num(t.atk_scale, 1); + battle.fx('crit', { x: target.x, y: target.y, id: unit.id }); + } + }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_10-sunbr.js b/server/sim/content/kits/ops/chess_char_1_10-sunbr.js new file mode 100644 index 00000000..edde385b --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_10-sunbr.js @@ -0,0 +1,85 @@ +// server/sim/content/kits/ops/chess_char_1_10-sunbr.js — 古米 (char_196_sunbr) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, isMainHit, onHitBy, instantKind, skillRec } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_10 古米 备用军粮 (自动触发, ct charges — an AUTO skill: no 技能策略 applies, it keeps its own rule; PRTS 备注 "此技能 + // 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式(普通攻击改为治疗技能范围内的一名友方单位),直至古米 + // 完成一次普通攻击的治疗"): cast as soon as a healable ally of the skill range (x-4, herself included) is injured — + // no enemy needed (user playtest #6: it waited until she engaged) — then her next normal attack heals the most + // injured ally of the skill range for heal_scale × ATK instead of hitting an enemy; she makes no normal attack until + // that heal is done. Elite module (GUA-X): targets below hp_ratio HP get ×heal_scale more. + // 平底锅专精: each attack prob for ATK ×atk_scale and a `stun` s stun (only when that hit lands). + // Alternate S2 食粮烹制 (PRTS notes: 10 s 缴械 at the start, the real duration is disarm + 30 s, attacks become heals + // with the attack interval +130 %): cooking for `disarm` s — no attacks, DEF +def; then for the skill's `duration` s + // she heals the most injured ally inside the skill grid (x-4) for ATK per attack, ATK +atk, base attack time + // ×(1 + base_attack_time). The GUA-X heal bonus (below hp_ratio: ×heal_scale) applies to these heals too. + // Trigger: TAKE_DAMAGE (data: the 重装 strategy "不受技能范围影响,受到伤害时释放技能"). + chess_char_1_10_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + const S2 = 'skchr_sunbr_2'; + const r2 = skillRec(chess, S2); + const b2 = r2?.bb ?? {}; + const cook = Math.max(0, num(b2.disarm, 10)); + const cookKey = 'sunbr:cook', serveKey = 'sunbr:serve'; + const healGrid = def?.skill?.rangeGrid ?? null; + return { + skills: { + [S2]: { + kind: 'duration', duration: cook + Math.max(0, num(r2?.duration, 30)), + mods: { batPct: Math.max(0, num(b2.base_attack_time)) }, + targeting: { rangeGrid: r2?.rangeGrid ?? def?.skill?.rangeGrid ?? null }, + attack: { dmgType: 'heal', heal: { mode: 'single' } }, + onStart({ battle, unit }) { + battle.addBuff(unit, { key: cookKey, duration: cook, mods: { defPct: num(b2.def) }, flags: { disarm: true }, tags: ['skill'], visible: true }); + }, + onTick({ battle, unit }) { + if (!unit.findBuff(cookKey) && !unit.findBuff(serveKey)) { + battle.addBuff(unit, { key: serveKey, mods: { atkPct: num(b2.atk) }, tags: ['skill'] }); + battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'heal' }); + } + }, + onEnd({ battle, unit }) { + battle.removeBuff(unit, cookKey); + battle.removeBuff(unit, serveKey); + }, + }, + }, + install(battle, unit) { + if (tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return; + // GUA-X on her skill heals (S1's heal-mode attack, S2's attack heals while it runs — herself included, she stands + // in the grid), never her natural / self regeneration + battle.on('heal', (ctx) => { + if (ctx.source !== unit || !unit.skill?.active || ctx.opts?.regen || ctx.opts?.self) return; + if (ctx.target.hpRatio < num(tb.hp_ratio)) ctx.amount *= num(tb.heal_scale, 1); + }, { owner: unit }); + }, + skill: { + kind: instantKind(def), + heal: true, + ...(healGrid ? { trigger: { rule: 'SKILL_RANGE', grid: healGrid, allies: true }, targeting: { rangeGrid: healGrid } } : {}), + // the heal-mode attack: the most injured ally of the skill range (acquireTargets, heal profile) + attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: num(bb.heal_scale, 1), projectile: 'none' }, + onHit({ battle, target }) { if (target) battle.fx('heal', { x: target.x, y: target.y, id: target.id }); }, + }, + talents: [{ install(battle, unit) { + // the proc is rolled per attack hit; the stun only follows a hit that landed (not dodged / cancelled) + const procs = new WeakSet(); + onHitBy(battle, unit, ({ target, dmg }) => { + if (!isMainHit(dmg) || !(num(t.prob) > 0) || !battle.rng.chance(num(t.prob))) return; + dmg.amount *= num(t.atk_scale, 1); + procs.add(dmg); + battle.fx('crit', { x: target.x, y: target.y, id: unit.id }); + }); + battle.on('damaged', (ctx) => { + if (ctx.source !== unit || !ctx.dmg || !procs.has(ctx.dmg) || !ctx.target.alive) return; + procs.delete(ctx.dmg); + battle.applyStatus(ctx.target, 'stun', { duration: num(t.stun), source: unit }); + }, { owner: unit }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_11-skgoat.js b/server/sim/content/kits/ops/chess_char_1_11-skgoat.js new file mode 100644 index 00000000..efcbbfae --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_11-skgoat.js @@ -0,0 +1,15 @@ +// server/sim/content/kits/ops/chess_char_1_11-skgoat.js — 地灵 (char_183_skgoat) kit, tier 1 (hidden). +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_11 地灵 (hidden) 攻击力强化·β型: ATK +atk. Elite 地质勘探: trait 停顿 +sluggish s (module trait 1.2 s included). + chess_char_1_11_a: (bb, chess) => { + const add = num(talentBb(chess, 0).sluggish); + const kit = { skill: { kind: 'duration', mods: { atkPct: num(bb.atk) } } }; + if (add > 0) kit.trait = { onHitStatus: { key: 'sluggish', duration: num(traitBb(chess).sluggish, 0.8) + add } }; + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_12-estell.js b/server/sim/content/kits/ops/chess_char_1_12-estell.js new file mode 100644 index 00000000..9c650a37 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_12-estell.js @@ -0,0 +1,34 @@ +// server/sim/content/kits/ops/chess_char_1_12-estell.js — 艾丝黛尔 (char_127_estell) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { absoluteRangeKeys } from '../../../targeting.js'; +import { bodyInKeys } from '../../../body.js'; +import { num, talentBb, talentGrid, traitBb, up, cheb, onHitOn, skillBbOf } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_12 艾丝黛尔 舍身突击 (hurt SP): ATK +atk, cannot be healed by others. 自愈能力: an enemy dying within the talent + // grid (8 surrounding tiles) heals her hp_ratio × max HP. Elite module (CEN-Y): above hp_ratio HP, physical damage + // taken −damage_resistance. + // Alternate S1 攻击力强化·β型: ATK +atk (she stays a heal target). + chess_char_1_12_a: (bb, chess) => { + const t = talentBb(chess, 0); + const grid = talentGrid(chess, 0); + const tb = traitBb(chess); + return { + skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: { noHeal: true } }, + skills: { 'skcom_atk_up[2]': { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, 'skcom_atk_up[2]').atk) } } }, + talents: [{ install(battle, unit) { + battle.on('death', (ctx) => { + const e = ctx.unit; + if (e.side !== 'enemy' || ctx.reason !== 'killed' || !up(unit)) return; + const near = grid ? bodyInKeys(e, absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : cheb(unit, e) <= 1; + if (near) battle.heal(unit, unit, unit.s.maxHp * num(t.hp_ratio), { self: true, tags: ['talent'] }); + }, { owner: unit }); + if (tb.damage_resistance != null) { + onHitOn(battle, unit, ({ dmg }) => { if (dmg.type === 'phys' && unit.hpRatio > num(tb.hp_ratio)) dmg.mul *= 1 - num(tb.damage_resistance); }); + } + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_13-podego.js b/server/sim/content/kits/ops/chess_char_1_13-podego.js new file mode 100644 index 00000000..a20b5135 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_13-podego.js @@ -0,0 +1,64 @@ +// server/sim/content/kits/ops/chess_char_1_13-podego.js — 波登可 (char_258_podego) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { + num, talentBb, moduleBb, up, enemiesInGrid, enemyInRange, installAura, spTimeBonus, makeZone, skillBbOf, +} from '../shared/tier1.js'; + +// ================================================================================================================= +// tier-local constants that exist nowhere in data + +/** Radius (tiles) of 波登可's spore cloud. No blackboard key; PRTS 波登可 备注 "孢子群范围半径为0.9,可对空". */ +const SPORE_RADIUS = 0.9; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_13 波登可 孢子扩散: a vial bursts on the current target into a projectile_delay_time s spore cloud (radius 0.9, + // PRTS 备注, 可对空): enemies inside are 停顿 and lose their abilities (silence) and take atk_scale × ATK arts per second. + // 园丁: every 【辅助】 operator on the field ATK +atk. Elite module (DEC-X): +sp_recovery_per_sec SP/s with an enemy in range. + // Alternate S1 花香疗法: ATK +atk, normal attacks heal the most injured ally in range instead (ATK per attack). A heal + // skill: DEFAULT fires it with an injured ally inside her initial range (research 03 §1.4) — also checked every tick, + // since she is only "about to attack" with an enemy in range. + chess_char_1_13_a: (bb, chess) => { + const t = talentBb(chess, 0); + const spBonus = num(moduleBb(chess).sp_recovery_per_sec); + const dur = num(bb.projectile_delay_time, 5); + const S1 = 'skchr_podego_1'; + return { + skills: { + [S1]: { kind: 'duration', heal: true, mods: { atkPct: num(skillBbOf(chess, S1).atk) }, attack: { dmgType: 'heal', heal: { mode: 'single' } } }, + }, + install(battle, unit) { + if (unit.skill?.id !== S1) return; + battle.on('tick', () => { + const sk = unit.skill; + if (!up(unit) || !unit.canAct || !sk.ready || sk.active || sk.opCooling || unit.s.flags.silence) return; + if (battle.injuredAlliesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit).length) sk.activate('DEFAULT'); + }, { owner: unit }); + }, + skill: { + kind: 'instant', + onStart({ battle, unit }) { + const tgt = enemiesInGrid(battle, unit, null, { n: 1 })[0]; + const x = tgt ? tgt.x : unit.x + unit.fwd[1], y = tgt ? tgt.y : unit.y + unit.fwd[0]; + const atk = unit.s.atk; + makeZone(battle, unit, { + x, y, radius: SPORE_RADIUS, duration: dur, skill: 'spores', onPulse(b) { + for (const e of b.foesInRadius(x, y, SPORE_RADIUS)) { + if (e.s.flags.untargetable) continue; + b.applyStatus(e, 'sluggish', { duration: 1.05, source: unit }); + b.applyStatus(e, 'silence', { duration: 1.05, source: unit }); + b.dealDamage(unit, e, { amount: atk * num(bb.atk_scale), type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] }); + } + }, + }); + }, + }, + talents: [{ install(battle, unit) { + const v = num(t.atk); + if (v) installAura(battle, unit, { key: 'talent:podego', value: v, select: (a) => a.kind === 'op' && a.def.profession === 'SUPPORT', mods: { atkPct: v } }); + spTimeBonus(battle, unit, spBonus, () => enemyInRange(battle, unit)); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_14-greyy.js b/server/sim/content/kits/ops/chess_char_1_14-greyy.js new file mode 100644 index 00000000..cc20788c --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_14-greyy.js @@ -0,0 +1,22 @@ +// server/sim/content/kits/ops/chess_char_1_14-greyy.js — 格雷伊 (char_253_greyy) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, skillBbOf } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_14 格雷伊 静电释放: ASPD +attack_speed, the talent 停顿 lasts talent_scale × longer. + // 静电场: attacks cause `sluggish` s of 停顿 to the attacked target. + // Alternate S1 战术咏唱·β型: ASPD +attack_speed (the talent 停顿 keeps its length). Elite module SPC-Y: cost only. + chess_char_1_14_a: (bb, chess) => { + const sl = num(talentBb(chess, 0).sluggish); + return { + skills: { 'skcom_magic_rage[2]': { kind: 'duration', mods: { aspd: num(skillBbOf(chess, 'skcom_magic_rage[2]').attack_speed) } } }, + trait: sl > 0 ? { onHitStatus: { key: 'sluggish', duration: sl } } : undefined, + skill: { + kind: 'duration', mods: { aspd: num(bb.attack_speed) }, + attack: sl > 0 ? { onHitStatus: { key: 'sluggish', duration: sl * num(bb.talent_scale, 1) } } : {}, + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_15-pithst.js b/server/sim/content/kits/ops/chess_char_1_15-pithst.js new file mode 100644 index 00000000..ea45302f --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_15-pithst.js @@ -0,0 +1,54 @@ +// server/sim/content/kits/ops/chess_char_1_15-pithst.js — 盟约·辅助干员 (char_616_pithst) kit, tier 1 (hidden). +// Registered with the tier-6 kits, whose conventions are in ../shared/tier6.js; kit contract and rules: ../README.md. + +import { hasHp } from '../../../damage.js'; +import { num, tbb, isElite, elementDmg } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 盟约·辅助干员 chess_char_1_15 (巫役) — S1 战术咏唱·双型; 迭代元素 + +/** 迭代元素's elements in the order they are applied: 神经损伤 "(优先)", then 灼燃损伤, then 凋亡损伤. */ +export const PITHST_ELEMENTS = Object.freeze(['neural', 'burn', 'apoptosis']); + +/** + * 迭代元素 "攻击同时附带18%攻击力的神经损伤(优先)、灼燃损伤、凋亡损伤;优先攻击未处于损伤爆发的目标": + * * timing = every damage she deals (PRTS 盟约·辅助干员 corrects "攻击同时" to "造成伤害时"): HP damage > 0 — a dodged + * or fully absorbed hit deals none; her own element fills and the bursts they cause never re-trigger it; a killing + * blow attaches nothing (the target is dead — no burst on the corpse); + * * each such damage attaches ep_damage_ratio × ATK of ALL THREE elements, 神经 first, then 灼燃, then 凋亡 (user + * playtest #5 #3: the kit used to pick one element "not bursting" — but a burst's 爆发冷却 locks every gauge of the + * unit, so it only ever filled 神经). PRTS 元素: when one unit applies several elements that would each fill their + * gauge, the element applied first bursts — from her alone 神经 bursts (the "(优先)"), while her 灼燃 / 凋亡 add to + * the gauges the team builds (余, 塑心 …). [ASSUMED: simultaneous application — the reading of the official text] + * * elite module RIT-X "对精英和领袖敌人造成的元素损伤提升18%" = ep_damage_ratio_boss (0.2124) vs ELITE / BOSS enemies: + * a talent ratio, not an "元素损伤提升" (PRTS 备注), so it changes nothing else she deals (e.g. a 灼燃维式重锤). + * The target pick prefers enemies not in a burst (`priority: 'notBurst'`). + */ +function pithst(bb, chess, def) { + const t0 = tbb(def, 0); + const ratio = num(t0.ep_damage_ratio); + const ratioBoss = num(t0.ep_damage_ratio_boss, ratio); + return { + skill: { + kind: 'duration', + mods: { aspd: num(bb.attack_speed) }, + targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) }, + }, + trait: { priority: 'notBurst' }, + install(battle, unit) { + if (!(ratio > 0)) return; + battle.on('damaged', (ctx) => { + const t = ctx.target, d = ctx.dmg; + // hasHp: a killing blow attaches nothing (the hook runs before battle.kill, see hasHp) + if (ctx.source !== unit || !t || t.side !== 'enemy' || !hasHp(t) || !(ctx.amount > 0)) return; + if (ctx.type === 'element' || ctx.type === 'elemental' || (d && d.tags && d.tags.includes('burst'))) return; + const amount = unit.s.atk * (isElite(t) ? ratioBoss : ratio); + for (const el of PITHST_ELEMENTS) elementDmg(battle, unit, t, el, amount, ['talent', 'pithst']); + }, { owner: unit }); + }, + }; +} + +export default { + chess_char_1_15_a: pithst, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_16-tinman.js b/server/sim/content/kits/ops/chess_char_1_16-tinman.js new file mode 100644 index 00000000..a6bde74e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_16-tinman.js @@ -0,0 +1,67 @@ +// server/sim/content/kits/ops/chess_char_1_16-tinman.js — 锡人 (char_4151_tinman) kit, tier 1 (hidden). +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, moduleBb, enemiesInGrid, once, spTimeBonus, makeZone, instantKind } from '../shared/tier1.js'; + +// ================================================================================================================= +// kit builders shared by two chess ids + +/** + * 锡人 “大拉里” (chess_char_1_16 hidden and chess_char_2_19): throw an alchemy unit at the current target — + * for projectile_delay_time s, ground enemies within projectile_range tiles take atk_scale × ATK arts per second, + * allies there recover hp_recovery_per_sec_ratio × ATK per second. Elite: 凋敝魂灵 (skill@damage_scale: damage-over- + * time taken by ground enemies inside a unit ×1.2) and the module's +sp_recovery_per_sec SP while a unit exists. + */ +export function tinmanKit(bb, chess, def) { + const dur = num(bb.projectile_delay_time, 10); + const radius = num(bb.projectile_range, 1.5); + const dmgScale = num(bb.atk_scale); + const healRatio = num(bb.hp_recovery_per_sec_ratio); + const wither = num(talentBb(chess, 1)['skill@damage_scale'], 1); + const spBonus = num(moduleBb(chess).sp_recovery_per_sec); + const witherKey = 'tinman:wither'; + return { + skill: { + kind: instantKind(def), + onStart({ battle, unit }) { + const tgt = enemiesInGrid(battle, unit, null, { n: 1, groundOnly: true })[0] ?? enemiesInGrid(battle, unit, null, { n: 1 })[0]; + const x = tgt ? tgt.x : unit.x + unit.fwd[1], y = tgt ? tgt.y : unit.y + unit.fwd[0]; + const atk = unit.s.atk; + unit.mem.tinZones = (unit.mem.tinZones ?? 0) + 1; + makeZone(battle, unit, { + x, y, radius, duration: dur, skill: 'tinman', onPulse(b) { + for (const e of b.foesInRadius(x, y, radius)) { + if (e.isFlying || e.s.flags.untargetable) continue; + if (wither > 1) b.addBuff(e, { key: witherKey, duration: 1.05, data: { mul: wither }, source: unit }); + b.dealDamage(unit, e, { amount: atk * dmgScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] }); + } + if (healRatio > 0) for (const a of b.alliesInRadius(x, y, radius, null)) if (a.hp < a.s.maxHp) b.heal(unit, a, atk * healRatio, { tags: ['zone'] }); + }, + onEnd() { unit.mem.tinZones = Math.max(0, (unit.mem.tinZones ?? 1) - 1); }, + }); + }, + }, + talents: [{ install(battle, unit) { + if (wither > 1) { + // "持续伤害" = damage over time only: 'dot'-tagged ticks and the per-second ticks of a necrosis / apoptosis + // burst — never instantaneous bursts (burn/neural bursts, skill/summon bursts are also tagged 'burst') + once(battle, witherKey, () => battle.on('hit', (ctx) => { + if (!ctx.target || ctx.target.side !== 'enemy') return; + const tags = ctx.dmg.tags || []; + if (!(tags.includes('dot') || tags.includes('necrosis') || tags.includes('apoptosis'))) return; + const w = ctx.target.findBuff(witherKey); + if (w) ctx.dmg.amount *= w.data?.mul ?? 1; + })); + } + spTimeBonus(battle, unit, spBonus, () => (unit.mem.tinZones ?? 0) > 0); + } }], + }; +} + +export default { + // 1_15 盟约·辅助干员 (hidden, band 优等生): its kit is tier6.js `pithst` (迭代元素 — one implementation only). + + // --------------------------------------------------------------------------------------------------------------- + // 1_16 锡人 (hidden tier-1 entry of chess_char_2_19): see tinmanKit. + chess_char_1_16_a: tinmanKit, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_17-indigo.js b/server/sim/content/kits/ops/chess_char_1_17-indigo.js new file mode 100644 index 00000000..2b6e65de --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_17-indigo.js @@ -0,0 +1,71 @@ +// server/sim/content/kits/ops/chess_char_1_17-indigo.js — 深靛 (char_469_indigo) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets } from '../../../targeting.js'; +import { num, talentBb, skillRec, batMod } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_17 深靛 光影迷宫: attack interval ×base_attack_time, talent chance ×talent_scale, bound enemies in range take + // indigo_s_2[damage].atk_scale × ATK arts every indigo_s_2[damage].interval s. + // 柔光缚目: attacks bind the target `duration` s with `prob`; bound enemies are never chosen as targets. + // Alternate S1 灯塔守卫者: for `duration` s the skill range (4-1), attack interval ×(1 + base_attack_time) (PRTS: + // "大幅度缩短(-80%)" ⇒ ×0.2 — a flat −0.8 s on 3 s would be less than S2's "略微缩短 ×0.7"), every attack hits for + // attack@atk_scale × ATK arts. Elite module MSC-X (store 4) is the mystic profile of the module trait (TUNE.mystic). + chess_char_1_17_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const dScale = num(bb['indigo_s_2[damage].atk_scale']); + const dIv = num(bb['indigo_s_2[damage].interval'], 0.5); + const bound = (e) => !!e.s.flags.bind; + const r1 = skillRec(chess, 'skchr_indigo_1'); + const b1 = r1?.bb ?? {}; + return { + skills: { + skchr_indigo_1: { + kind: 'duration', mods: { batPct: Math.max(-0.95, Math.min(0, num(b1.base_attack_time))) }, + targeting: { rangeGrid: r1?.rangeGrid ?? null }, + attack: { atkScale: num(b1['attack@atk_scale'], 1) }, + }, + }, + trait: { + // with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she holds her + // fire (the mystic trait stores the energy meanwhile) + canAttack(battle, u) { + const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e)); + if (!ok) u.trait.hadTarget = false; + return ok; + }, + afterHit(battle, u, target) { + if (!target || !target.alive || target.side !== 'enemy') return; + const p = num(t.prob) * (u.skill?.active ? num(bb.talent_scale, 1) : 1); + if (p > 0 && battle.rng.chance(Math.min(1, p))) battle.applyStatus(target, 'bind', { duration: num(t.duration), source: u }); + }, + }, + skill: { + kind: 'duration', mods: { batPct: batMod(bb.base_attack_time, chess, def?.skill?.description ?? chess?.skill?.desc) }, + onStart({ unit }) { unit.mem.indigoAcc = 0; }, + onTick({ battle, unit, dt }) { + if (!(dScale > 0) || !(dIv > 0)) return; + unit.mem.indigoAcc = (unit.mem.indigoAcc ?? 0) + dt; + while (unit.mem.indigoAcc >= dIv - 1e-9) { + unit.mem.indigoAcc -= dIv; + for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) { + if (bound(e)) battle.dealDamage(unit, e, { amount: unit.s.atk * dScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot'] }); + } + } + }, + }, + talents: [{ install(battle, unit) { + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !ctx.targets.some(bound)) return; + const prof = ctx.profile || unit.profile; + const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof); + for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e); + for (let i = cands.length - 1; i >= 0; i--) if (bound(cands[i])) cands.splice(i, 1); + sortEnemyTargets(battle, unit, cands, prof.priority); + ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length)); + }, { owner: unit }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_18-utage.js b/server/sim/content/kits/ops/chess_char_1_18-utage.js new file mode 100644 index 00000000..9ee59915 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_18-utage.js @@ -0,0 +1,57 @@ +// server/sim/content/kits/ops/chess_char_1_18-utage.js — 宴 (char_337_utage) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, up, onHitBy, onHitOn, skillBbOf } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_18 宴 落地斩·破门 (passive): at each deployment lose hp_ratio of current HP, then for `duration` s ATK +atk and + // attacks deal arts damage. 认真模式: 坚忍 ASPD up to +min_attack_speed, reached at min_hp_ratio HP (linear). + // Elite (module talent): below hp_ratio HP, 庇护 −damage_resistance physical/arts damage taken. + // Alternate S1 分神: stops attacking, block count 0 (her blocked enemies walk on), DEF +def, recovers + // hp_recovery_per_sec_by_max_hp_ratio × max HP per second (the 生命回复速度 attribute: works under her 武者 no-heal). + chess_char_1_18_a: (bb, chess) => { + const t = talentBb(chess, 0); + const buffKey = 'utage:s2'; + const s1 = skillBbOf(chess, 'skchr_utage_1'); + return { + skills: { + skchr_utage_1: { + kind: 'duration', + mods: { defPct: num(s1.def), hpRegenRatio: num(s1.hp_recovery_per_sec_by_max_hp_ratio), blockCnt: -99 }, + attack: { noAttack: true }, + onStart({ battle, unit }) { battle.releaseBlocked(unit); }, + }, + }, + skill: { + kind: 'passive', + onStart({ battle, unit }) { + const loss = unit.hp * num(bb.hp_ratio); + if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); + battle.addBuff(unit, { key: buffKey, duration: num(bb.duration), mods: { atkPct: num(bb.atk) }, tags: ['skill'], visible: true }); + battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'breach' }); + }, + }, + talents: [{ install(battle, unit) { + onHitBy(battle, unit, ({ dmg }) => { if (dmg.isAttack && dmg.type === 'phys' && unit.findBuff(buffKey)) dmg.type = 'arts'; }); + const maxAs = num(t.min_attack_speed), minHp = num(t.min_hp_ratio); + if (maxAs > 0 && minHp < 1) { + battle.on('tick', () => { + if (!up(unit)) return; + const v = maxAs * Math.max(0, Math.min(1, (1 - unit.hpRatio) / (1 - minHp))); + const cur = unit.findBuff('utage:serious'); + if (cur && Math.abs((cur.data?.v ?? 0) - v) < 0.5) return; + if (v < 0.5) { if (cur) battle.removeBuff(unit, 'utage:serious'); return; } + battle.addBuff(unit, { key: 'utage:serious', mods: { aspd: v }, data: { v }, tags: ['talent'] }); + }, { owner: unit }); + } + // 庇护 (ba.protect): "受到的物理和法术伤害降低相应比例" — physical and arts damage only + if (t.damage_resistance != null && t.hp_ratio != null) { + onHitOn(battle, unit, ({ dmg }) => { + if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < num(t.hp_ratio)) dmg.mul *= 1 - num(t.damage_resistance); + }); + } + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_19-wildmn.js b/server/sim/content/kits/ops/chess_char_1_19-wildmn.js new file mode 100644 index 00000000..9f919449 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_19-wildmn.js @@ -0,0 +1,67 @@ +// server/sim/content/kits/ops/chess_char_1_19-wildmn.js — 野鬃 (char_496_wildmn) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { num, talentBb, skillRec } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_19 野鬃 夹枪冲锋: wider range (skill grid), ATK +atk, "攻击会把目标往攻击方向中等力度地推开" (attack@force 1 = 中力) by + // the official 力度 − 重量 distance (Battle.push: weight 0 → 2.14 tiles, 1 → 1.7, 2 → 0.44, 3 → 0.12, ≥ 4 → none; user + // playtest #6 item 14). A directional push (方向力, PRTS 推与拉): the client's S2 attack ability (charpack + // char_496_wildmn, anim Skill_2) carries buff wildmn_s_2[force] of template knockback[dir] — buff_template_data: + // Knockback {_useSourceDirection: true, _decreaseForceLevelWhenNotInDirection: 2} — i.e. along her deploy direction, + // radial at 受力等级 −2 for a target > 45° off it or < 0.25 tile away (特殊修正); the radial pushes are + // knockback[relative] (琳琅诗怀雅 S3, 山 S3, 莫斯提马 S3). Her text uses the 推击手 wording "往攻击方向". So an enemy she + // grabs behind her centre (the hand-over after a push freed her block) is not thrown 1.7 tiles towards the objective + // (player feedback D2, "往攻击方向相反方向推"; it was a radial push before). + // 一致向前: after deploying (normal flag 0: first deployment only; elite flag 1: every deployment) every undeployed + // 【近卫】 operator of the player costs `value` less DP to deploy (≤ max_stack_cnt per operator until it deploys). + // Alternate S1 骑枪刺击 (PASSIVE, ON_DEPLOY): for `duration` s after every deployment ASPD +attack_speed. + // Elite module CHG-X (+2 DP per kill) is the charger profile of the module trait (TUNE.charger). + chess_char_1_19_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const force = num(bb['attack@force'], 1); + const r1 = skillRec(chess, 'skchr_wildmn_1'); + return { + skills: { + skchr_wildmn_1: { + kind: 'passive', + onStart({ battle, unit }) { + const d = num(r1?.duration); + if (d > 0) battle.addBuff(unit, { key: 'wildmn:s1', duration: d, mods: { aspd: num(r1?.bb?.attack_speed) }, tags: ['skill'], visible: true }); + }, + }, + }, + skill: { + kind: 'duration', mods: { atkPct: num(bb.atk) }, + targeting: { rangeGrid: def?.skill?.rangeGrid ?? null }, + attack: { + onHit({ battle, unit, target }) { + if (!target || !target.alive || target.side !== 'enemy') return; + battle.push(target, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] } }); + }, + }, + }, + talents: [{ install(battle, unit) { + const cut = Math.abs(num(t.value, -1)), cap = num(t.max_stack_cnt, 1), every = num(t.flag) === 1; + let deploys = 0; + battle.on('deploy', ({ unit: u }) => { + if (u === unit) { + deploys++; + if (!every && deploys > 1) return; + for (const a of battle.allyUnits) { + if (a === unit || a.kind !== 'op' || a.ownerId !== unit.ownerId || a.alive || a.removed || a.def.profession !== 'WARRIOR') continue; + const done = a.mem.wildmnCut ?? 0; + const d = Math.min(cut, cap - done, a.base.cost); + if (d > 0) { a.base.cost -= d; a.mem.wildmnCut = done + d; } + } + } else if (u.mem.wildmnCut > 0) { + // the discount is consumed by this deployment + u.base.cost += u.mem.wildmnCut; + u.mem.wildmnCut = 0; + } + }, { owner: unit }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_1_20-liskam.js b/server/sim/content/kits/ops/chess_char_1_20-liskam.js new file mode 100644 index 00000000..a6907c1f --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_1_20-liskam.js @@ -0,0 +1,81 @@ +// server/sim/content/kits/ops/chess_char_1_20-liskam.js — 雷蛇 (char_107_liskam) kit, tier 1. +// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. + +import { + num, talentBb, talentGrid, moduleOn, up, hurtSpDamage, giveSp, onDamagedOn, alliesInGridOf, statBuff, installReveal, + skillBbOf, +} from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 1_20 雷蛇 反击电弧 (hurt SP): attack interval ×(1 + base_attack_time) (PRTS "攻击间隔增大(+70%)": a RATIO for this skill, + // 1.2 → 2.04 s — not +0.7 s), ATK +atk, arts attacks on up to attack@max_target enemies, + // attack@buff_prob to stun attack@stun s; afterwards 雷蛇 is stunned `stun` s. + // 技能策略 → DEFAULT (PR #12; DESIGN §21.29): 反击电弧 is an offensive skill (ATK +125 %, arts hits on up to 3 enemies, + // stun); the official 下半 TANK row (TAKE_DAMAGE for every MANUAL 重装 skill) made it — and 深巡's S2 — wait for a hit + // (GitHub issue #4). The owner's deliberate deviation from that row (2026-10-03, community feedback) gives both + // 哨戒铁卫 S2s the basic strategy; the data says DEFAULT too (rawRule keeps the official TAKE_DAMAGE). + // 战术防御: when attacked, +sp SP to herself and to one random ally in the talent grid. Elite 雷抗: RES +magic_resistance. + // Elite module (SPT-X): stealth of enemies inside the range is cancelled. + // Alternate S1 充能防御 (AUTO, hurt SP, "技能自动开启" — an AUTO skill takes no 技能策略: SP_FULL, so the hit that fills SP + // sets it off): PRTS "技能拥有8s的持续时间": for bb.duration s DEF +def, and the next damage instance taken is blocked + // (cancelled — a blocked hit "导致第一天赋无法触发": no 战术防御 SP, no hurt SP). + chess_char_1_20_a: (bb, chess) => { + const t = talentBb(chess, 0); + const grid = talentGrid(chess, 0); + const S1 = 'skchr_liskam_1'; + const b1 = skillBbOf(chess, S1); + const blockKey = 'liskam:block'; + return { + skills: { + [S1]: { + kind: 'duration', duration: num(b1.duration, 8), mods: { defPct: num(b1.def) }, trigger: 'SP_FULL', + onStart({ battle, unit, skill }) { + battle.addBuff(unit, { key: blockKey, duration: skill.duration + 0.05, tags: ['skill'], visible: true }); + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); + }, + onEnd({ battle, unit }) { battle.removeBuff(unit, blockKey); }, + }, + }, + install(battle, unit) { + if (moduleOn(chess) && chess.isGolden) installReveal(battle, unit); + if (unit.skill?.id !== S1) return; + battle.on('hit', (ctx) => { + if (ctx.target !== unit || ctx.dmg.cancel || !(ctx.dmg.amount > 0) || !unit.findBuff(blockKey)) return; + ctx.dmg.cancel = true; + battle.removeBuff(unit, blockKey); + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit, priority: 50 }); + }, + skill: { + trigger: 'DEFAULT', + kind: 'duration', mods: { atkPct: num(bb.atk), batPct: Math.max(0, num(bb.base_attack_time)) }, + targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) }, + attack: { + dmgType: 'arts', + onHit({ battle, unit, target }) { + if (target && target.alive && battle.rng.chance(num(bb['attack@buff_prob']))) battle.applyStatus(target, 'stun', { duration: num(bb['attack@stun']), source: unit }); + }, + }, + onEnd({ battle, unit, reason }) { + if (reason !== 'death' && unit.alive && num(bb.stun) > 0) battle.applyStatus(unit, 'stun', { duration: num(bb.stun), source: unit }); + }, + }, + talents: [{ install(battle, unit) { + const sp = num(t.sp); + if (sp > 0) { + // PRTS 备注 "仅伤害量不为0且能够触发受击回复的伤害才能触发此天赋": any such damage, not only an enemy attack + // (a zone tick, the 源石溶剂 drain — player report D1 audit) + onDamagedOn(battle, unit, (ctx) => { + if (!hurtSpDamage(ctx) || !up(unit)) return; + giveSp(unit, sp); + const mates = alliesInGridOf(battle, unit, grid ?? [[1, 0], [0, -1], [0, 1], [-1, 0]]).filter((a) => a !== unit && a.skill && !a.skill.noSkill); + const m = battle.rng.pick(mates); + if (m) giveSp(m, sp); + }); + } + statBuff(battle, unit, 'talent:liskam', { resFlat: num(talentBb(chess, 1).magic_resistance) }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_01-excu.js b/server/sim/content/kits/ops/chess_char_2_01-excu.js new file mode 100644 index 00000000..05a7ceab --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_01-excu.js @@ -0,0 +1,21 @@ +// server/sim/content/kits/ops/chess_char_2_01-excu.js — 送葬人 (char_279_excu) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, statBuff, batMod } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_01 送葬人 最终旅程: normal attacks become double hits, base attack time +base_attack_time s (−0.5 on 2.3 s + // "少量缩短"). 终结改装: ignore def_penetrate_fixed DEF. (Trait: every enemy in range, ×atk_scale on its front row — + // reaperrange profile.) + // + // S1 铳口收束 (alt): ATK +atk; "攻击时对攻击范围内的所有敌人应用特性加成" — every enemy in range takes the trait's + // front-row multiplier (profile frontScale: trait atk_scale 1.5, elite module 1.6, elite without module 1.5). + chess_char_2_01_a: (bb, chess) => ({ + skill: { kind: 'duration', mods: { batPct: batMod(bb.base_attack_time, chess) }, attack: { hits: 2 } }, + skills: { + skchr_excu_1: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: { dmgMul: (battle, u) => num(u.profile?.frontScale, 1.5) } }, + }, + talents: [{ install(battle, unit) { statBuff(battle, unit, 'talent:excu', { defIgnoreFlat: num(talentBb(chess, 0).def_penetrate_fixed) }); } }], + }), +}; diff --git a/server/sim/content/kits/ops/chess_char_2_02-silent.js b/server/sim/content/kits/ops/chess_char_2_02-silent.js new file mode 100644 index 00000000..16396ef2 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_02-silent.js @@ -0,0 +1,36 @@ +// server/sim/content/kits/ops/chess_char_2_02-silent.js — 赫默 (char_108_silent) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { releaseSkillSummon } from '../../tokens.js'; +import { num, talentBb, traitBb, installAura } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_02 赫默 医疗无人机 (AUTO, heal trigger): "获得一个医疗无人机 / 最多可库存1个无人机;无人机投入战场后治疗周围友军, + // 10秒后自动销毁" — the 医疗探机 is a hand piece the player places (user playtest #6; PRTS 卫戍协议/帮助): each cast gives + // one (stock ≤ cnt) and the placed piece takes the field on its own tile (tokens.js releaseSkillSummon: not at the + // battle start — user playtest #4; not placed ⇒ no drone); it heals around itself and self-destructs after 10 s (token + // kit). 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on ground units + // ×heal_scale (module 'none': no bonus). + // S1 治疗强化·γ型 (alt): ATK +atk for its duration (no drone). + chess_char_2_02_a: (bb, chess) => { + const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? [])[0] ?? 'token_10000_silent_healrb'; + const cnt = Math.max(1, Math.floor(num(bb.cnt, 1))); + const t = talentBb(chess, 0); + const tb = traitBb(chess); + return { + skill: { + kind: 'instant', heal: true, + onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokId, { cap: cnt }); }, + }, + skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } }, + talents: [{ install(battle, unit) { + const v = num(t.attack_speed); + if (v) installAura(battle, unit, { key: 'talent:silent', value: v, select: (a) => a.kind === 'op' && a.def.profession === 'MEDIC', mods: { aspd: v } }); + if (num(tb.heal_scale, 1) !== 1) { + battle.on('heal', (ctx) => { if (ctx.source === unit && ctx.target.ground) ctx.amount *= num(tb.heal_scale, 1); }, { owner: unit }); + } + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_03-slchan.js b/server/sim/content/kits/ops/chess_char_2_03-slchan.js new file mode 100644 index 00000000..97388fbe --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_03-slchan.js @@ -0,0 +1,39 @@ +// server/sim/content/kits/ops/chess_char_2_03-slchan.js — 崖心 (char_173_slchan) kit, tier 2 (hidden). +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, enemiesInGrid, toggleBuff } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中力; Battle.pullToFront: + // the official 力度 − 重量 pull — weight ≤ 1 all the way in front of her, 2 a third of the way, 3 barely, ≥ 4 not at all), + // take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking. + // Elite module (HOK-X, trait value/dist): dragged enemies take `value` arts per `dist` tiles travelled. + chess_char_2_03_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + const force = num(bb.force, 1); + return { + skill: { + kind: 'instant', + onStart({ battle, unit }) { + const foes = enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n: Math.max(1, Math.floor(num(bb.max_target, 1))) }); + for (const e of foes) { + const sx = e.x, sy = e.y; + const moved = battle.pullToFront(e, unit, force); + battle.fx('pull', { x: e.x, y: e.y, id: e.id, fromX: sx, fromY: sy }); + if (moved > 0 && num(tb.value) > 0) { + battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(0.01, num(tb.dist, 1)), type: 'arts', isSkill: true, canDodge: false, tags: ['drag'] }); + } + if (!e.alive) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'true', isSkill: true, tags: ['skill'] }); + if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit }); + } + }, + }, + talents: [{ install(battle, unit) { + if (num(t.atk) || num(t.def)) toggleBuff(battle, unit, 'slchan:hunter', () => unit.blocking.length === 0, { atkPct: num(t.atk), defPct: num(t.def) }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_04-grabds.js b/server/sim/content/kits/ops/chess_char_2_04-grabds.js new file mode 100644 index 00000000..c00041cf --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_04-grabds.js @@ -0,0 +1,49 @@ +// server/sim/content/kits/ops/chess_char_2_04-grabds.js — 小满 (char_4122_grabds) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { + num, talentBb, moduleBb, traitBb, enemiesInGrid, enemyInRange, statBuff, spTimeBonus, instantKind, +} from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_04 小满 乡音沉沉: stops attacking and puts max_target enemies in range to sleep `sleep` s; afterwards ASPD + // +attack_speed and attacks max_target enemies. 好好听话: ASPD +attack_speed; vs 【野生动物】 (tag `infection`, + // "野生的被感染生物") the trait 停顿 lasts +sluggish_addition s. Elite module (DEC-X): +sp_recovery_per_sec SP/s with + // an enemy in range. + // S1 竹笛飞声 (alt, 2 charges): the next attack deals atk_scale × ATK arts and hits one more enemy (+1 target). + chess_char_2_04_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + const n = Math.max(1, Math.floor(num(bb.max_target, 1))); + const sleep = num(bb.sleep); + return { + trait: { + onHitStatus: null, + canAttack: (battle, u) => !(u.skill?.active && battle.time < (u.mem.grainQuietUntil ?? -1)), + afterHit(battle, u, target) { + if (!target || !target.alive || target.side !== 'enemy') return; + const beast = (target.def?.tags || []).includes('infection'); + battle.applyStatus(target, 'sluggish', { duration: num(tb.sluggish, 0.8) + (beast ? num(t.sluggish_addition) : 0), source: u }); + }, + }, + skill: { + kind: 'duration', mods: { aspd: num(bb.attack_speed) }, + targeting: { maxTargets: n, rangeGrid: def?.skill?.rangeGrid ?? null }, + onStart({ battle, unit }) { + unit.mem.grainQuietUntil = battle.time + sleep; + for (const e of enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n })) { + if (battle.applyStatus(e, 'sleep', { duration: sleep, source: unit })) battle.fx('sleep', { x: e.x, y: e.y, id: e.id }); + } + }, + }, + skills: { + skchr_grabds_1: { kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 1), dmgType: 'arts' }, mods: { maxTargets: 1 } }, + }, + talents: [{ install(battle, unit) { + statBuff(battle, unit, 'talent:grabds', { aspd: num(t.attack_speed) }); + spTimeBonus(battle, unit, num(moduleBb(chess).sp_recovery_per_sec), () => enemyInRange(battle, unit)); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_05-harold.js b/server/sim/content/kits/ops/chess_char_2_05-harold.js new file mode 100644 index 00000000..bc53301a --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_05-harold.js @@ -0,0 +1,56 @@ +// server/sim/content/kits/ops/chess_char_2_05-harold.js — 哈洛德 (char_4114_harold) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { COLS, ELEMENT_GAUGE_MAX } from '../../../constants.js'; +import { num, talentBb, up } from '../shared/tier1.js'; +import { onDefaultSkill } from '../shared/tier2.js'; + +/** Largest element gauge of a unit (every element — 侵蚀 included: the bosses' attacks fill it on operators). */ +const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis, a.elem.erosion ?? 0); + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_05 哈洛德 重症优先: ASPD +attack_speed, heals the ally with the heaviest element damage first; on targets whose + // gauge is over half, the element recovery is trait_scale × (the wandermedic trait: ep_heal_ratio × ATK). + // 我即军营: allies in range whose gauge is over half take −ep_damage_resistance 元素损伤 — checked on the element hit + // itself (`elementHit`, a 元素损伤 multiplier: PRTS 元素 "…后续可应用元素损伤倍率提升/降低等效果"; not `elemTakenMul`, + // which is 元素伤害 / 元素脆弱); several 哈洛德 do not stack — the strongest cut applies. + // S1 治疗强化·γ型 (alt): ATK +atk; the 重症优先 target order belongs to S2 only. + chess_char_2_05_a: (bb, chess) => { + const t = talentBb(chess, 0); + const s2 = onDefaultSkill(chess); + const half = ELEMENT_GAUGE_MAX / 2; + const ts = num(bb.trait_scale, 1); + return { + skill: { + kind: 'duration', heal: true, mods: { aspd: num(bb.attack_speed) }, + onHit({ battle, unit, target }) { + if (!target || !unit.mem.haroldHalf?.has(target.id) || !(ts > 1)) return; + battle.reduceElement(target, unit.s.atk * (unit.profile.heal?.elementHealRatio ?? 0) * (ts - 1)); + }, + }, + skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } }, + talents: [{ install(battle, unit) { + if (s2) battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.skill?.active) return; + const cands = battle.injuredAlliesInKeys(unit.rangeKeys, unit, true); + cands.sort((a, b) => elemLoad(b) - elemLoad(a) || a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq); + if (cands.length) ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length)); + unit.mem.haroldHalf = new Set(ctx.targets.filter((a) => elemLoad(a) > half).map((a) => a.id)); + }, { owner: unit }); + const r = Math.max(0, Math.min(1, num(t.ep_damage_resistance))); + if (r > 0) { + battle.on('elementHit', (ctx) => { + const a = ctx.target, d = ctx.dmg; + if (!up(unit) || !a || a.side !== 'ally' || !d || d.type !== 'element' || !(elemLoad(a) > half)) return; + if (!(unit.rangeKeySet || new Set(unit.rangeKeys || [])).has(a.tileR * COLS + a.tileC)) return; + const prev = d.haroldCut || 0; // the strongest 我即军营 of the hit only + if (r <= prev) return; + d.mul *= (1 - r) / (1 - prev); + d.haroldCut = r; + }, { owner: unit, priority: 20 }); + } + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_06-papyrs.js b/server/sim/content/kits/ops/chess_char_2_06-papyrs.js new file mode 100644 index 00000000..8d7228dd --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_06-papyrs.js @@ -0,0 +1,85 @@ +// server/sim/content/kits/ops/chess_char_2_06-papyrs.js — 莎草 (char_4139_papyrs) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { bodyInKeys } from '../../../body.js'; +import { num, talentBb, up, alliesInGridOf, instantKind, batMod } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_06 莎草 巧思乍现 (AUTO, 2 charges): the next heal is heal_scale × ATK (chain jumps included) and the talent + // barrier is shield_scale_skill × stronger. 博览古卷: every heal gives the target a barrier of attack@scale × ATK for + // attack@shield_duration s. (Chain falloff incl. the elite module comes from the chainhealer profile.) + // S2 临考发挥 (alt): on start lock the ally in range (not herself, no 禁疗; any profession, summons included) with the + // highest max HP; while it runs that ally is the only main heal target, base attack time +base_attack_time s (−1.1 + // on 2.85), ATK +atk, chain jumps +attack@chain.extra_value. PRTS: "无法可选单位时无法开启技能" — with no lockable + // ally the skill never activates (the talent install guards the unit's skill.activate: no skillStart, no + // 萨尔贡 / 特质 "开启技能时" gains, SP kept); the skill stops at once when the locked ally leaves the field. + chess_char_2_06_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const sc = num(t['attack@scale']), sd = num(t['attack@shield_duration'], 8), boost = num(bb.shield_scale_skill, 1); + const extra = Math.max(0, Math.floor(num(bb['attack@chain.extra_value']))); + const lockOf = (battle, unit) => alliesInGridOf(battle, unit) + .filter((a) => a !== unit && !a.s.flags.noHeal && !a.profile?.noHeal) + .sort((a, b) => b.s.maxHp - a.s.maxHp || a.deploySeq - b.deploySeq)[0] ?? null; + const release = (battle, unit) => { + if (unit.mem.papyrsHeal) { unit.profile.heal = unit.mem.papyrsHeal; unit.mem.papyrsHeal = null; } + if (unit.mem.papyrsHook) battle.off(unit.mem.papyrsHook); + unit.mem.papyrsHook = null; + unit.mem.papyrsLock = null; + unit.mem.papyrsAbort = false; + }; + return { + skill: { kind: instantKind(def), heal: true, attack: { healScale: num(bb.heal_scale, 1) } }, + skills: { + skchr_papyrs_2: { + kind: 'duration', heal: true, mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) }, + onStart({ battle, unit, skill }) { + release(battle, unit); + const lock = lockOf(battle, unit); + // (only reachable when a runtime bypasses the activate guard below, e.g. a copied skill: undo on the next tick) + if (!lock) { unit.mem.papyrsAbort = true; return; } + unit.mem.papyrsLock = lock; + const h = unit.profile.heal; + if (h && extra > 0) { unit.mem.papyrsHeal = h; unit.profile.heal = { ...h, count: Math.max(1, h.count || 3) + extra }; } + unit.mem.papyrsHook = battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !skill.active) return; + const L = unit.mem.papyrsLock; + if (L && up(L) && bodyInKeys(L, unit.rangeKeySet)) ctx.targets = [L]; + }, { owner: unit, priority: 10 }); + battle.fx('buff', { x: lock.x, y: lock.y, id: lock.id, kind: 'lock' }); + }, + onTick({ unit, skill }) { + if (unit.mem.papyrsAbort) { + // "无法可选单位时无法开启技能": undo the cast, keep the SP + unit.mem.papyrsAbort = false; + skill.end('noTarget'); + skill.addCharge(1); + return; + } + const L = unit.mem.papyrsLock; + if (!L || !up(L)) skill.end('target'); + }, + onEnd({ battle, unit }) { release(battle, unit); }, + }, + }, + talents: [{ install(battle, unit) { + const sk = unit.skill; + if (sk && sk.id === 'skchr_papyrs_2' && !Object.prototype.hasOwnProperty.call(sk, 'activate')) { + // "无法可选单位时无法开启技能": every activation path (DEFAULT heal trigger, carried skill, content) is refused + // while no ally can be locked — the charge stays, nothing starts (non-enumerable: never serialised) + const activate = sk.activate; + Object.defineProperty(sk, 'activate', { + configurable: true, writable: true, enumerable: false, + value(reason, opts) { return lockOf(battle, unit) ? activate.call(this, reason, opts) : false; }, + }); + } + if (!(sc > 0)) return; + battle.on('heal', (ctx) => { + if (ctx.source !== unit || ctx.target === unit && ctx.opts?.regen || !(ctx.amount > 0)) return; + const bySkill = !!(unit.skill?.active && unit.skill.pending); + battle.addBuff(ctx.target, { key: 'papyrs:shield', shield: unit.s.atk * sc * (bySkill ? boost : 1), duration: sd, source: unit, visible: true, tags: ['talent'] }); + }, { owner: unit, priority: -10 }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_07-ghost.js b/server/sim/content/kits/ops/chess_char_2_07-ghost.js new file mode 100644 index 00000000..acb64886 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_07-ghost.js @@ -0,0 +1,35 @@ +// server/sim/content/kits/ops/chess_char_2_07-ghost.js — 幽灵鲨 (char_143_ghost) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, onHitBy, statBuff } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_07 幽灵鲨 肉斩骨断: ATK +atk and HP never drops below 1 while active; stunned `stun` s afterwards. + // Talent: max HP +max_hp (elite: + hp_recovery_per_sec_by_max_hp_ratio × max HP per s). + // Elite module (CEN-X, trait atk_scale): vs enemies it blocks ATK ×atk_scale. + // S1 攻击力强化·γ型 (alt): ATK +atk for its duration. + chess_char_2_07_a: (bb, chess) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + return { + skill: { + kind: 'duration', mods: { atkPct: num(bb.atk) }, + onStart({ battle, unit }) { + if (unit.mem.undying) battle.off(unit.mem.undying); + unit.mem.undying = battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 }); + }, + onEnd({ battle, unit, reason }) { + if (unit.mem.undying) battle.off(unit.mem.undying); + unit.mem.undying = null; + if (reason !== 'death' && unit.alive && num(bb.stun) > 0) battle.applyStatus(unit, 'stun', { duration: num(bb.stun), source: unit }); + }, + }, + skills: { 'skcom_atk_up[3]': { kind: 'duration', mods: { atkPct: num(bb.atk) } } }, + talents: [{ install(battle, unit) { + statBuff(battle, unit, 'talent:ghost', { hpPct: num(t.max_hp), hpRegenRatio: num(t.hp_recovery_per_sec_by_max_hp_ratio) }); + if (num(tb.atk_scale, 1) !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.blockedBy) dmg.amount *= num(tb.atk_scale, 1); }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_08-bubble.js b/server/sim/content/kits/ops/chess_char_2_08-bubble.js new file mode 100644 index 00000000..a935830e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_08-bubble.js @@ -0,0 +1,33 @@ +// server/sim/content/kits/ops/chess_char_2_08-bubble.js — 泡泡 (char_381_bubble) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, byEnemyAttack, onDamagedOn, toggleBuff } from '../shared/tier1.js'; +import { onDefaultSkill } from '../shared/tier2.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_08 泡泡 “挨打”: stops attacking; DEF +def, taunt +taunt_level, each attack received returns atk_scale × DEF phys + // damage to the attacker. 尖刺盾: attackers get ATK −atk for `duration` s. Elite module (PRO-X): DEF +def while blocking. + // S1 防御力强化·β型 (alt, TAKE_DAMAGE from data): DEF +def for its duration; the counter belongs to “挨打” only. + chess_char_2_08_a: (bb, chess) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + const s2 = onDefaultSkill(chess); + return { + skill: { kind: 'duration', mods: { defPct: num(bb.def), taunt: num(bb.taunt_level) }, attack: { noAttack: true } }, + skills: { 'skcom_def_up[2]': { kind: 'duration', mods: { defPct: num(bb.def) } } }, + talents: [{ install(battle, unit) { + onDamagedOn(battle, unit, (ctx) => { + if (!byEnemyAttack(ctx) || !unit.alive) return; + const src = ctx.source; + if (s2 && unit.skill?.active && src.alive) { + battle.dealDamage(unit, src, { amount: unit.s.def * num(bb.atk_scale), type: 'phys', isSkill: true, canDodge: false, tags: ['counter'] }); + battle.fx('counter', { x: src.x, y: src.y, id: unit.id }); + } + if (num(t.atk) < 0 && src.alive) battle.addBuff(src, { key: 'bubble:spike', duration: num(t.duration, 5), mods: { atkPct: num(t.atk) }, refresh: 'extend', source: unit }); + }); + if (num(tb.def)) toggleBuff(battle, unit, 'bubble:guard', () => unit.blocking.length > 0, { defPct: num(tb.def) }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_09-humus.js b/server/sim/content/kits/ops/chess_char_2_09-humus.js new file mode 100644 index 00000000..15b8d055 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_09-humus.js @@ -0,0 +1,60 @@ +// server/sim/content/kits/ops/chess_char_2_09-humus.js — 休谟斯 (char_491_humus) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_09 休谟斯 高效处理: block +block_cnt; above peak_1.hp_ratio HP ATK +peak_1.atk (精力充沛), above peak_2.hp_ratio + // +peak_2.atk. 再回收: healing beyond max HP becomes a barrier, at most max_hp_ratio × max HP. + // S1 固废切割 (alt, attack SP): the next attack hits at atk_scale × ATK (every enemy it reaches) and heals her `value` + // HP once (a self heal: her trait bars only healing by others; overflow feeds 再回收). + chess_char_2_09_a: (bb, chess) => { + const t = talentBb(chess, 0); + const peaks = []; + for (const [k, v] of Object.entries(bb)) { + const m = k.match(/\[(peak_\d+)\]\.peak_performance\.(atk|hp_ratio)$/); + if (!m) continue; + let p = peaks.find((x) => x.id === m[1]); + if (!p) peaks.push((p = { id: m[1], atk: 0, hp: 1 })); + if (m[2] === 'atk') p.atk = num(v); else p.hp = num(v, 1); + } + peaks.sort((a, b) => b.hp - a.hp); + const key = 'humus:peak'; + const peak = ({ battle, unit }) => { + const lvl = peaks.find((p) => unit.hpRatio > p.hp)?.atk ?? 0; + const cur = unit.findBuff(key); + if ((cur?.data?.v ?? 0) === lvl) return; + if (lvl > 0) battle.addBuff(unit, { key, mods: { atkPct: lvl }, data: { v: lvl }, visible: true, tags: ['skill'] }); + else battle.removeBuff(unit, key); + }; + return { + skill: { + kind: 'duration', mods: { blockCnt: num(bb.block_cnt) }, + onStart: peak, onTick: peak, + onEnd({ battle, unit }) { battle.removeBuff(unit, key); }, + }, + skills: { + skchr_humus_1: { + kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) }, + onAttack({ battle, unit }) { + if (num(bb.value) > 0 && unit.alive) battle.heal(unit, unit, num(bb.value), { self: true, tags: ['skill'] }); + }, + }, + }, + talents: [{ install(battle, unit) { + const ratio = num(t.max_hp_ratio); + if (!(ratio > 0)) return; + battle.on('heal', (ctx) => { + if (ctx.target !== unit || !(ctx.amount > 0)) return; + const over = ctx.amount - (unit.s.maxHp - unit.hp); + if (!(over > 0)) return; + const cur = unit.findBuff('humus:recycle'); + const have = cur ? cur.shield : 0; + const val = Math.min(unit.s.maxHp * ratio, have + over); + if (val > have + 1e-6) battle.addBuff(unit, { key: 'humus:recycle', shield: val, source: unit, visible: true, tags: ['talent'] }); + }, { owner: unit, priority: -50 }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_10-rockr.js b/server/sim/content/kits/ops/chess_char_2_10-rockr.js new file mode 100644 index 00000000..9159887c --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_10-rockr.js @@ -0,0 +1,68 @@ +// server/sim/content/kits/ops/chess_char_2_10-rockr.js — 洛洛 (char_4040_rockr) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { canTargetEnemy } from '../../../targeting.js'; +import { bodyInKeys } from '../../../body.js'; +import { num, talentBb, up } from '../shared/tier1.js'; +import { onDefaultSkill } from '../shared/tier2.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_10 洛洛 自负此轭: ASPD +attack_speed; the drones lock on their target until it dies or the skill ends. + // 过载 (term ba.overdrive "技能进行到一半时触发额外效果"): the second half of the duration — ATK +atk and the funnel cap + // ×scale; when the skill ends she is stunned as long as the overload lasted. 立于磐石: ATK +atk per `interval` s on the + // field, up to max_stack_cnt stacks. + // S1 战术咏唱·γ型 (alt): ASPD +attack_speed; the target lock belongs to 自负此轭 only. + chess_char_2_10_a: (bb, chess) => { + const t = talentBb(chess, 0); + const s2 = onDefaultSkill(chess); + const endOverload = (battle, unit) => { + if (unit.mem.rockFunnel) { unit.profile.funnel = unit.mem.rockFunnel; unit.mem.rockFunnel = null; } + battle.removeBuff(unit, 'rockr:overload'); + }; + return { + skill: { + kind: 'duration', mods: { aspd: num(bb.attack_speed) }, + onStart({ battle, unit, skill }) { + unit.mem.rockOverAt = battle.time + skill.duration / 2; + unit.mem.rockOver = false; + unit.mem.rockLock = null; + }, + onTick({ battle, unit }) { + if (unit.mem.rockOver || battle.time + 1e-9 < unit.mem.rockOverAt) return; + unit.mem.rockOver = true; + const f = unit.profile.funnel; + if (f) { unit.mem.rockFunnel = f; unit.profile.funnel = { ...f, max: f.max * num(bb.scale, 1) }; } + battle.addBuff(unit, { key: 'rockr:overload', mods: { atkPct: num(bb.atk) }, visible: true, tags: ['skill'] }); + battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); + }, + onEnd({ battle, unit, reason }) { + const over = unit.mem.rockOver ? battle.time - unit.mem.rockOverAt : 0; + endOverload(battle, unit); + unit.mem.rockOver = false; + unit.mem.rockLock = null; + if (over > 0 && reason !== 'death' && unit.alive) battle.applyStatus(unit, 'stun', { duration: over, source: unit }); + }, + }, + skills: { 'skcom_magic_rage[3]': { kind: 'duration', mods: { aspd: num(bb.attack_speed) } } }, + talents: [{ install(battle, unit) { + if (s2) battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.skill?.active) return; + const L = unit.mem.rockLock; + if (L && L.alive && bodyInKeys(L, unit.rangeKeySet) && canTargetEnemy(unit, L, ctx.profile || unit.profile)) ctx.targets = [L]; + else unit.mem.rockLock = ctx.targets[0] ?? null; + }, { owner: unit }); + const iv = num(t.interval), max = Math.floor(num(t.max_stack_cnt)), v = num(t.atk); + if (!(iv > 0) || !(max > 0) || !v) return; + battle.on('tick', () => { + if (!up(unit)) return; + const n = Math.min(max, Math.floor((battle.time - unit.deployedAt + 1e-9) / iv)); + const cur = unit.findBuff('rockr:rock'); + if ((cur ? cur.stacks : 0) === n) return; + if (n > 0) battle.addBuff(unit, { key: 'rockr:rock', mods: { atkPct: v }, stacks: n, maxStacks: max, tags: ['talent'] }); + else battle.removeBuff(unit, 'rockr:rock'); + }, { owner: unit }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_11-kazema.js b/server/sim/content/kits/ops/chess_char_2_11-kazema.js new file mode 100644 index 00000000..e02b93a4 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_11-kazema.js @@ -0,0 +1,79 @@ +// server/sim/content/kits/ops/chess_char_2_11-kazema.js — 风丸 (char_4016_kazema) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, up, cheb, freeTileAround } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_11 风丸 纸艺·双影: lose hp_ratio of current HP, ATK +atk, summon the <替身> (overrideTokenKey) on a free melee tile + // around her for the skill's duration [ASSUMED lifetime]. 折纸生花: whenever a <替身> appears (the summon, and the + // dollkeeper substitution) enemies on the 8 surrounding tiles take damage_scale × its ATK arts — air units too [ASSUMED: + // no 对空 note on PRTS; the <替身> itself "可对空"]. + // Trait substitution: she fights with the <替身>'s stats (the engine already swaps in its HP; the kit swaps ATK/DEF + // with flat deltas so %-buffs still apply). Elite module (PUM-X, trait atk): while substituted, ATK +atk. + // S1 纸艺·迅击 (alt, attack SP): the next attack hits at atk_scale × ATK and she loses hp_ratio of her MAX HP (PRTS: + // ten casts knock her out — the loss can be fatal and so sets off the <替身> substitution). + chess_char_2_11_a: (bb, chess) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? []).find((x) => /shadow|doll/.test(String(x))) ?? 'token_10022_kazema_shadow'; + const ds = num(t.damage_scale); + const origami = (battle, src, atk) => { + battle.fx('aoe', { x: src.x, y: src.y, radius: 1.5, id: src.id, skill: 'origami' }); + if (!(ds > 0)) return; + for (const e of battle.enemies) { + if (!e.alive || e.hidden || e.s.flags.untargetable || cheb(src, e) > 1) continue; + battle.dealDamage(src, e, { amount: atk * ds, type: 'arts', isSkill: true, canDodge: false, tags: ['talent'] }); + } + }; + return { + skill: { + kind: 'duration', mods: { atkPct: num(bb.atk) }, + onStart({ battle, unit }) { + const loss = unit.hp * num(bb.hp_ratio); + if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); + const tile = freeTileAround(battle, unit, (r, c) => battle.grid.canStand(r, c)); + if (!tile) return; + const doll = battle.spawnToken(unit, tokId, tile[0], tile[1], {}); + if (!doll) return; + unit.mem.doll = doll; + battle.fx('summon', { x: doll.x, y: doll.y, id: doll.id, token: tokId }); + origami(battle, doll, doll.s.atk); + }, + onEnd({ battle, unit }) { + const d = unit.mem.doll; + if (d && d.alive) battle.retreat(d, { reason: 'expired', permanent: true }); + unit.mem.doll = null; + }, + }, + skills: { + skchr_kazema_1: { + kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) }, + onAttack({ battle, unit }) { + const loss = unit.s.maxHp * num(bb.hp_ratio); + if (loss > 0 && unit.alive) battle.loseHp(unit, loss, { source: unit, tags: ['skill'] }); + }, + }, + }, + talents: [{ install(battle, unit) { + const bonus = num(tb.atk); + battle.on('tick', () => { + const sub = up(unit) && !!unit.findBuff('trait:substitute'); + if (sub && !unit.mem.kzSub) { + unit.mem.kzSub = true; + const ts = battle.tokenDef(tokId, unit)?.stats ?? null; // the owner's module variant (DESIGN §16) + const mods = {}; + if (num(ts?.atk) > 0) mods.atkFlat = num(ts.atk) - unit.base.atk; + if (ts && Number.isFinite(ts.def)) mods.defFlat = ts.def - unit.base.def; + if (bonus > 0) mods.atkPct = bonus; + battle.addBuff(unit, { key: 'kazema:doll', mods, tags: ['trait'] }); + origami(battle, unit, unit.s.atk); + } else if (!sub && unit.mem.kzSub) { + unit.mem.kzSub = false; + battle.removeBuff(unit, 'kazema:doll'); + } + }, { owner: unit }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_12-gravel.js b/server/sim/content/kits/ops/chess_char_2_12-gravel.js new file mode 100644 index 00000000..39659ff8 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_12-gravel.js @@ -0,0 +1,65 @@ +// server/sim/content/kits/ops/chess_char_2_12-gravel.js — 砾 (char_237_gravel) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, installAura } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_12 砾 鼠群 (passive): at each deployment a barrier of hp_ratio × max HP that decays to 0 over `duration` s + // (capacity stepped once per second; a damaged barrier shrinks in proportion — PRTS). + // 快速部署: own deployment cost +cost (−1). Elite 小个子支援: every unit whose initial deployment cost ≤ cond.cost + // DEF +def while 砾 is on the field. (Elite module withdraw refund: no manual retreat in battle ⇒ no effect.) + // S1 影袭 (alt, passive): at each deployment DEF +def, decaying linearly to 0 over `duration` s, updated once per + // second (PRTS: "防御力加成每秒更新一次"). + chess_char_2_12_a: (bb, chess) => { + const t = talentBb(chess, 0); + return { + skill: { + kind: 'passive', + onStart({ battle, unit }) { + const total = unit.s.maxHp * num(bb.hp_ratio); + const dur = num(bb.duration, 10); + if (!(total > 0) || !(dur > 0)) return; + let k = 0; + battle.addBuff(unit, { + key: 'gravel:rats', shield: total, duration: dur, interval: 1, visible: true, tags: ['skill'], + // PRTS: "屏障最大容量每秒更新一次;受到伤害后,剩余容量根据屏障最大容量等比变化" — the capacity steps down + // once per second and what is left of the barrier scales with it (never a plain cap) + onTick: ({ unit: u, buff }) => { + const prev = Math.max(0, dur - k) / dur; + k++; + const next = Math.max(0, dur - k) / dur; + if (prev > 0 && buff.shield > 0) { buff.shield *= next / prev; u.markDirty(); } + }, + }); + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); + }, + }, + skills: { + skchr_gravel_1: { + kind: 'passive', + onStart({ battle, unit }) { + const v = num(bb.def), dur = num(bb.duration); + if (!(v > 0) || !(dur > 0)) return; + let k = 0; + battle.addBuff(unit, { + key: 'gravel:shadow', duration: dur, mods: { defPct: v }, interval: 1, visible: true, tags: ['skill'], + onTick: ({ unit: u, buff }) => { + k++; + buff.mods = { defPct: v * Math.max(0, dur - k) / dur }; + u.markDirty(); + }, + }); + battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'def' }); + }, + }, + }, + talents: [{ install(battle, unit) { + const c = num(t.cost); + if (c) unit.base.cost = Math.max(0, unit.base.cost + c); + const d = num(t.def), lim = t['cond.cost']; + if (d && lim != null) installAura(battle, unit, { key: 'talent:gravel', value: d, select: (a) => num(a.def?.stats?.cost, Infinity) <= num(lim), mods: { defPct: d } }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_13-tippi.js b/server/sim/content/kits/ops/chess_char_2_13-tippi.js new file mode 100644 index 00000000..98a294ee --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_13-tippi.js @@ -0,0 +1,78 @@ +// server/sim/content/kits/ops/chess_char_2_13-tippi.js — 蒂比 (char_4191_tippi) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, up, onHitOn } from '../shared/tier1.js'; +import { onDefaultSkill } from '../shared/tier2.js'; + +/** + * 起飞 of 蒂比's skills (gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人"): the skill's flags + * `liftoff` (no ground enemy blocked — Battle._blockerFor; 对地规避 — targeting.js evadesGround: no ground enemy selects + * her, while what selects nobody still lands — 无视无法选择 abilities, direct picks, flying units' blasts, 无来源 damage, the + * ticks of a debuff already on her) and `blockFly` (blocks flyers at the air radius). She stays a ground unit on her tile (PRTS 行动方式 "起飞的 + * 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰 still counts her; 地面干员 bonds / items read her position). + */ +const LIFTOFF_FLAGS = Object.freeze({ blockFly: true, liftoff: true }); +/** 蒂比 take-off: the ground enemies she blocked walk on. */ +function tippiTakeOff({ battle, unit }) { + battle.releaseBlocked(unit); + battle.fx('takeoff', { x: unit.x, y: unit.y, id: unit.id }); +} +/** 蒂比 landing when the airborne skill ends: the flyers she held are released. */ +function tippiLand({ battle, unit }) { + battle.releaseBlocked(unit); +} + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_13 蒂比 紧急赶场通知 (AUTO): "受到攻击后触发" is officially "受到伤害前触发" (PRTS 修正 原因 6; 备注 "在受到伤害前自动 + // 触发技能…若本次伤害为物理或法术,再将本次伤害闪避") — any incoming damage instance (an attack, a zone tick, the 源石溶剂 + // drain; never a 流失, which has no `hit`) sets it off (the kit is the only trigger: the engine rule is disabled) and a + // physical/arts one is dodged; takes off for the duration: skill range, ATK +atk, attacks become 3 shots, blocks flying + // (not ground) enemies. Trait "起飞后能够阻挡2个飞行敌人": flying enemies are blocked only while airborne. 片场工作指南 + // ("若最近N秒内未受伤害" — 修正 原文 攻击): if no damage for stack_time s, the next physical/arts damage is dodged (prob); + // every damage instance restarts that timer (dodged or not; a 流失 does not — PRTS 备注). + // S1 专业喷绘技巧 (alt, DEFAULT trigger from data): takes off at once for its duration — skill range, ATK +atk, blocks + // flyers only; no triple shot, and incoming attacks never set it off. + // Both take off as 起飞 (LIFTOFF_FLAGS): she blocks no ground enemy and none selects her (对地规避), so a ground enemy's + // selected damage refused on the way never reaches her `hit` and neither sets off S2 nor restarts 片场工作指南. + chess_char_2_13_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const s2 = onDefaultSkill(chess); + return { + trait: { blockFly: false }, + skill: { + kind: 'duration', trigger: { rule: 'CUSTOM_RANGE', grid: [] }, // never by the engine: the hit handler below + mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS, + targeting: { rangeGrid: def?.skill?.rangeGrid ?? null }, + attack: { hits: 3 }, + onStart: tippiTakeOff, + onEnd: tippiLand, + }, + skills: { + skchr_tippi_1: { + kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS, + targeting: { rangeGrid: def?.skill?.rangeGrid ?? null }, + onStart: tippiTakeOff, + onEnd: tippiLand, + }, + }, + talents: [{ install(battle, unit) { + battle.removeBuff(unit, 'trait:skywalker'); // the profession grants blockFly permanently; here only airborne + const st = num(t.stack_time), p = num(t.prob, 1); + let last = -Infinity; + battle.on('deploy', ({ unit: u }) => { if (u === unit) last = battle.time - st; }, { owner: unit }); + onHitOn(battle, unit, (ctx) => { + const { dmg } = ctx; + if (!dmg || dmg.cancel || !up(unit)) return; + const dodgeable = dmg.type === 'phys' || dmg.type === 'arts'; + const sk = unit.skill; + let dodged = false; + if (s2 && sk && sk.ready && !sk.active && unit.canAct && !unit.s.flags.silence && sk.activate('TAKE_DAMAGE')) dodged = dodgeable; + else if (dodgeable && st > 0 && battle.time - last + 1e-9 >= st && battle.rng.chance(p)) dodged = true; + last = battle.time; + if (dodged) { dmg.cancel = true; battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); } + }, 5); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_14-flower.js b/server/sim/content/kits/ops/chess_char_2_14-flower.js new file mode 100644 index 00000000..15f08d87 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_14-flower.js @@ -0,0 +1,29 @@ +// server/sim/content/kits/ops/chess_char_2_14-flower.js — 调香师 (char_181_flower) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, moduleBb, up } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_14 调香师 精调: ASPD +attack_speed (−50), ATK +atk. 熏衣草: every ally on the field recovers + // atk_to_hp_recovery_ratio × ATK HP per second. Elite module (RIN-Y, hidden attack@max_target): heals 4 allies. + // S1 治疗强化·β型 (alt): ATK +atk for its duration. + chess_char_2_14_a: (bb, chess) => { + const t = talentBb(chess, 0); + const cnt = Math.floor(num(moduleBb(chess)['attack@max_target'])); + return { + trait: cnt > 0 ? { heal: { mode: 'multi', count: cnt } } : undefined, + skill: { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } }, + skills: { 'skcom_heal_up[2]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } }, + talents: [{ install(battle, unit) { + const r = num(t.atk_to_hp_recovery_ratio); + if (!(r > 0)) return; + battle.every(1, () => { + if (!up(unit)) return; + const amt = unit.s.atk * r; + for (const a of battle.alliesFor(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] }); + }, { owner: unit }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_15-akkord.js b/server/sim/content/kits/ops/chess_char_2_15-akkord.js new file mode 100644 index 00000000..c78f3997 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_15-akkord.js @@ -0,0 +1,43 @@ +// server/sim/content/kits/ops/chess_char_2_15-akkord.js — 协律 (char_4051_akkord) kit, tier 2 (hidden). +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, onHitBy, alliesInGridOf, toggleBuff } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_15 协律 (hidden) 震爆调谐: ATK +atk; each attack sets off a sonic boom at every other operator in range: + // attack@aoe_atk_scale × ATK arts within attack@range_radius. 律脉同构: ATK +atk with another operator in range. + // Elite module (BLA-X): damage grows with distance, up to +damage_scale at max_dist tiles. + chess_char_2_15_a: (bb, chess) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + const rad = num(bb['attack@range_radius'], 0.9), sc = num(bb['attack@aoe_atk_scale']); + return { + skill: { + kind: 'duration', mods: { atkPct: num(bb.atk) }, + onAttack({ battle, unit }) { + if (!(sc > 0)) return; + for (const a of alliesInGridOf(battle, unit)) { + if (a === unit || a.kind !== 'op') continue; + battle.fx('sonic', { x: a.x, y: a.y, radius: rad, id: unit.id }); + for (const e of battle.foesInRadius(a.x, a.y, rad)) { + if (!e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, isSplash: true, tags: ['sonic'] }); + } + } + }, + }, + talents: [{ install(battle, unit) { + const v = num(t.atk); + if (v) toggleBuff(battle, unit, 'akkord:sync', () => alliesInGridOf(battle, unit).some((a) => a !== unit && a.kind === 'op'), { atkPct: v }); + if (tb.damage_scale != null) { + const mn = num(tb.min_dist), mx = num(tb.max_dist, 4), ds = num(tb.damage_scale); + onHitBy(battle, unit, ({ target, dmg }) => { + if (!dmg.isAttack) return; + const d = Math.hypot(target.x - unit.x, target.y - unit.y); + dmg.amount *= 1 + ds * (mx > mn ? Math.max(0, Math.min(1, (d - mn) / (mx - mn))) : 1); + }); + } + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_16-whitew.js b/server/sim/content/kits/ops/chess_char_2_16-whitew.js new file mode 100644 index 00000000..4247b531 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_16-whitew.js @@ -0,0 +1,47 @@ +// server/sim/content/kits/ops/chess_char_2_16-whitew.js — 拉普兰德 (char_140_whitew) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, onHitOn } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_16 拉普兰德 狼魂 (AUTO, attack SP): ATK +atk, arts damage, one extra target, ranged attacks no longer reduced. + // 精神摧毁: attacks disable the target's abilities (silence) for `duration` s. Elite module (LOR-X, trait + // atk_scale_m): attacks add atk_scale_m × ATK arts damage. + // S1 日晷 (alt, attack SP, 持续时间无限 ⇒ toggle until she falls): ATK +atk and a `prob` chance to block (抵挡: the + // damage instance is negated) each physical damage an enemy deals her. + chess_char_2_16_a: (bb, chess) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + const trait = {}; + if (num(t.duration) > 0) trait.onHitStatus = { key: 'silence', duration: num(t.duration) }; + if (num(tb.atk_scale_m) > 0) { + trait.afterHit = (battle, u, target) => { + // PRTS 特性备注 "造成预计算的法术附加伤害": 附加伤害 (tag addition — no 叙拉古 6 roll) + if (target && target.alive && target.side === 'enemy') battle.dealDamage(u, target, { amount: u.s.atk * num(tb.atk_scale_m), type: 'arts', tags: ['module', 'addition'] }); + }; + } + return { + trait, + skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, targeting: { maxTargets: 2 }, attack: { dmgType: 'arts', dmgMul: () => 1 } }, + skills: { + skchr_whitew_1: { + kind: 'toggle', mods: { atkPct: num(bb.atk) }, + onStart({ battle, unit, skill }) { + if (unit.mem.sundial) battle.off(unit.mem.sundial); + const p = num(bb.prob); + unit.mem.sundial = p > 0 ? onHitOn(battle, unit, ({ source, credit, dmg }) => { // 抵挡 is target-side: a 无来源 burst counts via its credit + const src = source || credit; + if (!skill.active || dmg.cancel || dmg.type !== 'phys' || !src || src.side !== 'enemy') return; + if (battle.rng.chance(p)) { dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); } + }, 5) : null; + }, + onEnd({ battle, unit }) { + if (unit.mem.sundial) battle.off(unit.mem.sundial); + unit.mem.sundial = null; + }, + }, + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_17-branch.js b/server/sim/content/kits/ops/chess_char_2_17-branch.js new file mode 100644 index 00000000..83002135 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_17-branch.js @@ -0,0 +1,46 @@ +// server/sim/content/kits/ops/chess_char_2_17-branch.js — 折桠 (char_4207_branch) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, onHitOn, enemiesInGrid } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_17 折桠 生存决心: on start, ground enemies around her (skill grid) 战栗 (tremble: "被阻挡后无法进行普通攻击") for + // not_combat s; ATK +atk, DEF +def, hits every blocked enemy. 简易包扎: heals hp_ratio × max HP when the skill ends. + // Elite module (UNY-X, trait damage_scale): damage from enemies she blocks ×damage_scale. + // S1 绝境抵抗 (alt, TAKE_DAMAGE / hurt SP from data): DEF +def and 抵抗 (−one_minus_status_resistance: control + // statuses last half as long) for its duration; 简易包扎 heals at its end too. + chess_char_2_17_a: (bb, chess, def) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + return { + skill: { + kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, attack: { hitAllBlocked: true }, + onStart({ battle, unit }) { + const grid = def?.skill?.rangeGrid; + const foes = grid ? enemiesInGrid(battle, unit, grid, { canHitFly: false, groundOnly: true }) : battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !e.isFlying); + battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'resolve' }); + for (const e of foes) battle.applyStatus(e, 'tremble', { duration: num(bb.not_combat), source: unit }); + }, + }, + skills: { + skchr_branch_1: { + kind: 'duration', mods: { defPct: num(bb.def) }, + onStart({ battle, unit, skill }) { + const v = Math.min(0.95, Math.max(0, -num(bb.one_minus_status_resistance))); + if (v > 0) battle.applyStatus(unit, 'resist', { duration: skill.timeLeft, value: v, source: unit }); + }, + }, + }, + talents: [{ install(battle, unit) { + const hr = num(t.hp_ratio); + if (hr > 0) { + battle.on('skillEnd', ({ unit: u, reason }) => { + if (u === unit && reason !== 'death' && unit.alive) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true, tags: ['talent'] }); + }, { owner: unit }); + } + if (tb.damage_scale != null) onHitOn(battle, unit, ({ source, dmg }) => { if (source && source.blockedBy === unit) dmg.mul *= num(tb.damage_scale, 1); }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_18-ashlok.js b/server/sim/content/kits/ops/chess_char_2_18-ashlok.js new file mode 100644 index 00000000..69efbbe8 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_18-ashlok.js @@ -0,0 +1,37 @@ +// server/sim/content/kits/ops/chess_char_2_18-ashlok.js — 灰毫 (char_431_ashlok) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, traitBb, onHitBy, batMod } from '../shared/tier1.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_18 灰毫 攻击力强化·γ型 (DEFAULT, like S2: the owner's deliberate deviation from the 重装 TAKE_DAMAGE row, data + // TRIGGER_DEVIATIONS, DESIGN §21.29): ATK +atk. 炮术研习: ATK +atk, or +ashlok_t_1.atk when the `cnt` orthogonal + // tiles around her are all ground (LOW). Elite module (FOR-X, trait atk_scale): vs blocked enemies ATK ×atk_scale. + // S2 专注轰击 (alt): block count 0 (noBlock: releases what she holds), only ranged (splash) attacks, base attack time + // +base_attack_time s (−0.4 / −0.5 on 2.8), ATK +atk. + chess_char_2_18_a: (bb, chess) => { + const t = talentBb(chess, 0); + const tb = traitBb(chess); + return { + skill: { kind: 'duration', mods: { atkPct: num(bb.atk) } }, + skills: { + skchr_ashlok_2: { + kind: 'duration', mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) }, flags: { noBlock: true }, + attack: { fortress: false, _fortressMelee: false }, + }, + }, + talents: [{ install(battle, unit) { + battle.on('deploy', ({ unit: u }) => { + if (u !== unit) return; + const r = unit.tileR, c = unit.tileC; + const low = [[1, 0], [-1, 0], [0, 1], [0, -1]].filter(([dr, dc]) => battle.grid.isLow(r + dr, c + dc)).length; + const ground = low >= num(t.cnt, 4); + const v = ground ? num(t['ashlok_t_1.atk'], num(t.atk)) : num(t.atk); + if (v) battle.addBuff(unit, { key: 'talent:ashlok', mods: { atkPct: v }, data: { ground }, tags: ['talent'] }); + }, { owner: unit }); + if (num(tb.atk_scale, 1) !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.blockedBy) dmg.amount *= num(tb.atk_scale, 1); }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_2_19-tinman.js b/server/sim/content/kits/ops/chess_char_2_19-tinman.js new file mode 100644 index 00000000..4cb06c08 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_2_19-tinman.js @@ -0,0 +1,49 @@ +// server/sim/content/kits/ops/chess_char_2_19-tinman.js — 锡人 (char_4151_tinman) kit, tier 2. +// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. + +import { num, talentBb, enemiesInGrid, instantKind } from '../shared/tier1.js'; +import { tinmanKit } from './chess_char_1_16-tinman.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 2_19 锡人 “大拉里”: see tinmanKit (tier1.js). + // S1 “老科利” (alt, attack SP, needs a target): throws an alchemy unit at the target (ground first); for + // projectile_delay_time s ground enemies within projectile_range tiles are 虚弱 (weaken −atk) and take atk_scale × + // ATK arts per second (ATK cached at the cast; damage over time, so the elite 凋敝魂灵 ×skill@damage_scale applies; + // the zone counts for the module's SP bonus and outlives her). + chess_char_2_19_a: (bb, chess, def) => { + const k = tinmanKit(bb, chess, def); + const dur = num(bb.projectile_delay_time, 8), radius = num(bb.projectile_range, 1), dmgScale = num(bb.atk_scale); + const weak = Math.min(1, Math.max(0, -num(bb.atk))); + const wither = num(talentBb(chess, 1)['skill@damage_scale'], 1); + const STEP = 0.25; // weaken refresh cadence (damage every 1 s) + return { + ...k, + skills: { + ...(k.skills || {}), + skchr_tinman_1: { + kind: instantKind(def), + onStart({ battle, unit }) { + const tgt = enemiesInGrid(battle, unit, null, { n: 1, groundOnly: true })[0] ?? enemiesInGrid(battle, unit, null, { n: 1 })[0]; + const x = tgt ? tgt.x : unit.x + unit.fwd[1], y = tgt ? tgt.y : unit.y + unit.fwd[0]; + const atk = unit.s.atk; + const n = Math.max(1, Math.round(dur / STEP)), per = Math.max(1, Math.round(1 / STEP)); + let i = 0; + unit.mem.tinZones = (unit.mem.tinZones ?? 0) + 1; + battle.fx('zone', { x, y, radius, dur, id: unit.id, skill: 'tinman1' }); + battle.every(STEP, (b, sc) => { + const pulse = i % per === 0; + for (const e of b.foesInRadius(x, y, radius)) { + if (e.isFlying || e.s.flags.untargetable) continue; + if (weak > 0) b.applyStatus(e, 'weaken', { duration: STEP + 0.05, value: weak, source: unit }); + if (wither > 1) b.addBuff(e, { key: 'tinman:wither', duration: STEP + 0.05, data: { mul: wither }, source: unit }); + if (pulse && dmgScale > 0) b.dealDamage(unit, e, { amount: atk * dmgScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] }); + } + if (++i >= n) { sc.cancel(); unit.mem.tinZones = Math.max(0, (unit.mem.tinZones ?? 1) - 1); } + }, { immediate: true }); + }, + }, + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_01-angel.js b/server/sim/content/kits/ops/chess_char_3_01-angel.js new file mode 100644 index 00000000..f6c2ee59 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_01-angel.js @@ -0,0 +1,49 @@ +// server/sim/content/kits/ops/chess_char_3_01-angel.js — 能天使 (char_103_angel) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, traitBb, altSkills, instantKindOf, groundAspd, alive, fx } from '../shared/tier3.js'; + +export default { + // ---- 3_01 能天使 · 速射手 — S3 过载模式: 5 连射 + 攻击间隔缩短; 快速弹匣 ASPD; 天使的祝福 (self + one random ally) + // S1 冲锋模式: next attack = 3 shots × atk_scale; S2 扫射模式: 4 shots × attack@atk_scale per attack for 15 s; + // 精锐 module MAR-Y: ASPD + while a ground enemy is in range (MAR-X fly × = profession layer) + chess_char_3_01_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0), t1 = talentBb(d, 1); + const tb = traitBb(d); + const bless = { atkPct: num(t1.atk), hpPct: num(t1.max_hp) }; + const shots = (v) => Math.max(1, Math.floor(num(v, 1))); + const kit = { + skill: { + kind: 'duration', + mods: { batPct: num(bb.base_attack_time) }, + attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: Math.max(1, Math.floor(num(bb['attack@times'], 1))) }, + }, + skills: altSkills(chess, d, bb, { + skchr_angel_1: (s) => ({ kind: instantKindOf(s), attack: { atkScale: num(s.bb.atk_scale, 1), hits: shots(s.bb.times) } }), + skchr_angel_2: (s) => ({ kind: 'duration', attack: { atkScale: num(s.bb['attack@atk_scale'], 1), hits: shots(s.bb['attack@times']) } }), + }), + talents: [ + { install(battle, unit) { battle.addBuff(unit, { key: 'talent:angel_mag', mods: { aspd: num(t0.attack_speed) }, persist: true, allowDead: true }); } }, + { install(battle, unit) { + battle.addBuff(unit, { key: 'talent:angel_bless', mods: { ...bless }, persist: true, allowDead: true }); + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + // after the whole board is deployed (the initial deployment goes one unit after another) + battle.after(0, () => { + if (!alive(unit)) return; + const cands = battle.allies(unit.ownerId).filter((a) => a !== unit && a.kind === 'op' && !a.findBuff('talent:angel_bless_ally')); + const pick = battle.rng.pick(cands); + if (!pick) return; + battle.addBuff(pick, { key: 'talent:angel_bless_ally', mods: { ...bless }, persist: true, source: unit, visible: true }); + fx(battle, 'buff', pick, { src: unit.id, talent: 'angel_bless' }); + }, { owner: unit }); + }, { owner: unit }); + } }, + ], + }; + // MAR-Y trait "范围内存在地面敌人时攻击速度+8" (bb attack_speed, cnt = ground enemies needed) + if (num(tb.attack_speed) > 0) kit.install = groundAspd('trait:angel_ground', num(tb.attack_speed), Math.floor(num(tb.cnt, 1))); + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_02-ayer.js b/server/sim/content/kits/ops/chess_char_3_02-ayer.js new file mode 100644 index 00000000..859f706e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_02-ayer.js @@ -0,0 +1,67 @@ +// server/sim/content/kits/ops/chess_char_3_02-ayer.js — 断崖 (char_294_ayer) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { + num, defOf, talentBb, traitBb, altSkills, instantKindOf, alive, gridKeys, fx, copyGrid, NINE, aura, +} from '../shared/tier3.js'; + +export default { + // ---- 3_02 断崖 · 领主 — S2 浮游刃启动: arts, wider range, each attack hits every enemy blocked by allies around her + chess_char_3_02_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const tb = traitBb(d); + const tGrid = copyGrid(d.talents?.[0]?.rangeGrid) ?? NINE; + const extra = num(bb.atk_scale, 1); + const moduleArts = num(tb.atk_scale_m, 0); + const skillGrid = copyGrid(d.skill?.rangeGrid); + const kit = { + skill: { + kind: 'duration', + attack: { dmgType: 'arts', atkScale: num(bb['attack@atk_scale'], 1) }, + onAttack({ battle, unit }) { + // "周围8格友方单位阻挡的所有敌人": the 8 tiles around her (her own tile is not one of them — the talent + // says "自身与周围8格" when it means both) + const seen = new Set(); + for (const a of battle.allyUnits) { + if (a === unit || !alive(a) || a.kind === 'device' || !a.blocking.length) continue; + if (Math.abs(a.tileR - unit.tileR) > 1 || Math.abs(a.tileC - unit.tileC) > 1) continue; + for (const e of a.blocking) { + if (!e.alive || seen.has(e)) continue; + seen.add(e); + battle.dealDamage(unit, e, { amount: unit.s.atk * extra, type: 'arts', isSkill: true, tags: ['skill', 'melee', 'ayerBlade'] }); + fx(battle, 'strike', e, { src: unit.id, skill: 'ayer_2' }); + } + } + }, + }, + // S1 多导向散射弹丸: next attack hits up to max_target enemies in range for atk_scale arts + 停顿 + skills: altSkills(chess, d, bb, { + skchr_ayer_1: (s) => { + const slug = num(s.bb.sluggish, 0); + return { + kind: instantKindOf(s), + targeting: { maxTargets: Math.max(1, Math.floor(num(s.bb.max_target, 1))) }, + attack: { + dmgType: 'arts', atkScale: num(s.bb.atk_scale, 1), + onEachHit({ battle, unit, target }) { + if (slug > 0 && target && target.alive && target.side === 'enemy') battle.applyStatus(target, 'sluggish', { duration: slug, source: unit }); + }, + }, + }; + }, + }), + talents: [{ install(battle, unit) { + aura(battle, unit, { key: 'talent:ayer_aspd', side: 'ally', tiles: () => gridKeys(tGrid, unit), filter: (a) => a.kind === 'op', mods: { aspd: num(t0.attack_speed) } }); + } }], + }; + if (skillGrid) kit.skill.targeting = { rangeGrid: skillGrid }; + // 精锐 module LOR-X: "攻击附带10%攻击力的法术伤害" + if (moduleArts > 0) { + kit.trait = { afterHit: (battle, unit, target) => { + if (target && target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * moduleArts, type: 'arts', tags: ['module'] }); + } }; + } + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_03-swire.js b/server/sim/content/kits/ops/chess_char_3_03-swire.js new file mode 100644 index 00000000..7c01c002 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_03-swire.js @@ -0,0 +1,36 @@ +// server/sim/content/kits/ops/chess_char_3_03-swire.js — 诗怀雅 (char_308_swire) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, altSkills, gridKeys, copyGrid, NINE, aura } from '../shared/tier3.js'; + +export default { + // ---- 3_03 诗怀雅 · 教官 — S2 协同作战: ATK +, first talent ×talent_scale; 近距离作战指导 melee ATK aura + // S1 指挥调度: the talent covers the skill's range (x-1 normal / x-2 elite) at ×talent_scale while it runs + chess_char_3_03_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const tGrid = copyGrid(d.talents?.[0]?.rangeGrid) ?? NINE; + const scale = num(bb.talent_scale, 1); + // "第一天赋生效范围扩大" (talent_range_flag): the selected skill's range replaces the talent's while it is active + const skillTGrid = num(bb.talent_range_flag) > 0 ? copyGrid(d.skill?.rangeGrid) : null; + const refresh = ({ unit }) => unit.mem.swireAura?.(); + return { + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + onStart: refresh, + onEnd: refresh, + }, + skills: altSkills(chess, d, bb, { + skchr_swire_1: () => ({ kind: 'duration', onStart: refresh, onEnd: refresh }), + }), + talents: [{ install(battle, unit) { + unit.mem.swireAura = aura(battle, unit, { + key: 'talent:swire_guide', side: 'ally', tiles: () => gridKeys(skillTGrid && unit.skill?.active ? skillTGrid : tGrid, unit), + filter: (a) => String(a.def?.position ?? 'MELEE').toUpperCase() === 'MELEE', + mods: () => ({ atkPct: num(t0.atk) * (unit.skill?.active ? scale : 1) }), + }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_04-swire2.js b/server/sim/content/kits/ops/chess_char_3_04-swire2.js new file mode 100644 index 00000000..3646bcf9 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_04-swire2.js @@ -0,0 +1,230 @@ +// server/sim/content/kits/ops/chess_char_3_04-swire2.js — 琳琅诗怀雅 (char_1033_swire2) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { bodyOnTile } from '../../../body.js'; +import { + num, defOf, talentBb, moduleTalentBb, selectedId, altSkills, alive, gridKeys, fx, copyGrid, textNum, freeTile, + groundTile, enemiesOn, +} from '../shared/tier3.js'; + +/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */ +const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]); + +/** Merchant trait with a callback on every successful DP payment (琳琅诗怀雅 大买家). Same rules as professions.js. */ +function merchantInstall(onPay) { + return (battle, unit) => { + const iv = unit.profile.merchantInterval ?? 3; + const cost = unit.profile.merchantCost ?? 3; + battle.every(iv, () => { + if (!alive(unit)) return; + const pl = battle.getPlayer(unit.ownerId); + if (!pl) return; + if (pl.dp >= cost) { + battle.addDp(unit.ownerId, -cost); + onPay(battle, unit, cost); + battle.emit('merchantPay', { unit, cost }); + } else battle.retreat(unit, { reason: 'merchant' }); + }, { owner: unit }); + }; +} + +export default { + // ---- 3_04 琳琅诗怀雅 · 行商 — S2 “见面礼” (passive): each attack spends a coin to drop a champagne bomb in range; + // 大买家: coin at skill start + coin & ATK stack per trait payment; 破财消灾: DP-paid revive (cost doubles) + // S1 仗义疏财 (passive, 2 coins): a coin heals the most injured ally (< 70 % HP) of the 8 surrounding tiles for + // attack@heal_scale × ATK — on her attack, or with no enemy to attack on her own attack timer (owner's decision + // 2026-10-04, against the official 「下一次攻击会为…」; installS1). S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin; + // closing it spends every coin on random ground enemies of range 2-4 in front and those she blocks (atk_scale phys + + // a small push, radial despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2: + // the RandomGold ability (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位, + // 弹道不可对空). It does not close itself when the purse is full (PRTS 卫戍协议/帮助 「通常不会自动关闭技能」; the skill + // text is 「可随时主动关闭」). Owner 2026-10-04: at the cap (金币上限为10) she shoots once an enemy is in the skill's + // attack range (the 1-tile range, not the coin-mark 2-4 grid). Below the cap, or with nobody there, it stays open. + // 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks). + chess_char_3_04_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0), t1 = talentBb(d, 1); + const sel = selectedId(chess, d); + const S1 = 'skchr_swire2_1', S2 = 'skchr_swire2_2', S3 = 'skchr_swire2_3'; + // "携带此技能时金币上限为N": the SELECTED skill's cap + const coinMax = num(bb.sp, 3); + const coinCost = Math.abs(num(bb['attack@sp'], -1)) || 1; + const modTal = moduleTalentBb(chess); + const scale = num(bb.atk_scale, 1); + const slug = num(bb.sluggish, 2); + const tokenId = chess?.skill?.overrideTokenKey ?? (d.tokens || []).find((t) => /gdtrap/.test(String(t))) ?? 'token_10031_swire2_gdtrap'; + const skillGrid = copyGrid(d.skill?.rangeGrid); + const addCoins = (battle, unit, n) => { + const before = unit.mem.coins ?? 0; + unit.mem.coins = Math.min(coinMax, before + n); + if (unit.mem.coins > before) fx(battle, 'coin', unit, { n: unit.mem.coins }); + }; + const bombKit = (owner, switchT) => ({ + skill: null, + trait: { noAttack: true }, + install(battle, bomb) { + battle.on('tick', () => { + if (!bomb.alive || !bomb.deployed) return; + for (const e of battle.enemies) { + if (!e.alive || e.hidden || e.isFlying || e.s.flags.untargetable) continue; + if (!bodyOnTile(e, bomb.tileR, bomb.tileC)) continue; + // first enemy touching it; after switchT s on the field the bomb deals its damage one extra time + const hits = battle.time - bomb.deployedAt >= switchT - 1e-9 ? 2 : 1; + for (let i = 0; i < hits && e.alive; i++) { + battle.dealDamage(owner, e, { amount: owner.s.atk * scale, type: 'phys', isSkill: true, canDodge: false, tags: ['skill', 'trap'] }); + } + if (e.alive) battle.applyStatus(e, 'sluggish', { duration: slug, source: owner }); + // `consumed`: the bomb is used up by its own blast — clients play its impact sound, not a death sound + fx(battle, 'explode', bomb, { src: owner.id, target: e.id, hits, consumed: true }); + battle.retreat(bomb, { reason: 'expired', permanent: true }); + break; + } + }, { owner: bomb }); + }, + }); + const healRatio = textNum(d.skill?.description, /血量不足(\d+)%/, 70) / 100; + /** + * 仗义疏财 — official text 「消耗一枚金币,下一次攻击会为周围八格内血量不足70%的一名友方单位恢复相当于攻击力40%的生命」: the heal rides on an + * attack, so with no enemy around she never healed (community report #5 「琳琅诗怀雅1技能不会主动奶身边受伤的干员」). Owner's + * decision 2026-10-04 (a deliberate deviation, like §21.29's 重装 casts): she heals an injured ally beside her whether + * she attacks or not. Same target (the lowest HP ratio below 70 % of the 8 surrounding tiles, no 禁疗 / 孤立 unit, no + * device), coin and heal_scale × ATK. Cadence [ASSUMED]: at most one heal per attack cycle (her attack interval, ASPD + * included) — while she attacks, on the attack as before; when her last attack attempt found no target, on her own + * timer (the 'tick' hook runs after the attacks, so an attack due in the same tick takes it). + */ + const installS1 = (battle, unit) => { + const hs = num(bb['attack@heal_scale'], num(bb.heal_scale, 0)); + let nextAt = -Infinity; + const healTarget = () => { + let best = null; + for (const a of battle.allyUnits) { + if (a === unit || !alive(a) || a.hidden || a.kind === 'device' || a.hpRatio >= healRatio) continue; + if (a.s.flags.noHeal || a.profile?.noHeal) continue; // 禁疗 / 孤立: never a heal target + if (Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC)) !== 1) continue; // 周围八格 + if (!best || a.hpRatio < best.hpRatio || (a.hpRatio === best.hpRatio && a.deploySeq < best.deploySeq)) best = a; + } + return best; + }; + const tryHeal = () => { + if (!alive(unit) || (unit.mem.coins ?? 0) < coinCost || !(hs > 0) || battle.time < nextAt - 1e-9) return; + const best = healTarget(); + if (!best) return; + unit.mem.coins -= coinCost; + nextAt = battle.time + unit.s.interval; + battle.heal(unit, best, unit.s.atk * hs); + fx(battle, 'coin', unit, { n: unit.mem.coins, heal: best.id, skill: 'swire2_1' }); + }; + battle.on('attack', (ctx) => { if (ctx.attacker === unit) tryHeal(); }, { owner: unit, priority: -10 }); + battle.on('tick', () => { if (unit.deployed && unit.canAct && !unit.trait?.hadTarget) tryHeal(); }, { owner: unit }); + }; + const installS3 = (battle, unit) => { // 千金一掷: "击倒敌人时获得一枚金币" + battle.on('kill', (ctx) => { + if (ctx.killer === unit && ctx.victim.side === 'enemy' && unit.skill?.active) addCoins(battle, unit, 1); + }, { owner: unit }); + }; + return { + skill: { kind: 'passive' }, + skills: altSkills(chess, d, bb, { + [S1]: () => ({ kind: 'passive' }), // (coins → heals: installS1) + [S3]: (s) => { + const cash = num(s.bb.atk_scale, 1), force = num(s.bb.force, 0); + // the 【金币标记】 targets: ground enemies on range 2-4 in front of her (range_table "2-4") and every unit she blocks + const coinMarks = (battle, unit) => { + const list = enemiesOn(battle, unit, gridKeys(SWIRE2_COIN_GRID, unit), 0, { ...unit.profile, canHitFly: false }); + for (const e of unit.blocking || []) if (e.alive && !e.isFlying && !list.includes(e)) list.push(e); + return list; + }; + const purseCap = Math.max(1, Math.floor(num(s.bb.sp, 10))); + return { + kind: 'toggle', + attack: { hits: 2 }, + // Owner 2026-10-04: at the cap, shoot once a ground enemy is in the skill attack range (data rangeGrid, + // the same 1-tile range as her attack). The coin marks (前方 2-4) are who the burst pays, not the trigger. + // Nobody in that range: stay open and keep the coins. Below the cap: stay open. + onTick({ battle, unit, skill }) { + if (!skill.active || (unit.mem.coins ?? 0) + 1e-9 < purseCap) return; + const keys = gridKeys(skillGrid ?? unit.rangeGrid, unit); + if (enemiesOn(battle, unit, keys, 0, { ...unit.profile, canHitFly: false }).length) skill.end('manual'); + }, + onEnd({ battle, unit, reason }) { + if (reason !== 'manual' || !unit.alive) return; + const n = unit.mem.coins ?? 0; + unit.mem.coins = 0; + let spent = 0; + // PRTS 备注: closing it "立即对前方范围2-4内的地面敌方单位与自身阻挡的所有单位施加【金币标记】", the coins going to + // marked units at random — 弹道(不可对空): air units (FLY, 近地悬浮, 浮空) are never paid. Until 0.1.1 the + // coins went to her attack range (1-1) instead of range 2-4. + const marked = coinMarks(battle, unit); + for (let i = 0; i < n; i++) { + const e = battle.rng.pick(marked.filter((x) => x.alive && !x.isFlying)); + if (!e) break; + spent++; + battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] }); + // "将目标小力地向前推开" — PRTS 备注 "金币弹道…推开效果为径向推动", client knockback[relative]: a radial push + // away from her centre (not along her direction, so no 45° / 0.25-tile 特殊修正), official 力度 − 重量 + // distance (Battle.push) + if (e.alive) battle.push(e, force, { from: unit }); + } + fx(battle, 'coin', unit, { n: 0, spent, skill: 'swire2_3' }); + }, + }; + }, + }), + trait: { install: merchantInstall((battle, unit) => { + // MER-Y "每次特性消耗费用时攻击力+4%,最多可以叠加5次" (any time, not only during the skill) + if (modTal && num(modTal.atk) > 0) { + battle.addBuff(unit, { key: 'trait:swire2_module', refresh: 'stack', stacks: 1, maxStacks: Math.max(1, num(modTal.max_stack_cnt, 5)), mods: { atkPct: num(modTal.atk) } }); + } + if (!unit.skill?.active) return; // "技能期间" + addCoins(battle, unit, num(t0.trait_sp, 1)); + battle.addBuff(unit, { key: 'talent:swire2_buyer', refresh: 'stack', stacks: 1, maxStacks: Math.max(1, num(t0.max_stack_cnt, 8)), mods: { atkPct: num(t0.atk) } }); + }) }, + install(battle, unit) { + if (sel === S1) { installS1(battle, unit); return; } + if (sel === S3) { installS3(battle, unit); return; } + if (sel !== S2 && sel != null) return; + const switchT = num(battle.tokenDef(tokenId, unit)?.skill?.bb?.duration_switch, 3); + battle.on('attack', (ctx) => { + if (ctx.attacker !== unit || !alive(unit) || (unit.mem.coins ?? 0) < coinCost) return; + const tiles = []; + for (const k of gridKeys(skillGrid ?? unit.rangeGrid, unit)) { + const r = (k / COLS) | 0, c = k % COLS; + // (never on the home tile of a dead operator: it could not redeploy until an enemy triggers the bomb) + if (freeTile(battle, r, c) && groundTile(battle, r, c)) tiles.push([r, c]); + } + const tile = battle.rng.pick(tiles); + if (!tile) return; + const bomb = battle.spawnToken(unit, tokenId, tile[0], tile[1], { untargetable: true, kit: bombKit(unit, switchT) }); + if (!bomb) return; + unit.mem.coins -= coinCost; + fx(battle, 'summon', bomb, { src: unit.id, token: tokenId, coins: unit.mem.coins }); + }, { owner: unit, priority: -10 }); + }, + talents: [ + { install(battle, unit) { // 大买家: "开启技能时获得1枚金币" (a passive starts at every deployment, S3 when cast) + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + unit.mem.coins = 0; + if (unit.skill?.kind === 'passive') addCoins(battle, unit, num(t0.sp, 1)); + }, { owner: unit }); + battle.on('skillStart', (ctx) => { if (ctx.unit === unit && unit.skill?.kind !== 'passive') addCoins(battle, unit, num(t0.sp, 1)); }, { owner: unit }); + } }, + { install(battle, unit) { // 破财消灾 + battle.on('fatal', (ctx) => { + if (ctx.unit !== unit || ctx.prevented) return; + const n = unit.mem.saveCount ?? 0; + const cost = Math.abs(num(t1.cost, -5)) * Math.pow(num(t1.cost_multi, 2), n); + const pl = battle.getPlayer(unit.ownerId); + if (!pl || pl.dp + 1e-9 < cost) return; + battle.addDp(unit.ownerId, -cost); + unit.mem.saveCount = n + 1; + ctx.prevented = true; + unit.hp = Math.max(1, unit.s.maxHp * num(t1.hp_ratio, 0.7)); + fx(battle, 'revive', unit, { cost }); + }, { owner: unit }); + } }, + ], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_05-skadi.js b/server/sim/content/kits/ops/chess_char_3_05-skadi.js new file mode 100644 index 00000000..93728d15 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_05-skadi.js @@ -0,0 +1,62 @@ +// server/sim/content/kits/ops/chess_char_3_05-skadi.js — 斯卡蒂 (char_263_skadi) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, traitBb, altSkills, statSkill, fx } from '../shared/tier3.js'; + +/** Abyssal Hunters (character_table groupId `abyssal`, not in data/chess.json): 深海掠食者 targets. */ +const ABYSSAL = new Set(['char_263_skadi', 'char_143_ghost', 'char_218_cuttle', 'char_474_glady', 'char_1023_ghost2', 'char_4145_ulpia']); + +export default { + // ---- 3_05 斯卡蒂 · 无畏者 — S3 涌潮悲歌: ATK/DEF/HP +; 深海掠食者 (Abyssal Hunters ATK); 迅捷出击 (redeploy −10 s) + // 精锐 module DRE-Y: once per deployment, a lethal hit instead restores full HP with max HP −60 % and ASPD +30 + // S1 迅捷打击·γ型: ATK/ASPD +; S2 跃浪击 (passive): ATK + for `duration` s after every deployment; + // 精锐 module DRE-X: ×atk_scale on blocked enemies + chess_char_3_05_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0), t1 = talentBb(d, 1); + const tb = traitBb(d); + const kit = { + skill: { kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def), hpPct: num(bb.max_hp) } }, + skills: altSkills(chess, d, bb, { + 'skcom_quickattack[3]': statSkill, + skchr_skadi_2: (s) => ({ + kind: 'passive', + onStart({ battle, unit }) { // "部署后N秒内攻击力+X" (a passive starts at every deployment) + battle.addBuff(unit, { key: 'skill:skadi_wave', duration: num(s.bb.duration, 20), mods: { atkPct: num(s.bb.atk) }, visible: true }); + }, + }), + }), + talents: [ + { install(battle, unit) { // "编入队伍时": every Abyssal Hunter of this player, for the whole battle + for (const a of battle.allyUnits) { + if (a.kind === 'op' && a.ownerId === unit.ownerId && ABYSSAL.has(a.def?.charId)) { + battle.addBuff(a, { key: 'talent:skadi_predator', mods: { atkPct: num(t0.atk) }, persist: true, allowDead: true, source: unit }); + } + } + } }, + { install(battle, unit) { + if (unit.mem.skadiSwift) return; // base stats are per unit; never apply twice + unit.mem.skadiSwift = true; + unit.base.respawnTime = Math.max(0, unit.base.respawnTime + num(t1.respawn_time)); + } }, + ], + }; + if (tb.value != null || tb.hp_ratio != null) { + kit.install = (battle, unit) => { + battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.mem.tideUsed = false; }, { owner: unit }); + battle.on('fatal', (ctx) => { + if (ctx.unit !== unit || ctx.prevented || unit.mem.tideUsed) return; + unit.mem.tideUsed = true; + ctx.prevented = true; + const hpMul = tb.value != null ? 1 - num(tb.value) : num(tb.max_hp, 0.4); + battle.addBuff(unit, { key: 'trait:skadi_tide', mods: { hpMul: Math.max(0.05, hpMul), aspd: num(tb.attack_speed) }, visible: true }); + unit.hp = Math.max(1, unit.s.maxHp * num(tb.hp_ratio, 1)); + fx(battle, 'revive', unit, { module: true }); + }, { owner: unit, priority: -50 }); + }; + } + // DRE-X "攻击被阻挡的敌人时攻击力提升至115%" (blocked by anyone) + if (num(tb.atk_scale) > 0) kit.trait = { dmgMul: (battle, unit, target) => (target && target.blockedBy ? num(tb.atk_scale, 1) : 1) }; + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_06-philae.js b/server/sim/content/kits/ops/chess_char_3_06-philae.js new file mode 100644 index 00000000..6718b01e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_06-philae.js @@ -0,0 +1,102 @@ +// server/sim/content/kits/ops/chess_char_3_06-philae.js — 菲莱 (char_4148_philae) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, traitBb, selectedId, altSkills, fx, NINE, giveSp } from '../shared/tier3.js'; + +export default { + // ---- 3_06 菲莱 · 本源铁卫 — S2 冥河诅咒: stops attacking, HP +; when attacked blasts the ground enemies of the 3×3 + // around her (PRTS 备注: range x-4; arts + ep_damage_ratio × ATK apoptosis, aoe_cd); ATK + once hit by element + // damage; 神河谕使: 元素损伤 taken −damage_resistance, +SP on apoptosis + // 精锐 module PRP-X "阻挡敌人时,自身造成的元素损伤提升15%": EVERY element fill she deals (the S2 blast, a 灼燃维式重锤 + // she carries …) ×ep_damage_scale while she blocks — an `elementHit` multiplier, like 余's + // S1 灵河护佑 (TAKE_DAMAGE): HP +, clears her element gauges and gives a shield_value 损伤屏障 (absorbs element + // damage — gauge fills — until spent or the skill ends) + // 神河谕使 and the barrier act on the element hit before it lands (PRTS 备注: "减伤与技力回复效果于伤害计算前处理;即使 + // 受到0点的凋亡损伤依然可以回复技力", "损伤屏障于伤害计算前、第一天赋后处理"): the talent's cut and SP first (priority 20), + // then the barrier absorbs what is left (`dmg.amount × dmg.mul`). The cut is a 元素损伤 multiplier (PRTS 元素 "受到的 + // 元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01),后续可应用元素损伤倍率提升/降低等效果"), not `elemTakenMul` (元素伤害 / 元素脆弱). + chess_char_3_06_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const tb = traitBb(d); + const sel = selectedId(chess, d); + const S1 = 'skchr_philae_1', S2 = 'skchr_philae_2'; + const isS2 = sel === S2 || sel == null; + const scale = num(bb.atk_scale, 1); + const ep = num(bb.ep_damage_ratio, 0); + const cd = num(bb.aoe_cd, 2); + const epScale = num(tb.ep_damage_scale, 1); + const cut = Math.max(0, Math.min(1, num(t0.damage_resistance))); + // 损伤屏障 of S1: absorbs the gauge gain an element hit would add (after 神河谕使) + const installBarrier = (battle, unit) => { + battle.on('elementHit', (ctx) => { + if (ctx.target !== unit || !(unit.mem.philaeBarrier > 0) || !unit.skill?.active) return; + const dmg = ctx.dmg; + const eff = num(dmg.amount) * num(dmg.mul, 1); + if (!(eff > 0)) return; + const take = Math.min(unit.mem.philaeBarrier, eff); + unit.mem.philaeBarrier -= take; + if (take >= eff - 1e-9) dmg.cancel = true; + else dmg.amount *= (eff - take) / eff; + if (!(unit.mem.philaeBarrier > 0)) fx(battle, 'shieldBreak', unit, { element: true }); + }, { owner: unit }); + }; + return { + skill: { + kind: 'duration', + mods: { hpPct: num(bb.max_hp) }, + attack: { noAttack: true }, + onStart({ unit }) { unit.mem.philaeCd = -Infinity; }, + onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:philae_rage'); }, + }, + skills: altSkills(chess, d, bb, { + [S1]: (s) => ({ + kind: 'duration', + mods: { hpPct: num(s.bb.max_hp) }, + onStart({ battle, unit }) { + battle.reduceElement(unit, 1e12); // "立刻清除自身的元素损伤" (a gauge locked by a running burst stays) + unit.mem.philaeBarrier = num(s.bb.shield_value, 0); + fx(battle, 'shield', unit, { element: true, n: unit.mem.philaeBarrier }); + }, + onEnd({ unit }) { unit.mem.philaeBarrier = 0; }, + }), + }), + talents: [{ install(battle, unit) { + // 神河谕使 (every skill): 元素损伤 taken ×(1 − damage_resistance); "受到凋亡损伤时回复2点技力" — even for 0 damage + battle.on('elementHit', (ctx) => { + const dmg = ctx.dmg; + if (ctx.target !== unit || !dmg || dmg.type !== 'element') return; + if (dmg.element === 'apoptosis' && !unit.skill?.active) giveSp(unit, num(t0.sp), 'talent'); + if (cut > 0) dmg.mul *= 1 - cut; + }, { owner: unit, priority: 20 }); + } }], + install(battle, unit) { + if (sel === S1) installBarrier(battle, unit); + // module PRP-X: her element damage ×ep_damage_scale while she blocks + if (epScale > 1) { + battle.on('elementHit', (ctx) => { + if (ctx.source === unit && ctx.dmg?.type === 'element' && ctx.target?.side === 'enemy' && unit.blocking.length) ctx.dmg.mul *= epScale; + }, { owner: unit }); + } + battle.on('damaged', (ctx) => { + if (ctx.target !== unit || !unit.alive) return; + const sk = unit.skill; + if (ctx.type === 'element') { + if (isS2 && sk && sk.active && !unit.findBuff('skill:philae_rage')) battle.addBuff(unit, { key: 'skill:philae_rage', mods: { atkPct: num(bb.atk) }, visible: true }); + return; + } + if (!isS2 || !sk || !sk.active || !ctx.dmg?.isAttack || !ctx.source || ctx.source.side !== 'enemy') return; + if (battle.time < (unit.mem.philaeCd ?? -Infinity)) return; + unit.mem.philaeCd = battle.time + cd; + // "周围的地面敌人" = range x-4, the 3×3 tiles around her (PRTS 备注) + for (const e of battle.unitsInGrid(unit, NINE, { side: 'enemy' })) { + if (e.isFlying) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'counter'] }); + if (ep > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'apoptosis', amount: unit.s.atk * ep, tags: ['skill'] }); + } + fx(battle, 'aoe', unit, { radius: 1.5, tiles: 'box', dmgType: 'arts', skill: 'philae_2' }); // box: the 3×3 tiles + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_07-forcer.js b/server/sim/content/kits/ops/chess_char_3_07-forcer.js new file mode 100644 index 00000000..a3183016 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_07-forcer.js @@ -0,0 +1,72 @@ +// server/sim/content/kits/ops/chess_char_3_07-forcer.js — 见行者 (char_4036_forcer) kit, tier 3 (hidden). +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js'; +import { num, defOf, talentBb, traitBb, onTiles, gridKeys, fx, copyGrid } from '../shared/tier3.js'; + +/** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */ +const COLLIDE = 0.6; + +export default { + // ---- 3_07 见行者 · 推击手 (hidden) — S2 惊爆射击: push every enemy in the skill range forward + stun (longer when + // slammed into a wall, collided enemies stunned too; air units too [ASSUMED: "范围内所有敌人", no 对空 note on + // PRTS] — a 失衡免疫 enemy or a 静态刚体 (the drones) is not pushed but still stunned); 技巧射击: ignore DEF vs heavy enemies; + // 精锐 module PUS-X: redeployed on a ranged tile ⇒ half the deployment cost back + chess_char_3_07_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const tb = traitBb(d); + const skillGrid = copyGrid(d.skill?.rangeGrid); + const force = num(bb.force, 1); + const sDirect = num(bb['forcer_s_2[hit_directly].stun'], num(bb.stun, 1)); + const sWall = num(bb.stun, sDirect); + const sBrush = num(bb['forcer_s_2[brush].stun'], sDirect); + const kit = { + skill: { + kind: 'instant', + onStart({ battle, unit }) { + const keys = new Set(gridKeys(skillGrid ?? unit.rangeGrid, unit)); + const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && onTiles(e, keys)); + const pushed = new Set(victims); + for (const e of victims) { + // "往身前方向…推开": a directional push whose angle is fixed — PRTS 备注 "不会因为角度过大而改变推动的方向或削减力度" + // (only the angle: a target nearer than 0.25 tile still turns radial at 受力等级 −2) — by the official + // 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance); + // stopped short of it ⇒ it hit a wall + const near = Math.hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST; + const expect = battle.pushDistance(e, near ? force - 2 : force, { effect: true }); + const moved = battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] }, fixedAngle: true, effect: true }); + const wall = expect > 0 && moved + 0.05 < expect; + battle.applyStatus(e, 'stun', { duration: wall ? sWall : sDirect, source: unit }); + } + for (const e of victims) { + for (const o of battle.enemiesInRadius(e.x, e.y, COLLIDE)) { + if (pushed.has(o)) continue; + pushed.add(o); + battle.applyStatus(o, 'stun', { duration: sBrush, source: unit }); + } + } + fx(battle, 'push', unit, { skill: 'forcer_2', n: victims.length }); + }, + // "立即将范围内所有敌人…": the data rule SKILL_RANGE fires it once an enemy is inside the skill range (PRTS 技能 + // 策略 "仅在技能范围内存在敌人(无视其不可选中)时释放技能"), not only when one stands on her 2-tile attack range + }, + talents: [{ install(battle, unit) { + battle.on('hit', (ctx) => { + if (ctx.source === unit && ctx.target.side === 'enemy' && ctx.target.s.massLevel >= num(t0.value, 3)) ctx.dmg.defIgnoreFlat += num(t0.def_penetrate_fixed); + }, { owner: unit }); + } }], + }; + if (num(tb.value) > 0) { + kit.install = (battle, unit) => { + battle.on('deploy', (ctx) => { + // ranged position = not a ground tile (a high tile, or a platform that elevates it: unit.ground false) + if (ctx.unit !== unit || ctx.initial || unit.ground) return; + battle.addDp(unit.ownerId, unit.base.cost * num(tb.value)); + fx(battle, 'dp', unit, { n: unit.base.cost * num(tb.value) }); + }, { owner: unit }); + }; + } + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_08-mint.js b/server/sim/content/kits/ops/chess_char_3_08-mint.js new file mode 100644 index 00000000..adb85cd4 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_08-mint.js @@ -0,0 +1,79 @@ +// server/sim/content/kits/ops/chess_char_3_08-mint.js — 薄绿 (char_388_mint) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, traitBb, altSkills, gridKeys, fx, copyGrid, enemiesOn, aura } from '../shared/tier3.js'; + +export default { + // ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each attack strikes every enemy on her range (the trait's 群体法术伤害) and + // pushes each one towards her, end-of-skill burst on + // every enemy in range; 地质学者: DEF aura (skill off) / less likely targeted (skill on); + // 精锐 module PLX-X: keeps part of the guard (DEF/RES) while the skill runs + // S1 风语: wider range (skill grid), attacks every enemy on it at attack@atk_scale. Auto-cast: the data + // rule SEARCH is the 阵法术师 row (PRTS 卫戍协议/帮助 "不受基础策略影响,在初始攻击范围内存在敌人时释放技能"; it + // covers every MANUAL skill of the class — user playtest #6) = an enemy inside her INITIAL range — the engine's + // DEFAULT rule, checked every tick for a unit that does not attack while its skill is off — not any enemy on the + // field: she would burn the skill on enemies that just spawned. + chess_char_3_08_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const tb = traitBb(d); + const tGrid = copyGrid(d.talents?.[0]?.rangeGrid) ?? [[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]; + const pullForce = num(bb['attack@force'], num(bb.force, 0)); + const endScale = num(bb.atk_scale, 0); + const keepDef = num(tb['soil_e_002[buff].def'], 0), keepRes = num(tb['soil_e_002[buff].magic_resistance'], 0); + // every skill: PLX-X keeps part of the guard, 地质学者 taunt −1 while it runs, DEF aura only while it does not + const guardOn = ({ battle, unit }) => { + if (keepDef || keepRes) battle.addBuff(unit, { key: 'trait:mint_keep', mods: { defPct: keepDef, resFlat: keepRes } }); + battle.addBuff(unit, { key: 'talent:mint_taunt', mods: { taunt: num(t0.taunt_level, -1) } }); + unit.mem.mintAura?.(); + }; + const guardOff = ({ battle, unit }) => { + battle.removeBuff(unit, 'trait:mint_keep'); + battle.removeBuff(unit, 'talent:mint_taunt'); + unit.mem.mintAura?.(); + }; + return { + skills: altSkills(chess, d, bb, { + skchr_mint_1: (s) => { + const grid = copyGrid(s.rangeGrid); + return { + kind: 'duration', + trigger: { rule: 'DEFAULT' }, + attack: { atkScale: num(s.bb['attack@atk_scale'], num(s.bb.atk_scale, 1)) }, + ...(grid ? { targeting: { rangeGrid: grid } } : {}), + onStart: guardOn, + onEnd: guardOff, + }; + }, + }), + skill: { + kind: 'duration', + attack: { + atkScale: num(bb['attack@atk_scale'], 1), + onHit({ battle, unit, target }) { + if (!target || !target.alive || target.side !== 'enemy') return; + // PRTS 备注: "此技能的“拖拽”机制实际为反方向(指向薄绿方向)的推开" — a radial push towards her by the + // official 力度 − 重量 push distance (小力 vs weight 1: 0.44 tiles), never past her (Battle.push inward). Her + // attack reaches air units (阵法术师 "攻击时可对空"), but the drones of the mode are 静态刚体 (PRTS 特殊机制): hit, + // never dragged (player report after 0.1.0, "飞机可以被薄绿的技能拉走") + if (battle.push(target, pullForce, { from: unit, inward: true }) > 0) fx(battle, 'pull', target, { src: unit.id }); + }, + }, + onStart: guardOn, + onEnd(ctx) { + const { battle, unit, reason } = ctx; + if (reason !== 'death' && unit.alive && endScale > 0) { + for (const e of enemiesOn(battle, unit, unit.rangeKeys)) battle.dealDamage(unit, e, { amount: unit.s.atk * endScale, type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); + fx(battle, 'aoe', unit, { radius: 2, dmgType: 'arts', skill: 'mint_2' }); + } + guardOff(ctx); + }, + }, + talents: [{ install(battle, unit) { + unit.mem.mintAura = aura(battle, unit, { + key: 'talent:mint_geo', side: 'ally', tiles: () => gridKeys(tGrid, unit), active: () => !unit.skill?.active, mods: { defPct: num(t0.def) }, + }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_09-haini.js b/server/sim/content/kits/ops/chess_char_3_09-haini.js new file mode 100644 index 00000000..6a6688dd --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_09-haini.js @@ -0,0 +1,57 @@ +// server/sim/content/kits/ops/chess_char_3_09-haini.js — 海霓 (char_4079_haini) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, altSkills, instantKindOf, alive, onTiles, aura } from '../shared/tier3.js'; + +/** Non-elite, non-leader enemy (海霓 "非精英和领袖敌人"). */ +const isNormalEnemy = (e) => !e.isBoss && String(e.def?.rank ?? 'NORMAL').toUpperCase() === 'NORMAL'; + +export default { + // ---- 3_09 海霓 · 削弱者 — S2 阻滞性显色剂: ATK +, 2 targets, slows non-elite enemies in range, their deaths raise the + // talent up to max_talent_up; 测绘器材的奇用: non-elite enemies in range are fragile + chess_char_3_09_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const slow = num(bb['attack@move_speed'], 0); + const up = num(bb['attack@talent_up'], 0), upMax = num(bb['attack@max_talent_up'], 1); + const mt = Math.floor(num(bb['attack@max_target'], 1)); + const kit = { + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + onStart({ battle, unit }) { + unit.mem.hainiMul = 1; + if (!unit.mem.hainiSlow) { + unit.mem.hainiSlow = aura(battle, unit, { key: 'skill:haini_slow', side: 'enemy', interval: 0, tiles: () => unit.rangeKeySet, filter: isNormalEnemy, mods: { moveMul: Math.max(0, 1 + slow) } }); + } + unit.mem.hainiSlow(); + }, + onTick({ unit }) { unit.mem.hainiSlow?.(); }, + onEnd({ unit }) { unit.mem.hainiSlow?.clear(); unit.mem.hainiMul = 1; }, + }, + // S1 迷惑性洋流图: next attack ×atk_scale on max_target enemies at once + skills: altSkills(chess, d, bb, { + skchr_haini_1: (s) => ({ + kind: instantKindOf(s), + targeting: { maxTargets: Math.max(1, Math.floor(num(s.bb.max_target, 1))) }, + attack: { atkScale: num(s.bb.atk_scale, 1) }, + }), + }), + talents: [{ install(battle, unit) { + unit.mem.hainiMul = 1; + aura(battle, unit, { + key: 'talent:haini_fragile', side: 'enemy', tiles: () => unit.rangeKeySet, filter: isNormalEnemy, + mods: () => ({ dmgTakenMul: 1 + (num(t0.damage_scale, 1) - 1) * (unit.mem.hainiMul ?? 1) }), + }); + battle.on('kill', (ctx) => { + const v = ctx.victim; + if (!unit.skill?.active || !alive(unit) || v.side !== 'enemy' || !isNormalEnemy(v)) return; + if (!unit.rangeKeySet || !onTiles(v, unit.rangeKeySet)) return; + unit.mem.hainiMul = Math.min(upMax, (unit.mem.hainiMul ?? 1) + up); + }, { owner: unit }); + } }], + }; + if (mt > 1) kit.skill.targeting = { maxTargets: mt }; + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_10-pinecn.js b/server/sim/content/kits/ops/chess_char_3_10-pinecn.js new file mode 100644 index 00000000..e7e4d544 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_10-pinecn.js @@ -0,0 +1,54 @@ +// server/sim/content/kits/ops/chess_char_3_10-pinecn.js — 松果 (char_440_pinecn) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, selectedId, altSkills, instantKindOf, copyGrid } from '../shared/tier3.js'; + +export default { + // ---- 3_10 松果 · 散射手 — S2 电能过载: ATK + growing with each use (a→d), shorter range; 便携电源: SP regen for 60 s + chess_char_3_10_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const sel = selectedId(chess, d); + const steps = Object.keys(bb).filter((k) => /\[[a-z]\]\.atk$/.test(k)).sort().map((k) => num(bb[k])); + if (!steps.length) steps.push(num(bb.atk)); + const skillGrid = copyGrid(d.skill?.rangeGrid); + const kit = { + skill: { + kind: 'duration', + onStart({ battle, unit, skill }) { + const atk = steps[Math.min(steps.length - 1, Math.max(0, skill.activations - 1))]; + battle.addBuff(unit, { key: 'skill:pinecn_atk', mods: { atkPct: atk } }); + }, + onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:pinecn_atk'); }, + }, + // S1 RMA长钉 (charges): "立即以165%的攻击力进行一次攻击,无视敌人180的防御力" — an extra shot fired at once (all + // enemies in range, front-row × of the trait), on top of the normal attack it was cast before + skills: altSkills(chess, d, bb, { + skchr_pinecn_1: (s) => ({ + kind: instantKindOf(s), + // (the DEF ignore rides on the shots' tag: they land after the instant skill has ended) + attack: { atkScale: num(s.bb.atk_scale, 1), tags: ['skill', 'pinecnSpike'] }, + onStart({ battle, unit }) { battle.forceAttack(unit); }, + }), + }), + talents: [{ install(battle, unit) { + battle.on('deploy', (ctx) => { + if (ctx.unit === unit) battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } }); + }, { owner: unit }); + } }], + }; + if (sel === 'skchr_pinecn_1') { + const pen = num(bb.def_penetrate_fixed, 0); + kit.install = (battle, unit) => battle.on('hit', (ctx) => { + if (ctx.source === unit && pen > 0 && ctx.dmg.tags?.includes('pinecnSpike')) ctx.dmg.defIgnoreFlat += pen; + }, { owner: unit }); + } + if (skillGrid) { + kit.skill.targeting = { rangeGrid: skillGrid }; + // "攻击范围缩短": offensive skills fire with an enemy inside the SKILL range (research 03 §1.4) — with the + // initial (wider) range she would open the skill on enemies the shortened range cannot reach + kit.skill.trigger = { rule: 'CUSTOM_RANGE', grid: skillGrid }; + } + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_11-snhunt.js b/server/sim/content/kits/ops/chess_char_3_11-snhunt.js new file mode 100644 index 00000000..1b14cf2c --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_11-snhunt.js @@ -0,0 +1,74 @@ +// server/sim/content/kits/ops/chess_char_3_11-snhunt.js — 雪猎 (char_4211_snhunt) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { + num, defOf, talentBb, traitBb, selectedId, altSkills, instantKindOf, alive, fx, textNum, enemiesOn, +} from '../shared/tier3.js'; + +export default { + // ---- 3_11 雪猎 · 猎手 — S2 风雪连弩: instant special-bullet double shot (higher vs non-moving targets), charges; + // 裂云一击: the skill also sends 裂云兽 (ATK% phys + cold); 精锐 module HUN-X: reload from empty adds extra rounds + // S1 强力击·β型: next attack ×atk_scale (a normal shot: needs a round); 裂云兽 hits the enemy it is cast on + chess_char_3_11_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const tb = traitBb(d); + const sel = selectedId(chess, d); + const S2 = 'skchr_snhunt_2'; + const s1 = num(bb.atk_scale_1, 1), s2 = num(bb.atk_scale_2, s1); + const charges = Math.max(1, Math.floor(num(d.skill?.maxCharges, num(bb.ct, 1)))); + const shots = textNum(d.skill?.description, /(\d+|[一二两三四五])连击/, 2); + const still = (e) => !!(e.blockedBy || e.s.flags.stun || e.s.flags.noMove || !e.moving); + const extra = num(tb.extra_add, 0); + return { + skill: { + kind: charges > 1 ? 'charges' : 'instant', + onStart({ battle, unit }) { + const t = enemiesOn(battle, unit, unit.rangeKeys, 1, unit.profile)[0] ?? null; + unit.mem.snTarget = t; + if (!t) return; + battle.addProjectile({ + from: unit, target: t, speed: 16, visual: 'arrow', source: unit, + onHit: ({ target }) => { + if (!target || !target.alive) return; + const sc = still(target) ? s2 : s1; + for (let i = 0; i < shots && target.alive; i++) battle.dealDamage(unit, target, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, tags: ['skill', 'snhunt'] }); + }, + }); + fx(battle, 'volley', unit, { target: t.id, skill: 'snhunt_2' }); + }, + }, + skills: altSkills(chess, d, bb, { + skchr_snhunt_1: (s) => ({ kind: instantKindOf(s), attack: { atkScale: num(s.bb.atk_scale, 1) } }), + }), + talents: [{ install(battle, unit) { + battle.on('skillStart', (ctx) => { + if (ctx.unit !== unit) return; + // S2 aims its special bullets itself (mem.snTarget); another skill is cast on the enemy about to be shot + const t = sel === S2 || sel == null ? unit.mem.snTarget : (enemiesOn(battle, unit, unit.rangeKeys, 1, unit.profile)[0] ?? null); + if (!t || !t.alive) return; + battle.dealDamage(unit, t, { amount: unit.s.atk * num(t0.atk_scale, 1), type: 'phys', isSkill: true, tags: ['talent', 'cloudbeast'] }); + if (t.alive) battle.applyStatus(t, 'cold', { duration: num(t0.cold, 3), source: unit }); + fx(battle, 'strike', t, { src: unit.id, talent: 'snhunt_beast' }); + }, { owner: unit }); + } }], + install(battle, unit) { + // special bullets (S2 only): the skill also fires while the magazine is empty (the attack loop cannot run then) + if (sel === S2 || sel == null) { + battle.on('tick', () => { + const sk = unit.skill; + if (!alive(unit) || !unit.canAct || !sk || !sk.ready || sk.opCooling || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return; + if (battle.enemiesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit, unit.profile).length) sk.activate('DEFAULT'); + }, { owner: unit }); + } + if (extra > 0) { + battle.on('tick', () => { + const a = unit.trait.ammo ?? 0; + if (unit.mem.prevAmmo === 0 && a === 1) unit.trait.ammo = Math.min(Math.max(1, unit.profile.ammoMax ?? 8), a + extra); + unit.mem.prevAmmo = unit.trait.ammo; + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_12-blemsh.js b/server/sim/content/kits/ops/chess_char_3_12-blemsh.js new file mode 100644 index 00000000..ab607745 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_12-blemsh.js @@ -0,0 +1,127 @@ +// server/sim/content/kits/ops/chess_char_3_12-blemsh.js — 瑕光 (char_423_blemsh) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { bodyOnTile } from '../../../body.js'; +import { + num, defOf, talentBb, traitBb, altSkills, instantKindOf, alive, onTiles, gridKeys, fx, copyGrid, NINE, isLeader, + giveSp, +} from '../shared/tier3.js'; + +export default { + // ---- 3_12 瑕光 · 守护者 — S3 先贤化身: ATK/DEF +, bonus arts per hit, heals another nearby ally per attack; + // 剑盾骑士: hurt-SP skills of the team also gain SP on attack; 仁慈: attacks sleeping enemies (first, ×atk_scale); + // 精锐 module GUA-Y: damage taken −15 % + // S1 光芒涌动 (自动触发 ⇒ DEFAULT, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself + // included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile and every enemy she + // blocks to sleep (PRTS 备注; for the skill's duration: no own value in the data) and heals every ally of the + // skill range by ATK × ratio each second; + // 精锐 module GUA-X: her heals on allies under hp_ratio HP × heal_scale + chess_char_3_12_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0), t1 = talentBb(d, 1); + const tb = traitBb(d); + const bonus = num(bb['attack@blemsh_s_3_extra_dmg[magic].atk_scale'], 0); + const heal = num(bb.heal_scale, 0); + const healGrid = copyGrid(d.skill?.rangeGrid) ?? NINE; + const mercy = num(t1.atk_scale, 1); + const sleepersInRange = (battle, unit) => { + const set = unit.rangeKeySet; + return set ? battle.enemies.filter((e) => e.alive && !e.hidden && e.s.flags.sleep && !e.s.flags.untargetable && !e.isFlying && onTiles(e, set)) : []; + }; + const kit = { + trait: { hitSleep: true }, // 仁慈: 自身可以攻击…沉睡的目标 (沉睡 = 无敌 for everyone else) + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, + heal: false, + attack: { onHit({ battle, unit, target }) { + if (bonus > 0 && target && target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * bonus, type: 'arts', isSkill: true, tags: ['skill', 'blemshBonus'] }); + } }, + onAttack({ battle, unit }) { + if (!(heal > 0)) return; + const ally = battle.injuredAlliesInKeys(new Set(gridKeys(healGrid, unit)), unit).find((a) => a !== unit); + if (ally) battle.heal(unit, ally, unit.s.atk * heal); + }, + }, + skills: altSkills(chess, d, bb, { + skchr_blemsh_1: (s) => { + const grid = copyGrid(s.rangeGrid) ?? NINE; + const hs = num(s.bb.heal_scale, 0); + return { + kind: instantKindOf(s), + heal: false, + attack: { + atkScale: num(s.bb.atk_scale, 1), + onHit({ battle, unit }) { // "恢复周围一名友方单位" + const ally = hs > 0 ? battle.injuredAlliesInKeys(new Set(gridKeys(grid, unit)), unit)[0] : null; + if (ally) battle.heal(unit, ally, unit.s.atk * hs); + }, + }, + }; + }, + skchr_blemsh_2: (s) => { + const grid = copyGrid(s.rangeGrid) ?? NINE; + const ratio = num(s.bb['attack@atk_to_hp_recovery_ratio'], num(s.bb.atk_to_hp_recovery_ratio, 0)); + return { + kind: 'duration', + heal: false, + mods: { atkPct: num(s.bb.atk) }, + onStart({ battle, unit, skill }) { + const dur = skill.timeLeft > 0 ? skill.timeLeft : Math.max(0.1, num(s.duration, 10)); + let n = 0; + // PRTS 备注: "技能生效对象实际为“自身这格内的所有地面敌人及自身阻挡的敌人”,即使阻挡的是飞行敌人" — a blocked + // enemy stands at the block radius, outside her tile (Battle._checkBlock) + for (const e of battle.enemies) { + if (!e.alive || e.hidden) continue; + if (e.blockedBy !== unit && (e.isFlying || !bodyOnTile(e, unit.tileR, unit.tileC))) continue; + if (battle.applyStatus(e, 'sleep', { duration: dur, source: unit })) n++; + } + fx(battle, 'aoe', unit, { radius: 0.5, skill: 'blemsh_2', status: 'sleep', n }); + unit.mem.blemshRegen = 0; + }, + onTick({ battle, unit, dt }) { // "周围的所有友方单位每秒恢复相当于攻击力N%的生命值" + if (!(ratio > 0)) return; + unit.mem.blemshRegen = (unit.mem.blemshRegen ?? 0) + dt; + while (unit.mem.blemshRegen >= 1 - 1e-9) { + unit.mem.blemshRegen -= 1; + for (const a of battle.injuredAlliesInKeys(new Set(gridKeys(grid, unit)), unit)) battle.heal(unit, a, unit.s.atk * ratio); + } + }, + }; + }, + }), + talents: [ + { install(battle, unit) { + unit.mem.blemshKnight = true; + battle.on('attack', (ctx) => { + const a = ctx.attacker; + if (!a || a.kind !== 'op' || a.ownerId !== unit.ownerId || a.skill?.spType !== 'hurt') return; + if (!alive(unit) || !isLeader(battle, unit, 'blemshKnight')) return; + giveSp(a, num(t0.sp, 1), 'talent'); + }, { owner: unit }); + } }, + { install(battle, unit) { + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit) return; + const s = sleepersInRange(battle, unit); + if (s.length) ctx.targets = [s[0], ...ctx.targets.filter((t) => t !== s[0])].slice(0, Math.max(1, ctx.targets.length)); + }, { owner: unit }); + battle.on('hit', (ctx) => { + if (ctx.source === unit && ctx.target.side === 'enemy' && ctx.target.s.flags.sleep) ctx.dmg.mul *= mercy; + }, { owner: unit }); + // (a lone sleeper is found by the engine's own target search: `trait.hitSleep` makes sleepers targetable + // and damageable for her only) + } }, + ], + }; + if (num(tb.damage_resistance) > 0) { + kit.install = (battle, unit) => battle.addBuff(unit, { key: 'trait:blemsh_guard', mods: { dmgTakenMul: 1 - num(tb.damage_resistance) }, persist: true, allowDead: true }); + } else if (num(tb.heal_scale) > 0) { + // GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%" (the HP before the heal) + kit.install = (battle, unit) => battle.on('heal', (ctx) => { + if (ctx.source === unit && ctx.target && ctx.target.hpRatio < num(tb.hp_ratio, 0.5)) ctx.amount *= num(tb.heal_scale, 1); + }, { owner: unit }); + } + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_13-malist.js b/server/sim/content/kits/ops/chess_char_3_13-malist.js new file mode 100644 index 00000000..97d0962e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_13-malist.js @@ -0,0 +1,25 @@ +// server/sim/content/kits/ops/chess_char_3_13-malist.js — 至简 (char_4054_malist) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, altSkills, statSkill, textNum, funnelMap, installFunnelPrune } from '../shared/tier3.js'; + +export default { + // ---- 3_13 至简 · 驭械术师 — S2 神工意匠: next attack ATK% arts twice (charges); 忽有所悟: prob of ATK ×atk_scale per attack + chess_char_3_13_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const charges = Math.max(1, Math.floor(num(d.skill?.maxCharges, num(bb.ct, 1)))); + const hits = textNum(d.skill?.description, /连续攻击(\d+|[一二两三四五])次/, 2); + const insight = (battle) => (battle.rng.chance(num(t0.prob, 0)) ? num(t0.atk_scale, 1) : 1); + return { + trait: { dmgMul: (battle, unit, target) => funnelMap(battle, unit, target) * insight(battle) }, + skill: { + kind: charges > 1 ? 'charges' : 'instant', + attack: { atkScale: num(bb.atk_scale, 1), hits, dmgType: 'arts', dmgMul: (battle) => insight(battle) }, + }, + // S1 迅捷打击·γ型: ATK / ASPD + (the drones' ramp and 忽有所悟 stay on every attack through the trait) + skills: altSkills(chess, d, bb, { 'skcom_quickattack[3]': statSkill }), + install: installFunnelPrune, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_14-slbell.js b/server/sim/content/kits/ops/chess_char_3_14-slbell.js new file mode 100644 index 00000000..9e589041 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_14-slbell.js @@ -0,0 +1,55 @@ +// server/sim/content/kits/ops/chess_char_3_14-slbell.js — 初雪 (char_174_slbell) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, altSkills, aura } from '../shared/tier3.js'; + +export default { + // ---- 3_14 初雪 · 削弱者 — S2 自然震慑: DEF/RES shred aura on every enemy in range; 虚弱化: low-HP enemies fragile; + // 双响: 2 targets + chess_char_3_14_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0), t1 = talentBb(d, 1); + const mr = num(bb.magic_resistance, 0); + const shred = { defPct: num(bb.def) }; + if (mr) { + if (Math.abs(mr) < 1) shred.resMul = Math.max(0, 1 + mr); + else shred.resFlat = mr; + } + const mt = Math.floor(num(t1['attack@max_target'], 1)); + const kit = { + skill: { + kind: 'duration', + onStart({ battle, unit }) { + if (!unit.mem.slbellAura) unit.mem.slbellAura = aura(battle, unit, { key: 'skill:slbell_shred', side: 'enemy', interval: 0, tiles: () => unit.rangeKeySet, mods: shred }); + unit.mem.slbellAura(); + }, + onTick({ unit }) { unit.mem.slbellAura?.(); }, + onEnd({ unit }) { unit.mem.slbellAura?.clear(); }, + }, + // S1 传音回响: 2 targets, every enemy in range ASPD + attack_speed (negative) while it runs + skills: altSkills(chess, d, bb, { + skchr_slbell_1: (s) => { + const slow = { aspd: num(s.bb.attack_speed) }; + return { + kind: 'duration', + targeting: { maxTargets: Math.max(1, Math.floor(num(s.bb['attack@max_target'], num(s.bb.max_target, 1)))) }, + onStart({ battle, unit }) { + if (!unit.mem.slbellSlow) unit.mem.slbellSlow = aura(battle, unit, { key: 'skill:slbell_aspd', side: 'enemy', interval: 0, tiles: () => unit.rangeKeySet, mods: slow }); + unit.mem.slbellSlow(); + }, + onTick({ unit }) { unit.mem.slbellSlow?.(); }, + onEnd({ unit }) { unit.mem.slbellSlow?.clear(); }, + }; + }, + }), + talents: [{ install(battle, unit) { + aura(battle, unit, { + key: 'talent:weak_fragile', side: 'enemy', tiles: () => unit.rangeKeySet, filter: (e) => e.hpRatio < num(t0.hp_ratio, 0.4), + mods: { dmgTakenMul: num(t0.damage_scale, 1) }, interval: 0.1, + }); + } }], + }; + if (mt > 1) kit.trait = { maxTargets: mt }; + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_15-vodfox.js b/server/sim/content/kits/ops/chess_char_3_15-vodfox.js new file mode 100644 index 00000000..ef4ade8e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_15-vodfox.js @@ -0,0 +1,29 @@ +// server/sim/content/kits/ops/chess_char_3_15-vodfox.js — 巫恋 (char_254_vodfox) kit, tier 3 (hidden). +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { releaseSkillSummon } from '../../tokens.js'; +import { num, defOf, talentBb, aura } from '../shared/tier3.js'; + +export default { + // ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃 "获得一个诅咒娃娃(最多可库存1个)": the doll is a hand piece the player + // places (user playtest #6; PRTS 卫戍协议/帮助); each cast gives one and the placed piece takes the field on its + // own tile (tokens.js releaseSkillSummon: not at the battle start; not placed ⇒ no doll); its token kit keeps the + // 3×3 ATK/DEF aura (the token skill's bb) for 15 s; 溃败暗示: low-HP enemies fragile + chess_char_3_15_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const tokenId = chess?.skill?.overrideTokenKey ?? (d.tokens || []).find((t) => /doll/.test(String(t))) ?? 'token_10006_vodfox_doll'; + return { + skill: { + kind: 'instant', + onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokenId); }, + }, + talents: [{ install(battle, unit) { + aura(battle, unit, { + key: 'talent:weak_fragile', side: 'enemy', tiles: () => unit.rangeKeySet, filter: (e) => e.hpRatio < num(t0.hp_ratio, 0.4), + mods: { dmgTakenMul: num(t0.damage_scale, 1) }, interval: 0.1, + }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_16-snakek.js b/server/sim/content/kits/ops/chess_char_3_16-snakek.js new file mode 100644 index 00000000..28ec160e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_16-snakek.js @@ -0,0 +1,26 @@ +// server/sim/content/kits/ops/chess_char_3_16-snakek.js — 蛇屠箱 (char_150_snakek) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, traitBb, altSkills, statSkill, whileTrue } from '../shared/tier3.js'; + +export default { + // ---- 3_16 蛇屠箱 · 铁卫 — S2 壳状防御: stops attacking, block +1, DEF +, regen; 防御专精: DEF +; + // 精锐 module PRO-X: DEF + while blocking + chess_char_3_16_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const tb = traitBb(d); + const kit = { + skill: { + kind: 'duration', + mods: { defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }, + attack: { noAttack: true }, + }, + // S1 防御力强化·β型 (TAKE_DAMAGE): DEF + (she keeps attacking) + skills: altSkills(chess, d, bb, { 'skcom_def_up[2]': statSkill }), + talents: [{ install(battle, unit) { battle.addBuff(unit, { key: 'talent:snakek_def', mods: { defPct: num(t0.def) }, persist: true, allowDead: true }); } }], + }; + if (num(tb.def) > 0) kit.install = (battle, unit) => whileTrue(battle, unit, 'trait:snakek_block', () => unit.blocking.length > 0, { defPct: num(tb.def) }); + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_17-shotst.js b/server/sim/content/kits/ops/chess_char_3_17-shotst.js new file mode 100644 index 00000000..e522db18 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_17-shotst.js @@ -0,0 +1,41 @@ +// server/sim/content/kits/ops/chess_char_3_17-shotst.js — 流星 (char_126_shotst) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, altSkills, instantKindOf, fx, textNum, enemiesOn } from '../shared/tier3.js'; + +export default { + // ---- 3_17 流星 · 速射手 — S2 碎甲击·扩散: instant ATK% phys on ≤5 enemies in range + DEF shred for `duration` s; + // 空射专精: ×atk_scale vs flying (stacks with the module fly bonus) + chess_char_3_17_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const n = textNum(d.skill?.description, /至多(\d+)个/, 5); + const flyMul = (unit, t) => (t.isFlying ? (unit.profile.flyScale ?? 1) * num(t0.atk_scale, 1) : 1); + return { + trait: { dmgMul: (battle, unit, target) => flyMul(unit, target) }, + skill: { + kind: 'instant', + onStart({ battle, unit }) { + const list = enemiesOn(battle, unit, unit.rangeKeys, n, unit.profile); + for (const e of list) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1) * flyMul(unit, e), type: 'phys', isSkill: true, tags: ['skill', 'burst'] }); + if (e.alive) battle.addBuff(e, { key: 'skill:shotst_shred', duration: num(bb.duration, 5), refresh: 'extend', mods: { defPct: num(bb.def) }, source: unit, visible: true }); + } + if (list.length) fx(battle, 'volley', unit, { targets: list.map((e) => e.id), skill: 'shotst_2' }); + }, + }, + // S1 碎甲击: next attack ×atk_scale (fly × of the trait kept) and the target's DEF − for `duration` s + skills: altSkills(chess, d, bb, { + skchr_shotst_1: (s) => ({ + kind: instantKindOf(s), + attack: { + atkScale: num(s.bb.atk_scale, 1), + onHit({ battle, unit, target }) { + if (target && target.alive && target.side === 'enemy') battle.addBuff(target, { key: 'skill:shotst_shred', duration: num(s.bb.duration, 5), refresh: 'extend', mods: { defPct: num(s.bb.def) }, source: unit, visible: true }); + }, + }, + }), + }), + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_18-vulpis.js b/server/sim/content/kits/ops/chess_char_3_18-vulpis.js new file mode 100644 index 00000000..a7ce204d --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_18-vulpis.js @@ -0,0 +1,127 @@ +// server/sim/content/kits/ops/chess_char_3_18-vulpis.js — 忍冬 (char_4026_vulpis) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { + num, defOf, talentBb, traitBb, altSkills, instantKindOf, alive, gridKeys, fx, copyGrid, enemiesOn, whileTrue, + isLeader, +} from '../shared/tier3.js'; + +/** 忍冬 S3's 迷彩 (until her next cast): its own buff key, never merged with another unit status. */ +const CAMOU_KEY = 'vulpis:camou'; + +export default { + // ---- 3_18 忍冬 · 尖兵 — S3 隐狐之艺: +DP, range +1, ATK +, decaying ASPD, hits all blocked, 0.2 s stun per hit, + // after a kill during the skill 迷彩 from its end until the next cast ("进入迷彩状态,直至下一次开启技能") — the + // `camou` status under its own key (ba.camou "不阻挡时不成为敌方普通攻击的目标": only the enemy she blocks attacks + // her, as with 隐匿; it used to be the generic `stealth` = 隐匿 under the key `stealth`, shared with 伪装服's timed + // 隐匿, which the unending one then made permanent, and it counted as 隐匿 for 叙拉古 / 家族徽章 — user playtest #6 + // "叙拉古阵营隐身不会结束": the bond's 隐匿 ends on time, this 迷彩 officially lasts); 追凶: bonus arts on marked + // enemies; 蓄势: DP regen + out-of-combat + // regen; 精锐 module SOL-X: ATK/DEF + while blocking + // S1 小施惩戒 (charges): next attack + extra_damage_ratio × ATK arts and +cost DP; S2 坠刃拷问 (charges, the data rule + // SKILL_RANGE: fires once an enemy is inside its own range 3-12, charges 3 s apart): +cost DP, ≤ max_target enemies of that range take atk_scale arts and + // 停顿 — the ones already 停顿 are also stunned. (SOL-Y "首次部署时部署费用-4": the initial deployment is free.) + chess_char_3_18_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0), t1 = talentBb(d, 1); + const tb = traitBb(d); + const skillGrid = copyGrid(d.skill?.rangeGrid); + const aspd0 = num(bb.attack_speed, 0); + const kit = { + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + attack: { hitAllBlocked: true, onHitStatus: { key: 'stun', duration: num(bb['attack@stun'], 0.2) } }, + onStart({ battle, unit, skill }) { + battle.removeBuff(unit, CAMOU_KEY); + unit.mem.vulpisKill = false; + const dp = num(bb.cost, 0); + if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); } + battle.addBuff(unit, { key: 'skill:vulpis_aspd', mods: { aspd: aspd0 } }); + unit.mem.vulpisDur = skill.timeLeft; + }, + onTick({ battle, unit, skill }) { + const b = unit.findBuff('skill:vulpis_aspd'); + const dur = unit.mem.vulpisDur || skill.duration || 1; + if (b) { b.mods = { aspd: aspd0 * Math.max(0, skill.timeLeft) / dur }; unit.markDirty(); } + }, + onEnd({ battle, unit, reason }) { + battle.removeBuff(unit, 'skill:vulpis_aspd'); + if (reason !== 'death' && unit.alive && unit.mem.vulpisKill) { + battle.addBuff(unit, { key: CAMOU_KEY, flags: { camou: true }, status: 'camou', source: unit }); + fx(battle, 'camouflage', unit); + } + }, + }, + skills: altSkills(chess, d, bb, { + skchr_vulpis_1: (s) => ({ + kind: instantKindOf(s), + attack: { onHit({ battle, unit, target }) { + if (target && target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * num(s.bb.extra_damage_ratio), type: 'arts', isSkill: true, tags: ['skill', 'vulpisPunish'] }); + const dp = num(s.bb.cost, 0); + if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); } + } }, + }), + skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空"); data trigger SKILL_RANGE (its 3-12 range) + const grid = copyGrid(s.rangeGrid); + const n = Math.max(1, Math.floor(num(s.bb.max_target, 6))); + return { + kind: instantKindOf(s), + onStart({ battle, unit }) { + const dp = num(s.bb.cost, 0); + if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); } + const list = enemiesOn(battle, unit, gridKeys(grid ?? unit.rangeGrid, unit), n); + for (const e of list) { + const was = !!e.findBuff('sluggish'); // "若敌人已经处于停顿状态" (before this cast) + battle.dealDamage(unit, e, { amount: unit.s.atk * num(s.bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'vulpisTorture'] }); + if (!e.alive) continue; + battle.applyStatus(e, 'sluggish', { duration: num(s.bb.sluggish, 0), source: unit }); + if (was && e.alive) battle.applyStatus(e, 'stun', { duration: num(s.bb.stun, 0), source: unit }); + } + if (list.length) fx(battle, 'aoe', unit, { radius: 1.5, dmgType: 'arts', skill: 'vulpis_2', n: list.length }); + }, + }; + }, + }), + talents: [ + { install(battle, unit) { // 追凶 + const marks = new Map(); + // re-entrancy guard: content that turns a hit into HP loss credited to her (频次 enemies: every instance + // = 1 HP) would otherwise feed the bonus back into itself until the hook-depth limit + let busy = false; + battle.on('damaged', (ctx) => { + const e = ctx.target; + if (busy || ctx.source !== unit || e.side !== 'enemy' || ctx.type === 'element' || ctx.dmg?.tags?.includes('vulpisHunt')) return; + const t = marks.get(e.id); + if (t == null) { marks.set(e.id, battle.time); return; } + if (battle.time - t <= num(t0.interval, 10) + 1e-9 && e.alive) { + busy = true; + // PRTS 备注 "伤害类型为法术附加伤害": tag addition (no 叙拉古 6 roll) + try { battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.atk_scale), type: 'arts', canDodge: false, tags: ['talent', 'vulpisHunt', 'addition'] }); } finally { busy = false; } + } + }, { owner: unit }); + battle.on('kill', (ctx) => { if (ctx.killer === unit && unit.skill?.active) unit.mem.vulpisKill = true; }, { owner: unit }); + } }, + { install(battle, unit) { // 蓄势 + unit.mem.vulpisDp = true; + const bonus = num(t1.delta_cost_increase_time, 1) - 1; + const quiet = num(t1.interval, 4); + const regen = num(t1['vulpis_t_2[heal][interval].hp_recovery_per_sec_by_max_hp_ratio'], 0); + battle.on('tick', (ctx) => { + if (!alive(unit)) return; + if (bonus > 0 && isLeader(battle, unit, 'vulpisDp')) battle.addDp(unit.ownerId, battle.flags.dpPerSec * ctx.dt * bonus); + const on = battle.time - unit.lastHitAt >= quiet && regen > 0; + const has = !!unit.findBuff('talent:vulpis_regen'); + if (on && !has) battle.addBuff(unit, { key: 'talent:vulpis_regen', mods: { hpRegenRatio: regen } }); + else if (!on && has) battle.removeBuff(unit, 'talent:vulpis_regen'); + }, { owner: unit }); + } }, + ], + }; + if (skillGrid) kit.skill.targeting = { rangeGrid: skillGrid }; + if (num(tb.atk) > 0 || num(tb.def) > 0) { + kit.install = (battle, unit) => whileTrue(battle, unit, 'trait:vulpis_block', () => unit.blocking.length > 0, { atkPct: num(tb.atk), defPct: num(tb.def) }); + } + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_19-vigil.js b/server/sim/content/kits/ops/chess_char_3_19-vigil.js new file mode 100644 index 00000000..b00b87ac --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_19-vigil.js @@ -0,0 +1,240 @@ +// server/sim/content/kits/ops/chess_char_3_19-vigil.js — 伺夜 (char_427_vigil) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { num, defOf, talentBb, selectedId, altSkills, alive, fx, textNum, tacticalPoint } from '../shared/tier3.js'; + +/** Tokens `tokenId` summoned by / placed for `owner` (board pieces included). */ +const tokensOf = (battle, owner, tokenId) => battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === tokenId && t.ownerUnit === owner && !t.mem.isClone); + +export default { + // ---- 3_19 伺夜 · 战术家 — the tactical reinforcement is the wolf pack (狼群领袖: 2 wolves, +1 every 25 s up to 3, each + // wolf = +1 block and one more bite, a wolf is lost instead of the pack dying); 狼群天性: DEF ignore vs pack-blocked + // enemies; S3 领袖的尊严: DP over time, 三连击, bonus arts vs pack-blocked enemies; 精锐 module: pack takes less + // damage from the enemies it blocks (token module talent). A 狼群 piece placed in the prep phase is the pack. + // S1 领袖的呼唤 (ALWAYS): +cost DP and one more “狼影” (≤ the talent's maximum); S2 领袖的馈赠: +cost DP, the pack + // recovers hp_ratio of its max HP and its next attack hits ×atk_scale — a kill by that attack gives +cost DP. + // 精锐 module TAC-Y: trait ×165 % (profession layer) and "援军阻挡的敌人更容易受到我方的攻击": the pack's token module + // talent taunt_level (+1) goes to the enemies it blocks — the enemy-side 嘲讽等级 our operators target first + // (targeting.js; research 05: "更容易受到我方的攻击" = enemy taunt.taunt_level), never to the pack itself. + chess_char_3_19_a: (bb, chess, def) => { + const d = defOf(chess, def); + const sel = selectedId(chess, d); + const t0 = d.talents?.[0] ?? {}, t1 = talentBb(d, 1); + const wolfId = t0.tokenKey ?? (d.tokens || []).find((t) => /wolf/.test(String(t))) ?? 'token_10028_vigil_wolf'; + const initial = textNum(t0.description, /初始(\d+|[一二两三四五])只/, 2); + const maxWolves = textNum(t0.description, /至多(\d+|[一二两三四五])只/, 3); + const hits = textNum(d.skill?.description, /(\d+|[一二两三四五])连击/, 3); + const bonus = num(bb['attack@vigil_s_3.atk_scale'], 0); + const pen = num(t1.def_penetrate_fixed, 0); + const wolfOf = (u) => (u.trait.reinforcement && u.trait.reinforcement.alive ? u.trait.reinforcement : null); + const wolfKit = (vigil) => ({ + skill: null, + trait: { hitsFn: (b, w) => Math.max(1, w.mem.wolves || 1) }, + install(battle, w) { + const tal = w.def.talents || []; + const wb = tal[0]?.bb ?? {}; + const per = num(wb.block_cnt, num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], 1)); + const grow = num(wb.interval, num(wb['vigil_wolf_t_1_enhance[trigger].interval'], 25)); + // (mem.shadows mirrors the count for content reading tokens.js wolfShadows()) + const apply = () => { w.mem.shadows = w.mem.wolves; battle.addBuff(w, { key: 'token:wolves', mods: { blockCnt: per * w.mem.wolves }, allowDead: true }); }; + w.mem.wolves = Math.max(1, Math.min(maxWolves, initial)); + apply(); + w.mem.addWolf = () => { + if (!w.alive || w.mem.wolves >= maxWolves) return false; + w.mem.wolves++; + apply(); + fx(battle, 'summon', w, { src: vigil.id, wolves: w.mem.wolves }); + return true; + }; + battle.every(grow, () => w.mem.addWolf(), { owner: w }); + battle.on('fatal', (ctx) => { + if (ctx.unit !== w || ctx.prevented || w.mem.wolves <= 1) return; + w.mem.wolves--; + apply(); + ctx.prevented = true; + w.hp = w.s.maxHp; + fx(battle, 'revive', w, { wolves: w.mem.wolves }); + }, { owner: w }); + const mod = tal.find((t) => t && t.bb && t.bb.damage_scale != null); + if (mod) { + battle.on('hit', (ctx) => { + if (ctx.target === w && ctx.source && ctx.source.blockedBy === w) ctx.dmg.mul *= num(mod.bb.damage_scale, 1); + }, { owner: w }); + } + }, + }); + // TAC-Y token module talent (taunt_level) of the pack — whichever kit runs it (this one or a tokens.js board piece): + // the enemies it blocks carry that taunt level while they stay blocked (short refreshed buff: nothing lingers when + // the pack or 伺夜 leaves) + const packTaunt = (w) => { + if (w.mem.packTaunt == null) { + const t = (w.def?.talents || []).find((x) => x && x.bb && x.bb.taunt_level != null); + w.mem.packTaunt = t ? num(t.bb.taunt_level) : 0; + } + return w.mem.packTaunt; + }; + const installPackMark = (battle, unit) => { + let cur = new Set(); + const levelOf = (w) => (w && alive(w) && alive(unit) ? packTaunt(w) : 0); + const mark = (e, w, lvl) => battle.addBuff(e, { key: 'token:pack_mark', duration: 0.25, refresh: 'extend', mods: { taunt: lvl }, source: w, visible: true }); + // at once when the pack blocks (the operators attacking this tick already see it), kept / dropped every 0.1 s + battle.on('blocked', (ctx) => { + const w = wolfOf(unit), lvl = ctx.blocker === w ? levelOf(w) : 0; + if (lvl && ctx.enemy.alive) { mark(ctx.enemy, w, lvl); cur.add(ctx.enemy); } + }, { owner: unit }); + battle.every(0.1, () => { + const w = wolfOf(unit); + const lvl = levelOf(w); + const next = new Set(); + if (lvl) { + for (const e of w.blocking) { + if (!e.alive || e.side !== 'enemy' || e.blockedBy !== w) continue; + mark(e, w, lvl); + next.add(e); + } + } + for (const e of cur) { + if (next.has(e)) continue; + const b = e.findBuff('token:pack_mark'); + if (b) battle.removeBuff(e, b); + } + cur = next; + }, { owner: unit, immediate: true }); + }; + /** One more “狼影” (S1): this kit's pack, or a 狼群 board piece run by content/tokens.js (same 'wolf:shadows' buff). */ + const addShadow = (battle, vigil, w) => { + if (typeof w.mem.addWolf === 'function') return w.mem.addWolf(); + const n = Math.max(1, w.mem.shadows ?? 1); + if (n >= maxWolves) return false; + const wb = w.def?.talents?.[0]?.bb ?? {}; + const per = num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], num(wb.block_cnt, 1)); + w.mem.shadows = n + 1; + battle.addBuff(w, { key: 'wolf:shadows', persist: true, allowDead: true, refresh: 'replace', mods: { blockCnt: w.mem.shadows * per } }); + fx(battle, 'summon', w, { src: vigil.id, wolves: w.mem.shadows }); + return true; + }; + const dpGain = (battle, unit, n) => { if (n > 0) { battle.addDp(unit.ownerId, n); fx(battle, 'dp', unit, { n }); } }; + // S2: the pack's empowered next attack (armed by the cast; ×scale on its hits; a kill pays once) + const installGift = (battle, unit) => { + battle.on('beforeAttack', (ctx) => { + const w = wolfOf(unit); + if (!w || ctx.attacker !== w || !w.mem.vigilGift) return; + w.mem.vigilGiftOn = w.mem.vigilGift; + w.mem.vigilGift = null; + }, { owner: unit, priority: -100 }); + battle.on('hit', (ctx) => { + const g = ctx.source?.mem?.vigilGiftOn; + if (g && ctx.dmg.isAttack && ctx.source === wolfOf(unit)) ctx.dmg.mul *= g.scale; + }, { owner: unit }); + battle.on('kill', (ctx) => { + const g = ctx.killer?.mem?.vigilGiftOn; + if (!g || g.paid || ctx.victim.side !== 'enemy' || ctx.killer !== unit.trait.reinforcement) return; + g.paid = true; + dpGain(battle, unit, g.dp); + }, { owner: unit }); + battle.on('attack', (ctx) => { if (ctx.attacker?.mem?.vigilGiftOn && ctx.attacker === unit.trait.reinforcement) ctx.attacker.mem.vigilGiftOn = null; }, { owner: unit }); + }; + return { + trait: { install(battle, unit) { + // The pack is the tactician's 援军. The match also hands the player the 狼群 token to place in the prep phase + // (= choosing the tactical point): that board piece (tokens.js kit, owner-coupled effects left to this kit) is + // the pack when present — deployed early on its own tile if 伺夜 deploys first — never a second pack. + const spawn = () => { + if (!alive(unit) || wolfOf(unit)) return; + const pieces = tokensOf(battle, unit, wolfId); + const live = pieces.find((t) => alive(t)); + if (live) { unit.trait.reinforcement = live; return; } + const waiting = pieces.find((t) => !t.alive && !t.removed); + if (waiting && battle.redeploy(waiting, { free: true })) { + unit.trait.reinforcement = waiting; + fx(battle, 'summon', waiting, { src: unit.id, token: wolfId }); + return; + } + const board = pieces.find((t) => t.uid != null); + const tile = tacticalPoint(battle, unit, board ? [board.homeR, board.homeC] : null); + if (!tile) return; + const w = battle.spawnToken(unit, wolfId, tile[0], tile[1], { kit: wolfKit(unit) }); + unit.trait.reinforcement = w; + if (!w) return; + fx(battle, 'summon', w, { src: unit.id, token: wolfId, wolves: w.mem.wolves }); + const again = Math.max(0, w.base.respawnTime || 0); + battle.on('death', (c) => { if (c.unit === w && c.reason === 'killed') battle.after(again, spawn, { owner: unit }); }, { owner: w }); + }; + battle.on('deploy', (c) => { if (c.unit === unit) spawn(); }, { owner: unit }); + installPackMark(battle, unit); + battle.on('death', (c) => { + if (c.unit !== unit) return; + const w = wolfOf(unit); + if (w) battle.retreat(w, { reason: 'expired', permanent: true }); + }, { owner: unit }); + } }, + skill: { + kind: 'duration', + attack: { hits }, + onStart({ unit }) { unit.mem.vigilAcc = 0; unit.mem.vigilDp = 0; }, + onTick({ battle, unit, dt }) { + const iv = num(bb.interval, 1.5), step = num(bb.cost, 1), cap = num(bb.value, 0); + if (!(iv > 0) || !(step > 0)) return; // (junk blackboard: never spin) + unit.mem.vigilAcc += dt; + while (unit.mem.vigilAcc >= iv - 1e-9 && unit.mem.vigilDp + step <= cap + 1e-9) { + unit.mem.vigilAcc -= iv; + unit.mem.vigilDp += step; + battle.addDp(unit.ownerId, step); + } + }, + onEnd({ battle, unit, reason }) { + // the whole `value` is granted over a full duration (精锐: 11 × 1.364 s ends a hair after 15 s) + const rest = num(bb.value, 0) - (unit.mem.vigilDp ?? 0); + if (reason === 'duration' && rest > 1e-9) { battle.addDp(unit.ownerId, rest); unit.mem.vigilDp += rest; } + fx(battle, 'dp', unit, { n: unit.mem.vigilDp ?? 0 }); + }, + }, + skills: altSkills(chess, d, bb, { + // (自动触发: an AUTO skill takes no 技能策略 — the 战术家 row is for MANUAL skills — and this DP skill fires as soon + // as it is ready, as before) + skchr_vigil_1: (s) => ({ + kind: 'instant', + trigger: 'SP_FULL', + onStart({ battle, unit }) { + dpGain(battle, unit, num(s.bb.cost, 0)); + const w = wolfOf(unit); + if (w) addShadow(battle, unit, w); + }, + }), + skchr_vigil_2: (s) => ({ + kind: 'instant', + onStart({ battle, unit }) { + dpGain(battle, unit, num(s.bb.cost, 0)); + const w = wolfOf(unit); + if (!w) return; + const hr = num(s.bb['vigil_wolf_s_2.hp_ratio'], 0); + if (hr > 0) battle.heal(w, w, w.s.maxHp * hr, { self: true }); + w.mem.vigilGift = { scale: num(s.bb['vigil_wolf_s_2.atk_scale'], 1), dp: num(s.bb['vigil_wolf_s_2.cost'], 0), paid: false }; + fx(battle, 'buff', w, { src: unit.id, skill: 'vigil_2' }); + }, + }), + }), + install(battle, unit) { if (sel === 'skchr_vigil_2') installGift(battle, unit); }, + talents: [ + { install() { /* 狼群领袖: the pack itself (trait.install / wolfKit) */ } }, + { install(battle, unit) { + battle.on('hit', (ctx) => { + const w = wolfOf(unit); + // "伺夜和狼群对其的攻击无视其175防御力": their attacks only (not item procs or other non-attack damage) + if (!w || pen <= 0 || !ctx.dmg.isAttack || (ctx.source !== unit && ctx.source !== w) || ctx.target.blockedBy !== w) return; + ctx.dmg.defIgnoreFlat += pen; + }, { owner: unit }); + battle.on('damaged', (ctx) => { + const w = wolfOf(unit); + if (!w || !(bonus > 0) || !unit.skill?.active || !ctx.dmg?.isAttack || (ctx.source !== unit && ctx.source !== w)) return; + const e = ctx.target; + if (e.side !== 'enemy' || !e.alive || e.blockedBy !== w) return; + // "狼群与伺夜攻击…时,额外造成…": the extra hit belongs to that attack's dealer (a bite's bonus is the pack's + // damage — never re-typed by 伺夜's 弱点伤害 特质), its size to 伺夜's ATK (same as tokens.js unmanaged mode) + battle.dealDamage(ctx.source, e, { amount: unit.s.atk * bonus, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'vigilBonus'] }); + }, { owner: unit }); + } }, + ], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_20-kjera.js b/server/sim/content/kits/ops/chess_char_3_20-kjera.js new file mode 100644 index 00000000..e56f57bc --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_20-kjera.js @@ -0,0 +1,58 @@ +// server/sim/content/kits/ops/chess_char_3_20-kjera.js — 耶拉 (char_4013_kjera) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { num, defOf, talentBb, altSkills, statSkill, funnelMap, installFunnelPrune } from '../shared/tier3.js'; + +export default { + // ---- 3_20 耶拉 · 驭械术师 — S2 心随意动: +1 drone (2 locks; a lone enemy gets both), ATK +, cold procs; 低眉: ATK + + // (more with ≥cnt ground tiles in range) + chess_char_3_20_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0); + const extra = Math.floor(num(bb['attack@cnt'], 0)); + const prob = num(bb['attack@prob'], 0), cold = num(bb['attack@cold'], 0); + const kit = { + trait: { dmgMul: funnelMap }, + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + attack: { onHit({ battle, unit, target }) { + if (target && target.alive && cold > 0 && battle.rng.chance(prob)) battle.applyStatus(target, 'cold', { duration: cold, source: unit }); + } }, + }, + // S1 攻击力强化·γ型: ATK + (one drone, the trait ramp as usual) + skills: altSkills(chess, d, bb, { 'skcom_atk_up[3]': statSkill }), + install(battle, unit) { + installFunnelPrune(battle, unit); + // "浮游单元+1": every drone locks an enemy — with fewer enemies in range than drones, the spare drone joins a + // lock (both attack it, each hit rolls the cold proc; they ramp in parallel, see funnelMap) + if (extra > 0) { + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.skill?.active || !ctx.targets.length) return; + const n = 1 + extra; + if (ctx.targets.length >= n) return; + const base = ctx.targets.slice(); + const out = base.slice(); + for (let i = 0; out.length < n; i++) out.push(base[i % base.length]); + ctx.targets = out; + }, { owner: unit, priority: -50 }); + } + }, + talents: [{ install(battle, unit) { + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + let low = 0; + for (const k of unit.baseRangeKeys || []) { + const r = (k / COLS) | 0, c = k % COLS; + if (battle.grid.inRect(r, c) && battle.grid.isLow(r, c)) low++; + } + const atk = low >= num(t0.cnt, 2) ? num(t0['kjera_t_1[high].atk'], num(t0.atk)) : num(t0.atk); + battle.addBuff(unit, { key: 'talent:kjera_brow', mods: { atkPct: atk } }); + }, { owner: unit }); + } }], + }; + if (extra > 0) kit.skill.targeting = { maxTargets: 1 + extra }; + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_3_21-archet.js b/server/sim/content/kits/ops/chess_char_3_21-archet.js new file mode 100644 index 00000000..70a075eb --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_3_21-archet.js @@ -0,0 +1,122 @@ +// server/sim/content/kits/ops/chess_char_3_21-archet.js — 空弦 (char_332_archet) kit, tier 3. +// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md. + +import { canTargetEnemy } from '../../../targeting.js'; +import { bodyDist } from '../../../body.js'; +import { + num, defOf, talentBb, moduleTalentBb, altSkills, instantKindOf, groundAspd, alive, fx, enemiesOn, isLeader, giveSp, +} from '../shared/tier3.js'; + +/** The unit's own profile damage multiplier on `target` (fastshot fly bonus …) for damage a kit deals itself. */ +function profileMul(battle, unit, target) { + const f = unit.profile?.dmgMul; + const m = typeof f === 'function' ? f(battle, unit, target) : f; + return typeof m === 'number' && Number.isFinite(m) ? m : 1; +} +/** Targetable enemies within `r` tiles of (x, y), nearest first (spawn order breaks ties), excluding `skip`. */ +function enemiesAround(battle, unit, x, y, r, skip = null) { + const out = battle.foesInRadius(x, y, r).filter((e) => !(skip && skip.has(e)) && canTargetEnemy(unit, e, { canHitFly: true })); + const d = (e) => bodyDist(e, x, y); + return out.sort((a, b) => d(a) - d(b) || (a.spawnSeq ?? a.id) - (b.spawnSeq ?? b.id)); +} +/** 周围 (around a target) for 空弦's scatter / bounce arrows [ASSUMED radius, tiles: not in the data]. */ +const AROUND_R = 1.5; + +export default { + // ---- 3_21 空弦 · 速射手 — S3 箭矢·暴风: ATK +, range +1, 3 hits × 2 targets; 兰登战术: attack-SP snipers +1 SP / 2.5 s; + // 铁弦: one-hit shield at deployment, +7 SP when it breaks; 精锐 module MAR-Y: ASPD + with ground enemies in range + // S1 箭矢·散逸: next attack ×atk_scale + up to max_target−1 other enemies around the target × atk_scale_2; + // S2 箭矢·追猎 (charges): an arrow fired at once hits its target `times` times, then bounces to the nearest enemy + // around not hit yet with one hit fewer each bounce (5, 4, 3, 2, 1). 精锐 MAR-X: fly × (profession layer); + // ISW-A (集成战略 only): no range bonus here. + chess_char_3_21_a: (bb, chess, def) => { + const d = defOf(chess, def); + const t0 = talentBb(d, 0), t1 = talentBb(d, 1); + const mod = moduleTalentBb(chess); + const skill = { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + attack: { hits: Math.max(1, Math.floor(num(bb['attack@times'], 1))) }, + }; + const tg = {}; + if (num(bb.ability_range_forward_extend) > 0) tg.rangeExtend = Math.round(bb.ability_range_forward_extend); + if (num(bb['attack@max_target']) > 1) tg.maxTargets = Math.floor(bb['attack@max_target']); + if (Object.keys(tg).length) skill.targeting = tg; + const physHit = (battle, unit, e, scale, tag) => battle.dealDamage(unit, e, { amount: unit.s.atk * scale * profileMul(battle, unit, e), type: 'phys', isSkill: true, tags: ['skill', tag] }); + const kit = { + skill, + skills: altSkills(chess, d, bb, { + skchr_archet_1: (s) => { + const more = Math.max(0, Math.floor(num(s.bb.max_target, 4)) - 1); + const sc2 = num(s.bb.atk_scale_2, 1); + return { + kind: instantKindOf(s), + attack: { + atkScale: num(s.bb.atk_scale, 1), + onHit({ battle, unit, target, x, y }) { + const cx = target ? target.x : x, cy = target ? target.y : y; + if (!Number.isFinite(cx) || !Number.isFinite(cy) || !(more > 0)) return; + const others = enemiesAround(battle, unit, cx, cy, AROUND_R, new Set([target])).slice(0, more); + for (const e of others) physHit(battle, unit, e, sc2, 'archetScatter'); + if (others.length) fx(battle, 'volley', unit, { targets: others.map((e) => e.id), skill: 'archet_1' }); + }, + }, + }; + }, + skchr_archet_2: (s) => { + const times = Math.max(1, Math.floor(num(s.bb.times, 5))); + const sc = num(s.bb.atk_scale, 1); + return { + kind: instantKindOf(s), + onStart({ battle, unit }) { + const first = enemiesOn(battle, unit, unit.rangeKeys, 1, unit.profile)[0]; + if (!first) return; + const seen = new Set(); + const fire = (from, target, n) => { + seen.add(target); + battle.addProjectile({ + from, target, speed: 16, visual: 'arrow', source: unit, hitDead: true, + onHit: ({ target: t, x, y }) => { + if (t && t.alive) for (let i = 0; i < n && t.alive; i++) physHit(battle, unit, t, sc, 'archetPursuit'); + if (n <= 1) return; + const next = enemiesAround(battle, unit, x, y, AROUND_R, seen)[0]; + if (next) fire({ x, y }, next, n - 1); + }, + }); + }; + fire(unit, first, times); + fx(battle, 'volley', unit, { target: first.id, skill: 'archet_2' }); + }, + }; + }, + }), + talents: [ + { install(battle, unit) { // 兰登战术 + unit.mem.archetTactics = true; + battle.every(num(t0.interval, 2.5), () => { + if (!alive(unit) || !isLeader(battle, unit, 'archetTactics')) return; + for (const a of battle.allies(unit.ownerId)) { + if (a.kind === 'op' && a.def?.profession === 'SNIPER' && a.skill?.spType === 'attack') giveSp(a, num(t0.sp, 1), 'talent'); + } + }, { owner: unit }); + } }, + { install(battle, unit) { // 铁弦 + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + battle.addBuff(unit, { + key: 'talent:archet_shield', shieldHits: 1, visible: true, + onRemove: ({ unit: u, buff }) => { + if (buff.shieldHits > 0 || !u.alive) return; + giveSp(u, num(t1.sp), 'talent'); + fx(battle, 'shieldBreak', u); + }, + }); + fx(battle, 'shield', unit); + }, { owner: unit }); + } }, + ], + }; + if (mod && num(mod.attack_speed) > 0) kit.install = groundAspd('trait:archet_ground', num(mod.attack_speed)); + return kit; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_4_01-rmixer.js b/server/sim/content/kits/ops/chess_char_4_01-rmixer.js new file mode 100644 index 00000000..21564fc2 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_01-rmixer.js @@ -0,0 +1,144 @@ +// server/sim/content/kits/ops/chess_char_4_01-rmixer.js — 信仰搅拌机 (char_4194_rmixer) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { isHpLoss } from '../../../damage.js'; +import { holdsUndying } from '../../items/battle.js'; +import { + AURA, num, tbb, grid, nationOf, enemiesOnRange, targetsInRange, whileDeployed, reveal, skillActive, isSel, alt, + instantKind, withDefaults, +} from '../shared/tier4.js'; + +/** Is module `id` the active one? (elites; `'none'` / normal chess ⇒ false) */ +const moduleIs = (def, id) => !!(def?.raw?.module?.active && def.raw.module.id === id); +/** + * Permanent forward range extension (攻击距离 +n): every range, skill ranges included — except a skill whose range ignores + * 攻击距离 (`targeting.noRangeExtend`). SPT-Y's "攻击距离+1(不受技能攻击范围变化影响)" reads, as PRTS 修正 it, "(部分技能不受 + * 此影响)" (原因: 语序颠倒、表述模糊) — 信仰搅拌机 S3 备注 "此技能的攻击范围不受'攻击距离'属性影响". + */ +const rangeUp = (battle, unit, n = 1) => battle.addBuff(unit, { key: 'module:range', mods: { rangeExtend: n }, persist: true, allowDead: true }); + +export default withDefaults({ + // ===== 信仰搅拌机 (shotprotector) S3 退休前布道 — ammo 30 counters; talents 扫射迎宾仪礼 / 架盾送客仪礼; module: reveal + // S1 铳骑主考官 (自动触发 ⇒ DEFAULT: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo; + // a fatal hit is blocked for `ammo_cost` bullets); module SPT-Y 老朋友: range +1 (not on S3's range, PRTS 备注) + chess_char_4_01_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const counterMax = Math.max(1, Math.floor(num(bb['attack@max_target'], 3))); + const counterRatio = num(bb.base_attack_time, 0.6); // 反击最小间隔 = 实际攻击间隔 × ratio (not a BAT change) + const g = grid(def.skill?.rangeGrid); + const S2 = isSel(def, 'skchr_rmixer_2'), S3 = isSel(def, 'skchr_rmixer_3'); + return { + skills: alt(def, { + // 铳骑主考官: the next attack is a triple hit of atk_scale each and reloads `charge` bullet(s) into ONE other + // 拉特兰 operator around him (8 tiles, [ASSUMED] nearest first) whose ammo skill runs + skchr_rmixer_1: () => ({ + kind: instantKind(def), + attack: { + atkScale: num(bb.atk_scale, 1.3), hits: 3, + onHit({ battle, unit }) { + const n = num(bb.charge, 1); + const cand = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId) + .filter((a) => a !== unit && a.kind === 'op' && nationOf(a) === 'laterano' && a.skill && a.skill.active && a.skill.kind === 'ammo') + .sort((a, b) => Math.hypot(a.x - unit.x, a.y - unit.y) - Math.hypot(b.x - unit.x, b.y - unit.y) || a.id - b.id); + if (!cand[0] || !(n > 0)) return; + cand[0].skill.addAmmo(n); + battle.fx('reload', { x: cand[0].x, y: cand[0].y, id: cand[0].id, n }); + }, + }, + }), + // 八臂电锯侠: ATK/DEF up, 42/45 bullets; the fatal-hit guard is installed below + skchr_rmixer_2: () => ({ + kind: 'ammo', ammo: num(bb['attack@trigger_time'], 42), + mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, + onStart({ battle, unit }) { battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); }, + }), + }), + skill: { + kind: 'ammo', ammo: num(bb['attack@trigger_time'], 30), + mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) }, + // 3-13 exactly: SPT-Y's 攻击距离+1 does not reach it (PRTS 备注; reviewer of community report E1 after 0.1.0) + targeting: g ? { rangeGrid: g, noRangeExtend: true } : undefined, + attack: { noAttack: true }, // 停止主动攻击敌人 (ammo is spent by counters) + onStart({ battle, unit }) { + unit.mem.counterReady = -Infinity; + for (const a of battle.allies(unit.ownerId)) { + if (a === unit || a.kind !== 'op' || nationOf(a) !== 'laterano') continue; + if (a.skill && a.skill.active && a.skill.kind === 'ammo') { a.skill.addAmmo(num(bb.ammo, 0)); battle.fx('reload', { x: a.x, y: a.y, id: a.id, n: num(bb.ammo, 0) }); } + } + battle.fx('sermon', { x: unit.x, y: unit.y, id: unit.id }); + }, + }, + talents: [ + { install(battle, unit) { // 扫射迎宾仪礼: every damage dealt → DEF +30 / ASPD +3 for 10 s, ≤ 3 stacks + battle.on('damaged', (c) => { + if (c.source !== unit || !unit.alive || c.target.side !== 'enemy' || !(c.amount > 0)) return; + battle.addBuff(unit, { key: 'rmixer:t1', duration: num(t0.duration, 10), refresh: 'stack', maxStacks: Math.max(1, num(t0.max_stack_cnt, 3)), + mods: { defFlat: num(t0.def), aspd: num(t0.attack_speed) } }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 架盾送客仪礼: no active attack for 8 s → shield 15 % max HP; timer restarts when it breaks + const since = () => Math.max(unit.deployedAt, unit.mem.lastActiveAtk ?? -Infinity, unit.mem.shieldLostAt ?? -Infinity); + battle.on('attack', (c) => { if (c.attacker === unit && !unit.mem.inCounter) unit.mem.lastActiveAtk = battle.time; }, { owner: unit }); + battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.lastActiveAtk = -Infinity; unit.mem.shieldLostAt = -Infinity; } }, { owner: unit }); + whileDeployed(battle, unit, 0.1, () => { + if (unit.findBuff('rmixer:shield') || battle.time - since() < num(t1.interval, 8) - 1e-9) return; + battle.addBuff(unit, { key: 'rmixer:shield', shield: unit.s.maxHp * num(t1.shield, 0.15), visible: true, + onRemove: () => { unit.mem.shieldLostAt = battle.time; } }); + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); + }); + } }, + ], + install(battle, unit) { + if (S2) { + // S2 "技能期间若受到致命伤害,立即消耗N发弹药抵挡这次伤害": the lethal hit is negated (HP back to its value + // before the hit) and N bullets go; with fewer left it still blocks, spends them all and the skill ends (PRTS 备注 + // "弹药量不足时仍可抵挡致命伤害,此时将消耗所有剩余弹药并退出技能状态"). "自身持有不死时此效果不会生效": a 不死 + // that runs before it (骑士戒律, priority 20) has already prevented the hit; 坚固维式重锤's held window + // (items/battle.js holdsUndying: this deployment's, not run out) runs last, so the guard steps aside while it lasts + const cost = Math.max(1, Math.floor(num(bb.ammo_cost, 30))); + battle.on('hit', (c) => { if (c.target === unit) unit.mem.rmixerPre = { dmg: c.dmg, hp: unit.hp }; }, { owner: unit, priority: -100 }); + battle.on('fatal', (c) => { + const sk = unit.skill; + if (c.unit !== unit || c.prevented || !sk || !sk.active || sk.kind !== 'ammo' || !(sk.ammoLeft > 0)) return; + if (holdsUndying(battle, unit)) return; + c.prevented = true; + const pre = unit.mem.rmixerPre; + unit.hp = pre && pre.dmg === c.dmg ? Math.max(1, Math.min(unit.s.maxHp, pre.hp)) : 1; + const spent = Math.min(cost, sk.ammoLeft); + sk.ammoLeft -= spent; + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, n: spent }); + if (sk.ammoLeft <= 0) sk.end('ammo'); + }, { owner: unit }); + } + // module SPT-Y (elite, 老朋友): 攻击距离+1 — S1 / S2 keep it, S3's 3-13 does not (noRangeExtend above) + if (num(def.traitBb?.ability_range_forward_extend, 0) > 0) rangeUp(battle, unit, num(def.traitBb.ability_range_forward_extend, 1)); + // module SPT-X (elite default): 攻击范围内敌人的隐匿效果失效 + if (moduleIs(def, 'uniequip_002_rmixer')) whileDeployed(battle, unit, AURA, () => reveal(battle, enemiesOnRange(battle, unit))); + if (!S3) return; + // S3 counter (PRTS 备注 "反击受到任何伤害后均可触发,无需目标,视为普通攻击,受各类无法触发普通攻击效果的影响"): ANY damage + // she takes — an enemy's attack, a zone, the 无来源 源石溶剂 tick (items/battle.js periodic_damage) — fires one volley at + // ≤ 3 enemies in range (the attacker first), at most once per actual interval × ratio. With no enemy in range the + // counter still happens and spends its bullet (无需目标: the drain alone empties the skill — 莫斯提马's 特质 turns those + // bullets into 拉特兰 layers, player report D1). A 流失 (Battle.loseHp, tag 'hpLoss': it skips every damage event — + // PRTS 作战机制) and an element 损伤 never counter; stunned / disarmed: no counter + battle.on('damaged', (c) => { + const d = c.dmg; + if (c.target !== unit || !unit.canAct || unit.s.flags.disarm || !skillActive(unit) || !d || c.type === 'element') return; + if (isHpLoss(d)) return; + if (battle.time < (unit.mem.counterReady ?? -Infinity) - 1e-9) return; + const list = targetsInRange(battle, unit); + const i = c.source ? list.indexOf(c.source) : -1; + if (i > 0) { list.splice(i, 1); list.unshift(c.source); } + const targets = list.slice(0, counterMax); + unit.mem.counterReady = battle.time + unit.s.interval * counterRatio; + unit.mem.inCounter = true; + try { + if (targets.length) battle.forceAttack(unit, targets); + else { unit.stats.attacks++; unit.skill.onAttackPerformed([], true); } // a counter into nothing: its bullet goes + } finally { unit.mem.inCounter = false; } + battle.fx('counter', { x: unit.x, y: unit.y, id: unit.id, n: targets.length }); + }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_02-mostma.js b/server/sim/content/kits/ops/chess_char_4_02-mostma.js new file mode 100644 index 00000000..1e32fa4e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_02-mostma.js @@ -0,0 +1,75 @@ +// server/sim/content/kits/ops/chess_char_4_02-mostma.js — 莫斯提马 (char_213_mostma) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { + AURA, num, tbb, grid, enemiesOnRange, whileDeployed, pulse, spAura, skillActive, isSel, alt, applyModuleRange, + withDefaults, +} from '../shared/tier4.js'; + +const TICK_EPS = 0.01; // minimal status duration (s) +/** + * Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉; + * user playtest #6 item 14). Returns the tiles moved. + */ +function shove(battle, e, from, force) { + return e && e.alive ? battle.push(e, num(force, 0), { from }) : 0; +} + +export default withDefaults({ + // ===== 莫斯提马 (splashcaster) S3 序时之匙 — ripple hits all in range, +ATK, talent 2 ×3, small knock-back + // S1 攻击力强化·γ型; S2 荒时之锁 (every enemy in range stunned for the rest of the skill, 100 %/120 % ATK arts + // per second); module SPC-X (资深万国信使定制斗篷): 攻击范围扩大 + chess_char_4_02_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const g = grid(def.skill?.rangeGrid); + const force = num(bb['attack@force'], num(bb.force, 0)); + const S3 = isSel(def, 'skchr_mostma_3'); + return { + skills: alt(def, { + 'skcom_atk_up[3]': () => ({ kind: 'duration', mods: { atkPct: num(bb.atk) } }), + skchr_mostma_2: () => ({ + kind: 'duration', + onStart({ battle, unit, skill }) { + unit.mem.timeLock = { acc: 0, locked: new Set() }; + battle.fx('zone', { x: unit.x, y: unit.y, id: unit.id, duration: skill.duration }); + }, + onTick({ battle, unit, skill, dt }) { + const L = unit.mem.timeLock; + if (!L) return; + // every enemy standing in her range (entering later too) is stunned until the skill ends + for (const e of enemiesOnRange(battle, unit)) { + if (L.locked.has(e.id)) continue; + L.locked.add(e.id); + battle.applyStatus(e, 'stun', { duration: Math.max(TICK_EPS, skill.timeLeft), source: unit }); + } + L.acc += dt; + while (L.acc + 1e-9 >= 1) { + L.acc -= 1; + for (const e of enemiesOnRange(battle, unit)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'timeLock'] }); + } + } + }, + onEnd({ unit }) { unit.mem.timeLock = null; }, + }), + }), + install(battle, unit) { applyModuleRange(battle, unit, def); }, // module SPC-X: 攻击范围扩大 (module range grid) + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + targeting: { ...(g ? { rangeGrid: g } : {}), allInRange: true }, + attack: { projectile: 'none', splashRadius: 0, onHit({ battle, unit, target }) { if (target && target.alive) shove(battle, target, unit, force); } }, + onAttack({ battle, unit }) { battle.fx('ripple', { x: unit.x, y: unit.y, id: unit.id }); }, + }, + talents: [ + { install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec, 0.4), (a) => a.def?.profession === 'CASTER'); } }, + { install(battle, unit) { // 主观缓时: enemies in range −15 % move speed (×talent_scale during S3) + whileDeployed(battle, unit, AURA, () => { + const v = num(t1.move_speed, -0.15) * (S3 && skillActive(unit) ? num(bb.talent_scale, 3) : 1); + for (const e of enemiesOnRange(battle, unit)) pulse(battle, e, `mostma:slow:${unit.id}`, { moveMul: Math.max(0, 1 + v) }, { status: 'slow', visible: true }); + }); + } }, + ], + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_03-kjera.js b/server/sim/content/kits/ops/chess_char_4_03-kjera.js new file mode 100644 index 00000000..7da9d16c --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_03-kjera.js @@ -0,0 +1,77 @@ +// server/sim/content/kits/ops/chess_char_4_03-kjera.js — 耶拉 (char_4013_kjera) kit, tier 4 (hidden). +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { canTargetEnemy } from '../../../targeting.js'; +import { num, tbb, skillActive, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 耶拉 (funnel, hidden) S2 心随意动 — +1 drone locking targets, ATK up, chance to chill; talent 低眉 + chess_char_4_03_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const n = 1 + Math.max(0, Math.floor(num(bb['attack@cnt'], 1))); + const prob = num(bb['attack@prob'], 0), cold = num(bb['attack@cold'], 0); + const funnel = (unit) => unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; + return { + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + targeting: { maxTargets: n }, + attack: { + // every drone ramps on its own locked target (trait init/delta/max): the multipliers are queued per target + // by the beforeAttack hook below (two drones on the same enemy ramp independently) + dmgMul: (battle, unit, target) => { + const q = unit.mem.kjeraQueue && unit.mem.kjeraQueue.get(target.id); + return q && q.length ? q.shift() : funnel(unit).init; + }, + onHit({ battle, unit, target }) { + if (target && target.alive && cold > 0 && battle.rng.chance(prob)) battle.applyStatus(target, 'cold', { duration: cold, source: unit }); + }, + }, + onStart({ battle, unit }) { unit.mem.kjeraDrones = []; unit.mem.kjeraQueue = new Map(); battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n }); }, + onEnd({ unit }) { unit.mem.kjeraDrones = []; unit.mem.kjeraQueue = null; }, + }, + talents: [{ install(battle, unit) { // 低眉: ATK +10 %, +16 % with ≥ 2 ground tiles in range + battle.on('deploy', (c) => { + if (c.unit !== unit) return; + let low = 0; + for (const k of unit.rangeKeys || []) if (battle.grid.isLow((k / COLS) | 0, k % COLS)) low++; + const v = low >= num(t0.cnt, 2) ? num(t0['kjera_t_1[high].atk'], num(t0.atk)) : num(t0.atk); + battle.addBuff(unit, { key: 'kjera:t1', mods: { atkPct: v } }); + }, { owner: unit }); + } }], + install(battle, unit) { + // n drones, each locked on its enemy until that enemy dies (while at least one enemy is in her range). A free + // drone takes an enemy no other drone holds, else doubles up on the best target (a lone enemy/boss gets both). + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !skillActive(unit)) return; + const ok = (e) => !!e && e.alive && canTargetEnemy(unit, e, { canHitFly: true }); + const cands = c.targets.filter(ok); + const drones = unit.mem.kjeraDrones || (unit.mem.kjeraDrones = []); + const held = new Set(); + for (let i = 0; i < n; i++) { if (drones[i] && ok(drones[i].e)) held.add(drones[i].e); else drones[i] = null; } + for (let i = 0; i < n; i++) { + if (drones[i]) continue; + const e = cands.find((x) => !held.has(x)) ?? cands[0] ?? drones.find((d) => d)?.e; + if (!e) continue; + held.add(e); + drones[i] = { e, scale: null }; + } + const f = funnel(unit); + const q = unit.mem.kjeraQueue || (unit.mem.kjeraQueue = new Map()); + const out = []; + for (const d of drones) { + if (!d) continue; + d.scale = d.scale == null ? f.init : Math.min(f.max, d.scale + f.delta); + out.push(d.e); + const l = q.get(d.e.id); + if (l) l.push(d.scale); else q.set(d.e.id, [d.scale]); + } + if (out.length) c.targets = out; + }, { owner: unit, priority: 10 }); + // queued multipliers of shots that never landed (target died mid-flight) are dropped with the target + battle.on('death', (c) => { if (c.unit.side === 'enemy' && unit.mem.kjeraQueue) unit.mem.kjeraQueue.delete(c.unit.id); }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_04-ines.js b/server/sim/content/kits/ops/chess_char_4_04-ines.js new file mode 100644 index 00000000..40e21936 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_04-ines.js @@ -0,0 +1,168 @@ +// server/sim/content/kits/ops/chess_char_4_04-ines.js — 伊内丝 (char_4087_ines) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { bodyDist } from '../../../body.js'; +import { canTargetEnemy } from '../../../targeting.js'; +import { AURA, num, tbb, grid, enemiesOnRange, pulse, isSel, alt, instantKind, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 伊内丝 (agent) S2 暗夜无明 — stealth, +ATK, +1 DP & steal ASPD per attack; talents 影织 / 影哨 + // S1 淬影突袭 (next attack: 3 s arts DoT 40 %/55 % ATK per s, not stacking, +2 DP); S3 独影归途 (passive: the + // first deployment leaves a 影哨 and retreats with the redeploy timer refreshed; every deployment: ATK up for + // the skill duration, the sentry is recalled through ≤ 4/5 enemies for 110 %/140 % phys, +1 DP per damage) + chess_char_4_04_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const tb = def.traitBb || {}; + const g = grid(def.skill?.rangeGrid); + const step = num(bb['attack@steal_atk_speed'], 5), maxSteal = num(bb['attack@steal_atk_speed_max'], 50); + const S3 = isSel(def, 'skchr_ines_3'); + const clearAspd = (battle, unit) => { + for (const id of unit.mem.inesAspdVictims || []) { const e = battle.unitById(id); if (e) battle.removeBuff(e, `ines:aspd:${unit.id}`); } + unit.mem.inesAspdVictims = []; + unit.mem.inesAspd = 0; + battle.removeBuff(unit, 'ines:aspdGain'); + }; + const s3Dur = num(def.skill?.duration, 11); + return { + skills: alt(def, { + skchr_ines_1: () => ({ + kind: instantKind(def), + attack: { + onHit({ battle, unit, target }) { + battle.addDp(unit.ownerId, num(bb.cost, 2)); + if (!target || !target.alive || target.side !== 'enemy') return; + const scale = num(bb.bleed_atk_scale, 0.4); + // (不叠加): a new DoT replaces the running one + battle.addBuff(target, { key: `ines:bleed:${unit.id}`, duration: num(bb.bleed_duration, 3) + 1e-6, interval: 1, source: unit, visible: true, refresh: 'extend', + onTick: ({ unit: t }) => battle.dealDamage(unit, t, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'dot'] }) }); + }, + }, + }), + skchr_ines_3: () => ({ + kind: 'passive', + onStart({ battle, unit }) { + if (!unit.mem.inesS3Placed) { + // 首次部署: place a 影哨 (her talent's sentry, left on retreat) and leave; 立刻刷新再部署时间 — the redeploy + // (auto, paying her DP cost like every redeploy) follows as soon as it is affordable + unit.mem.inesS3Placed = true; + battle.after(0, () => { + if (!unit.alive || !unit.deployed) return; + battle.retreat(unit, { reason: 'retreat' }); + unit.respawnAt = battle.time; + battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + return; + } + battle.addBuff(unit, { key: 'ines:s3', duration: s3Dur, mods: { atkPct: num(bb.atk) }, visible: true }); + // 立刻收回影哨: the sentry flies back to her and hits ≤ max_target enemies on its way (within + // projectile_range of the segment, [ASSUMED] nearest to its start first) + const from = unit.mem.inesSentryAt; + if (!unit.mem.sentry || !from) return; + unit.mem.sentry = null; + unit.mem.inesSentryAt = null; + const ax = from.x, ay = from.y, bx = unit.x, by = unit.y; + const L2 = (bx - ax) ** 2 + (by - ay) ** 2; + const w = num(bb.projectile_range, 1.4); + const hits = []; + for (const e of battle.aliveEnemies()) { + if (!canTargetEnemy(unit, e, { canHitFly: true })) continue; + const t = L2 > 1e-9 ? Math.max(0, Math.min(1, ((e.x - ax) * (bx - ax) + (e.y - ay) * (by - ay)) / L2)) : 0; + const d = bodyDist(e, ax + t * (bx - ax), ay + t * (by - ay)); + if (d <= w + 1e-9) hits.push({ e, t, d }); + } + hits.sort((a, b) => a.t - b.t || a.d - b.d || a.e.id - b.e.id); + const amount = unit.s.atk * num(bb.atk_scale, 1.1); // one projectile: ATK taken when it flies back + for (const { e } of hits.slice(0, Math.max(1, Math.floor(num(bb.max_target, 4))))) { + battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, tags: ['skill', 'sentryRecall'] }); + } + battle.fx('beam', { x: ax, y: ay, tx: bx, ty: by, id: unit.id }); + }, + }), + }), + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + flags: { stealth: true }, + targeting: g ? { rangeGrid: g } : undefined, + onStart({ battle, unit }) { clearAspd(battle, unit); battle.fx('stealth', { x: unit.x, y: unit.y, id: unit.id }); }, + onAttack({ battle, unit }) { battle.addDp(unit.ownerId, num(bb.cost, 1)); }, + onHit({ battle, unit, target }) { + if (!target || !target.alive || target.side !== 'enemy') return; + const room = maxSteal - (unit.mem.inesAspd ?? 0); + if (room <= 0) return; + const s = Math.min(step, room); + unit.mem.inesAspd = (unit.mem.inesAspd ?? 0) + s; + const key = `ines:aspd:${unit.id}`; + const cur = target.findBuff(key); + battle.addBuff(target, { key, mods: { aspd: (cur?.mods?.aspd ?? 0) - s }, source: unit }); + if (!(unit.mem.inesAspdVictims ||= []).includes(target.id)) unit.mem.inesAspdVictims.push(target.id); + battle.addBuff(unit, { key: 'ines:aspdGain', mods: { aspd: unit.mem.inesAspd } }); + }, + onEnd({ battle, unit }) { clearAspd(battle, unit); }, + }, + talents: [ + { install(battle, unit) { // 影织: first damage on each enemy → bind 5 s + steal 90 ATK until it dies or Ines leaves + unit.mem.woven = new Set(); + unit.mem.atkVictims = []; + const refresh = () => { + unit.mem.atkVictims = unit.mem.atkVictims.filter((id) => battle.unitById(id)?.alive); + const gain = Math.min(num(t0.steal_atk_max, Infinity), num(t0.steal_atk, 90) * unit.mem.atkVictims.length); + if (unit.alive) { if (gain > 0) battle.addBuff(unit, { key: 'ines:atkGain', mods: { atkFlat: gain } }); else battle.removeBuff(unit, 'ines:atkGain'); } + }; + battle.on('damaged', (c) => { + const e = c.target; + if (c.source !== unit || !unit.alive || e.side !== 'enemy' || c.type === 'element' || unit.mem.woven.has(e.id)) return; + unit.mem.woven.add(e.id); + if (!e.alive) return; + battle.applyStatus(e, 'bind', { duration: num(t0.duration, 5), source: unit }); + battle.addBuff(e, { key: `ines:atkSteal:${unit.id}`, mods: { atkFlat: -num(t0.steal_atk, 90) }, source: unit }); + unit.mem.atkVictims.push(e.id); + refresh(); + battle.fx('shadowWeave', { x: e.x, y: e.y, id: e.id }); + }, { owner: unit }); + battle.on('death', (c) => { + if (c.unit.side === 'enemy' && unit.mem.atkVictims.includes(c.unit.id)) refresh(); + if (c.unit !== unit) return; + for (const id of unit.mem.atkVictims) { const e = battle.unitById(id); if (e) battle.removeBuff(e, `ines:atkSteal:${unit.id}`); } + unit.mem.atkVictims = []; + unit.mem.woven = new Set(); + }, { owner: unit }); + } }, + { install(battle, unit) { // 影哨: reveal + −30 % move speed in range; a sentry keeps it after she leaves (max 1) + const mods = { moveMul: Math.max(0, 1 + num(t1.move_speed, -0.3)) }; + battle.on('death', (c) => { + if (c.unit !== unit || c.reason === 'expired') return; + unit.mem.sentry = new Set(unit.baseRangeKeys || unit.rangeKeys || []); + battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + battle.every(AURA, () => { + const set = new Set(); + if (unit.alive && unit.deployed) for (const k of unit.rangeKeys || []) set.add(k); + if (unit.mem.sentry) for (const k of unit.mem.sentry) set.add(k); + if (!set.size) return; + for (const e of enemiesOnRange(battle, unit, set)) pulse(battle, e, `ines:sentry:${unit.id}`, mods, { flags: { reveal: true } }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + if (S3) { + // where the talent's 影哨 stays (every leave but an expiry), for the recall of the next deployment + battle.on('death', (c) => { if (c.unit === unit && c.reason !== 'expired') unit.mem.inesSentryAt = { x: unit.x, y: unit.y }; }, { owner: unit, priority: 5 }); + // 技能期间每对一个敌人造成伤害就获得1点部署费用 + battle.on('damaged', (c) => { + if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.findBuff('ines:s3')) return; + battle.addDp(unit.ownerId, num(bb.cost, 1)); + }, { owner: unit }); + } + // module (elite): the first retreat's redeploy time is 35 % shorter + const rt = num(tb.respawn_time, 0); + if (!rt) return; + battle.on('death', (c) => { + if (c.unit !== unit || unit.mem.firstRetreatDone || !Number.isFinite(unit.respawnAt)) return; + unit.mem.firstRetreatDone = true; + unit.respawnAt = unit.deathAt + (unit.respawnAt - unit.deathAt) * Math.max(0, 1 + rt); + }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_05-beewax.js b/server/sim/content/kits/ops/chess_char_4_05-beewax.js new file mode 100644 index 00000000..f5552993 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_05-beewax.js @@ -0,0 +1,66 @@ +// server/sim/content/kits/ops/chess_char_4_05-beewax.js — 蜜蜡 (char_344_beewax) kit, tier 4 (hidden). +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { + num, tbb, grid, targetsInRange, targetsInGrid, toggleBuff, skillActive, withDefaults, +} from '../shared/tier4.js'; + +/** + * Free tile for a summon/device: in the rect, standable, not reserved (Battle.isReservedTile: empty, no knocked-out + * operator lying there) and not the home tile of any ally (dead ones redeploy there). + */ +function freeTile(battle, r, c, { ranged = false, ground = false } = {}) { + if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c)) return false; + if (battle.isReservedTile(r, c)) return false; + if (!battle.grid.canStand(r, c, { ranged })) return false; + if (ground && !battle.grid.groundPassable(r, c)) return false; + for (const u of battle.allyUnits) if (!u.removed && u.homeR === r && u.homeC === c && u.kind !== 'device') return false; + return true; +} + +export default withDefaults({ + // ===== 蜜蜡 (phalanx, hidden) S2 守卫尖碑 — obelisk on a melee tile in range: 200 % arts burst + stun, blocks 3 + chess_char_4_05_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const tb = def.traitBb || {}; + const tokenId = def.raw?.skill?.overrideTokenKey ?? (def.tokens || [])[0] ?? 'token_10011_beewax_oblisk'; + const keepDef = num(tb['soil_e_002[buff].def'], 0), keepRes = num(tb['soil_e_002[buff].magic_resistance'], 0); + const obeliskKit = { skill: null, talents: [], trait: { noAttack: true } }; + return { + skill: { + kind: 'duration', + onStart({ battle, unit }) { + if (keepDef || keepRes) battle.addBuff(unit, { key: 'beewax:keep', mods: { defPct: keepDef, resFlat: keepRes } }); + // melee deploy tile in range: nearest to the most advanced enemy (path tile first) + const foes = targetsInRange(battle, unit); + const ref = foes[0] || battle.aliveEnemies().filter((e) => !e.hidden).sort((a, b) => battle.remainingDistance(a) - battle.remainingDistance(b))[0] || unit; + let best = null, bd = Infinity; + for (const k of unit.rangeKeys || []) { + const r = (k / COLS) | 0, c = k % COLS; + if (!freeTile(battle, r, c)) continue; + const d = Math.hypot(c - ref.x, r - ref.y) + (battle.grid.groundPassable(r, c) ? 0 : 5); + if (d < bd - 1e-9) { bd = d; best = [r, c]; } + } + if (!best) return; + const tok = battle.tokenDef(tokenId, unit); // the owner's skill / module variant (DESIGN §16) + const dur = num(tok?.talents?.[0]?.bb?.duration, num(def.skill?.duration, 20)); + const ob = battle.spawnToken(unit, tokenId, best[0], best[1], { duration: dur, kit: obeliskKit }); + if (!ob) return; + unit.mem.obelisk = ob; + battle.fx('obelisk', { x: ob.x, y: ob.y, id: ob.id }); + const tokGrid = grid(tok?.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]]; + for (const e of targetsInGrid(battle, ob, tokGrid)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 2), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); + if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 1), source: unit }); + } + }, + onEnd({ battle, unit }) { battle.removeBuff(unit, 'beewax:keep'); }, + }, + talents: [{ install(battle, unit) { // 沙原的庇护: +4 % max HP/s while the skill is off + const set = () => toggleBuff(battle, unit, 'beewax:regen', unit.alive && !skillActive(unit), { hpRegenRatio: num(t0.hp_recovery_per_sec_by_max_hp_ratio, 0.04) }); + for (const ev of ['deploy', 'skillStart', 'skillEnd']) battle.on(ev, (c) => { if (c.unit === unit) set(); }, { owner: unit }); + } }], + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_06-kroos2.js b/server/sim/content/kits/ops/chess_char_4_06-kroos2.js new file mode 100644 index 00000000..599d3072 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_06-kroos2.js @@ -0,0 +1,50 @@ +// server/sim/content/kits/ops/chess_char_4_06-kroos2.js — 寒芒克洛丝 (char_1021_kroos2) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { num, tbb, batFlat, skillActive, isSel, alt, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 寒芒克洛丝 (fastshot) S2 封喉 — double shot → 4 shots after 40 hits; talent 中的 (20 % ×1.5 + 0.2 s stun) + // S1 无痕 (ATK up, double shot, 迷彩 = the engine's `camou` flag: only an enemy she blocks targets her) + chess_char_4_06_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const need = num(bb['attack@max_stack_count'], 40); + const S2 = isSel(def, 'skchr_kroos2_2'); + return { + skills: alt(def, { + skchr_kroos2_1: () => ({ + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + flags: { camou: true }, + attack: { hits: 2 }, + onStart({ battle, unit }) { battle.fx('camouflage', { x: unit.x, y: unit.y, id: unit.id }); }, + }), + }), + skill: { + kind: 'duration', + mods: { batPct: batFlat(def, bb.base_attack_time) }, + attack: { hitsFn: (battle, unit) => ((unit.mem.kroosHits ?? 0) >= need ? 4 : 2) }, + onStart({ unit }) { unit.mem.kroosHits = 0; }, + }, + talents: [{ install(battle, unit) { + // 中的: the ×1.5 is rolled before mitigation; the stun (and the S2 "击中目标" count) only for shots that landed + const crits = new WeakSet(); + battle.on('hit', (c) => { + if (c.source !== unit || !c.dmg.isAttack || c.target.side !== 'enemy') return; + if (battle.rng.chance(num(t0.prob, 0.2))) { c.dmg.mul *= num(t0.atk_scale, 1.5); crits.add(c.dmg); } + }, { owner: unit }); + battle.on('damaged', (c) => { + if (c.source !== unit || !c.dmg || !c.dmg.isAttack || c.target.side !== 'enemy') return; + if (S2 && skillActive(unit)) { + unit.mem.kroosHits = (unit.mem.kroosHits ?? 0) + 1; + if (unit.mem.kroosHits === need) battle.fx('quadShot', { x: unit.x, y: unit.y, id: unit.id }); + } + if (!crits.has(c.dmg)) return; + crits.delete(c.dmg); + if (c.target.alive) battle.applyStatus(c.target, 'stun', { duration: num(t0.stun, 0.2), source: unit }); + battle.fx('crit', { x: c.target.x, y: c.target.y, id: c.target.id }); + }, { owner: unit }); + } }], + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_07-bpipe.js b/server/sim/content/kits/ops/chess_char_4_07-bpipe.js new file mode 100644 index 00000000..f435fe45 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_07-bpipe.js @@ -0,0 +1,59 @@ +// server/sim/content/kits/ops/chess_char_4_07-bpipe.js — 风笛 (char_222_bpipe) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets } from '../../../targeting.js'; +import { num, tbb, batFlat, alt, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 风笛 (charger) S2 高效冲击 — next attack 145 % ×2 (charges); talents 精密填弹 / 军事传统 + // S1 迅捷打击·γ型; S3 闭膛连发 (BAT +0.7 s, block +1, ATK/DEF up, triple hits); module CHG-Y (“棍棒与口袋”): + // attacks on enemies below 40 % HP use 115 % ATK + chess_char_4_07_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const tb = def.traitBb || {}; + return { + skills: alt(def, { + 'skcom_quickattack[3]': () => ({ kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } }), + skchr_bpipe_3: () => ({ + kind: 'duration', + mods: { atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt, 1), batPct: batFlat(def, bb.base_attack_time) }, + attack: { hits: 3 }, + onStart({ battle, unit }) { battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id }); }, + }), + }), + install(battle, unit) { + const hr = num(tb.hp_ratio, 0), sc = num(tb.atk_scale, 0); + if (!(hr > 0) || !(sc > 0)) return; + battle.on('hit', (c) => { // 攻击力提升至115% (before mitigation) + if (c.source === unit && c.dmg.isAttack && c.target.side === 'enemy' && c.target.hpRatio < hr) c.dmg.amount *= sc; + }, { owner: unit }); + }, + skill: { + kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', + attack: { atkScale: num(bb.atk_scale, 1.45), hits: 2 }, + onStart({ battle, unit }) { battle.fx('charge', { x: unit.x, y: unit.y, id: unit.id }); }, + }, + talents: [ + { install(battle, unit) { // 精密填弹: 25 % → ×1.3 and one extra target + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit) return; + unit.mem.bpBoost = battle.rng.chance(num(t0.prob, 0.25)); + if (!unit.mem.bpBoost) return; + const extra = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile).filter((e) => !c.targets.includes(e)); + sortEnemyTargets(battle, unit, extra, c.profile?.priority ?? null); + if (extra[0]) c.targets = [...c.targets, extra[0]]; + }, { owner: unit }); + battle.on('hit', (c) => { if (c.source === unit && c.dmg.isAttack && unit.mem.bpBoost) c.dmg.mul *= num(t0.atk_scale, 1.3); }, { owner: unit }); + battle.on('attack', (c) => { if (c.attacker === unit) unit.mem.bpBoost = false; }, { owner: unit }); + } }, + { install(battle, unit) { // 军事传统: every 先锋 of the team starts with +6 SP + battle.on('deploy', (c) => { + const u = c.unit; + if (u.side !== 'ally' || u.kind !== 'op' || u.ownerId !== unit.ownerId || u.def?.profession !== 'PIONEER' || !u.skill || (u.skill.active && u.skill.isTimed)) return; + u.skill.gainSp(num(t1.sp, 6), 'init'); + }, { owner: unit }); + } }, + ], + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_08-rosesa.js b/server/sim/content/kits/ops/chess_char_4_08-rosesa.js new file mode 100644 index 00000000..2f8a525e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_08-rosesa.js @@ -0,0 +1,47 @@ +// server/sim/content/kits/ops/chess_char_4_08-rosesa.js — 瑰盐 (char_4163_rosesa) kit, tier 4 (hidden). +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { + AURA, num, tbb, batFlat, keySet, whileDeployed, pulse, skillActive, applyModuleRange, withDefaults, +} from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 瑰盐 (ringhealer, hidden) S2 绝妙的长效药呀 — allies in range: 20 % of phys/arts damage → 5 s HP loss + chess_char_4_08_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const scale = num(bb['attack@damage_scale'], 0.8), fin = num(bb['attack@final_duration'], 5), iv = Math.max(0.1, num(bb['attack@interval'], 1)); + return { + skill: { kind: 'duration', heal: true, mods: { batPct: batFlat(def, bb.base_attack_time) }, + onStart({ battle, unit }) { battle.fx('saltWard', { x: unit.x, y: unit.y, id: unit.id }); } }, + talents: [{ install(battle, unit) { // 最好的草药医生: own ATK −5 %, operators in range receive +15 % healing + battle.addBuff(unit, { key: 'rosesa:t1', mods: { atkPct: num(t0.atk, -0.05) }, persist: true, allowDead: true }); + whileDeployed(battle, unit, AURA, () => { + for (const a of battle.alliesInGrid(unit)) if (a.kind === 'op') pulse(battle, a, `rosesa:heal:${unit.id}`, { healingTakenMul: num(t0.heal_scale, 1.15) }); + }); + } }], + install(battle, unit) { + const deferred = new WeakSet(); + battle.on('hit', (c) => { + const t = c.target; + // "友方干员": operators only (summons/devices keep the full hit) + if (!skillActive(unit) || !unit.alive || t.side !== 'ally' || t.kind !== 'op' || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return; + if (!keySet(unit).has(t.tileR * COLS + t.tileC)) return; + c.dmg.mul *= scale; + deferred.add(c.dmg); + }, { owner: unit, priority: -5 }); + battle.on('damaged', (c) => { + if (!deferred.has(c.dmg) || !(c.amount > 0) || !c.target.alive) return; + deferred.delete(c.dmg); + const total = (c.amount * (1 - scale)) / Math.max(1e-6, scale); + const per = total / Math.max(1, Math.round(fin / iv)); + const src = c.source; + battle.addBuff(c.target, { key: 'rosesa:dot', refresh: 'independent', maxStacks: 1000, duration: fin + 1e-6, interval: iv, + onTick: ({ unit: tgt }) => battle.loseHp(tgt, per, { source: src && src.side === 'enemy' ? src : null }) }); + }, { owner: unit }); + // module (elite) RIN-X: 攻击范围扩大 (module range grid) + applyModuleRange(battle, unit, def); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_09-mizuki.js b/server/sim/content/kits/ops/chess_char_4_09-mizuki.js new file mode 100644 index 00000000..3c306134 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_09-mizuki.js @@ -0,0 +1,62 @@ +// server/sim/content/kits/ops/chess_char_4_09-mizuki.js — 水月 (char_437_mizuki) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { + AURA, num, tbb, moduleBb, grid, batFlat, enemiesOnRange, whileDeployed, pulse, toggleBuff, skillActive, isSel, alt, + instantKind, withDefaults, +} from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 水月 (stalker) S2 囚徒困境 — faster, +ATK, talent 1 +1 target & bind; talents 创伤性癔症 / 反移情; module slow + // S1 唤醒 (charges: next attack 200 %/230 %, talent 1 ×2.0/×2.3); S3 镜花水月 (wider range, ATK up, talent 1 +2 + // targets & stun; an attack hitting < 3 enemies costs 15 % max HP); modules: AMB-Y 65 % dodge (trait, profession) + chess_char_4_09_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); + const S1 = isSel(def, 'skchr_mizuki_1'), S2 = isSel(def, 'skchr_mizuki_2'), S3 = isSel(def, 'skchr_mizuki_3'); + const g = grid(def.skill?.rangeGrid); + return { + skills: alt(def, { + skchr_mizuki_1: () => ({ kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 2) } }), + skchr_mizuki_3: () => ({ + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + targeting: g ? { rangeGrid: g } : undefined, + onStart({ battle, unit }) { battle.fx('ripple', { x: unit.x, y: unit.y, id: unit.id }); }, + onAttack({ battle, unit, targets }) { + if ((targets || []).filter((e) => e.side === 'enemy').length >= 3) return; + battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio'], 0.15), { tags: ['skill', 'selfLoss'] }); // (no source: not damage dealt) + }, + }), + }), + skill: { kind: 'duration', mods: { batPct: batFlat(def, bb.base_attack_time), atkPct: num(bb.atk) }, + onStart({ battle, unit }) { battle.fx('dilemma', { x: unit.x, y: unit.y, id: unit.id }); } }, + talents: [ + { install(battle, unit) { // 创伤性癔症: +50 % ATK arts to the lowest-HP enemy hit (S2 +1 target & bind, S3 +2 & stun, S1 ×2) + battle.on('attack', (c) => { + if (c.attacker !== unit || !unit.alive) return; + const act = skillActive(unit) && (S2 || S3); + const n = Math.max(1, Math.floor(num(t0['attack@max_target'], 1))) + (act ? Math.floor(num(bb['attack@max_target'], 1)) : 0); + const mul = S1 && c.isSkill ? num(bb.talent_scale, 2) : 1; + const list = c.targets.filter((e) => e.alive && e.side === 'enemy').sort((a, b) => a.hp - b.hp || a.id - b.id).slice(0, n); + for (const e of list) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0['attack@mizuki_t_1.atk_scale'], 0.5) * mul, type: 'arts', tags: ['talent'] }); + if (!act || !e.alive) continue; + if (S2) battle.applyStatus(e, 'bind', { duration: num(bb['attack@unmovable'], 0.8), source: unit }); + else battle.applyStatus(e, 'stun', { duration: num(bb['attack@stun'], 0.7), source: unit }); + } + }, { owner: unit }); + } }, + { install(battle, unit) { // 反移情: ATK +10 % while an enemy in range is below 50 % HP + whileDeployed(battle, unit, 0.1, () => { + const on = enemiesOnRange(battle, unit).some((e) => e.hpRatio < num(t1.hp_ratio, 0.5)); + toggleBuff(battle, unit, 'mizuki:t2', on, { atkPct: num(t1.atk, 0.1) }); + }); + } }, + ], + install(battle, unit) { + const ms = num(mb.move_speed, 0); + if (ms) whileDeployed(battle, unit, AURA, () => { for (const e of enemiesOnRange(battle, unit)) pulse(battle, e, `mizuki:slow:${unit.id}`, { moveMul: Math.max(0, 1 + ms) }); }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_10-aroma.js b/server/sim/content/kits/ops/chess_char_4_10-aroma.js new file mode 100644 index 00000000..54006970 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_10-aroma.js @@ -0,0 +1,81 @@ +// server/sim/content/kits/ops/chess_char_4_10-aroma.js — 阿罗玛 (char_446_aroma) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { bodyInKeys } from '../../../body.js'; +import { num, tbb, keySet, skillActive, isSel, alt, instantKind, withDefaults } from '../shared/tier4.js'; + +/** 阿罗玛's 非首次标记 (talent 起泡性能测试): one per enemy, shared by every 阿罗玛 (PRTS 备注). */ +const AROMA_MARK = 'aroma:bubbled'; + +export default withDefaults({ + // ===== 阿罗玛 (blastcaster) S2 小心地滑 — +ATK, levitated enemies take 65 % ATK arts on landing; talent 起泡性能测试 + // S1 强效清洁 (charges: next attack 150 %/180 % arts, flying victims +55 %/70 % ATK arts) + chess_char_4_10_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const tb = def.traitBb || {}; + const land = num(bb['attack@atk_scale_when_fly_finish'], 0.65); + const S2 = isSel(def, 'skchr_aroma_2'); + return { + skills: alt(def, { + skchr_aroma_1: () => ({ + kind: instantKind(def), + attack: { + atkScale: num(bb.atk_scale, 1.5), + onEachHit({ battle, unit, target }) { // every enemy of her line the blast strikes that flies + if (target && target.alive && target.isFlying) battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb.atk_scale_to_fly, 0.55), type: 'arts', isSkill: true, tags: ['skill', 'antiAir'] }); + }, + }, + }), + }), + skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, onStart({ battle, unit }) { battle.fx('slippery', { x: unit.x, y: unit.y, id: unit.id }); } }, + talents: [{ install(battle, unit) { // first attack on each enemy: ×1.1 and levitate 2.5 s + // PRTS 阿罗玛 备注: "天赋采用施加非首次标记的方式判断是否为'首次进行攻击',自身离场时移除自身已施加的标记,不同阿罗玛之间的 + // 非首次标记通用" — one mark per enemy (AROMA_MARK, the setter as source) for every 阿罗玛; hers go when she leaves + // the field (until 0.1.1 each 阿罗玛 kept her own list, so two of them both triggered on one enemy) + unit.mem.bubbled = new Set(); // the enemies she marked + battle.on('hit', (c) => { + const e = c.target; + if (c.source !== unit || !c.dmg.isAttack || e.side !== 'enemy' || e.findBuff(AROMA_MARK)) return; + battle.addBuff(e, { key: AROMA_MARK, source: unit }); + unit.mem.bubbled.add(e.id); + c.dmg.mul *= num(t0.damage_scale, 1.1); + if (battle.applyStatus(e, 'levitate', { duration: num(t0.levitate_duration, 2.5), source: unit })) battle.fx('levitate', { x: e.x, y: e.y, id: e.id }); + }, { owner: unit }); + battle.on('death', (c) => { + if (c.unit !== unit) return; + for (const id of unit.mem.bubbled) { + const e = battle.unitById(id); + if (e && e.findBuff(AROMA_MARK)?.source === unit) battle.removeBuff(e, AROMA_MARK); + } + unit.mem.bubbled.clear(); + }, { owner: unit }); + } }], + install(battle, unit) { + // S2: landing damage for every levitated enemy (any source) that lands inside her range while the skill runs + const floating = new Map(); + battle.on('statusApplied', (c) => { if (c.status === 'levitate' && c.target.side === 'enemy') floating.set(c.target.id, c.target); }, { owner: unit }); + battle.every(1 / 30, () => { + for (const [id, e] of floating) { + if (!e.alive) { floating.delete(id); continue; } + if (e.findBuff('levitate')) continue; + floating.delete(id); + if (S2 && unit.alive && unit.deployed && skillActive(unit) && bodyInKeys(e, keySet(unit))) { + battle.dealDamage(unit, e, { amount: unit.s.atk * land, type: 'arts', isSkill: true, tags: ['skill', 'landing'] }); + battle.fx('splash', { x: e.x, y: e.y, id: e.id }); + } + } + }, { owner: unit }); + // module (elite) trait: farther targets take more damage (up to ×1.1) + const ds = num(tb.damage_scale, 0); + if (ds > 0) { + const lo = num(tb.min_dist, 0), hi = num(tb.max_dist, 4); + battle.on('hit', (c) => { + if (c.source !== unit || !c.dmg.isAttack) return; + const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); + c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); + }, { owner: unit }); + } + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_11-cathy.js b/server/sim/content/kits/ops/chess_char_4_11-cathy.js new file mode 100644 index 00000000..c0ae3d7c --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_11-cathy.js @@ -0,0 +1,38 @@ +// server/sim/content/kits/ops/chess_char_4_11-cathy.js — 凯瑟琳 (char_4162_cathy) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { CAT_SHIELD_KEY } from '../../tokens.js'; +import { AURA, num, whileDeployed, pulse, alt, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 凯瑟琳 (craftsman) S2 战火淬炼 — stops attacking, HP/DEF up, devices give 6 %/s shields; talent 定向支援信号 + // "携带3个支援装置(最多部署2个)": the 爬行号·防护单元 are hand pieces (2 = the deploy limit) the player places and + // turns towards an operator (user playtest #6, PRTS 卫戍协议/帮助 §战斗部署); they deploy with the board and the + // token kit (tokens.js catShield) gives the shields — S2 overwrite_ratio per second while this skill runs — so + // this kit places nothing (none placed ⇒ no device). + // S1 岁月锻打 (passive: she and every other operator holding a device shield ATK/DEF +8 %/+11 %); module CRA-X + // carries one more device (talent cnt, data — a spare the hand never shows) + chess_char_4_11_a: (bb, chess, def) => ({ + skills: alt(def, { + skchr_cathy_1: () => ({ + kind: 'passive', + mods: { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) }, + onStart({ battle, unit }) { + unit.mem.forgeAura?.cancel(); + unit.mem.forgeAura = whileDeployed(battle, unit, AURA, () => { + for (const a of battle.allies(unit.ownerId)) { + if (a === unit || a.kind !== 'op' || !a.findBuff(CAT_SHIELD_KEY)) continue; + pulse(battle, a, `cathy:forge:${unit.id}`, { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) }); + } + }); + }, + }), + }), + skill: { + kind: 'duration', + mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) }, + attack: { noAttack: true }, + onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); }, + }, + }), +}); diff --git a/server/sim/content/kits/ops/chess_char_4_12-glady.js b/server/sim/content/kits/ops/chess_char_4_12-glady.js new file mode 100644 index 00000000..53b58e7e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_12-glady.js @@ -0,0 +1,100 @@ +// server/sim/content/kits/ops/chess_char_4_12-glady.js — 歌蕾蒂娅 (char_474_glady) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { bodyDist } from '../../../body.js'; +import { + AURA, num, tbb, moduleBb, grid, batFlat, enemyHasTag, targetsInRange, whileDeployed, pulse, alt, instantKind, + pullToFront, withDefaults, +} from '../shared/tier4.js'; + +const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']); +const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId); + +export default withDefaults({ + // ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado: slow, 85 % arts pulses + pull, final pull + // S1 缺水的大洋裂断 (charges: next attack pulls the target to her front, 150 %/180 %); S2 缺水的掌握怒海 (BAT +0.5 s, + // wider range, ≤ 2 targets — blocked first — at 135 %/150 % and pulled to her front); module HOK-Y (淡金坠饰): + // a pull towards herself of an enemy farther than 2.5 tiles is one force level stronger + chess_char_4_12_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); + const tb = def.traitBb || {}; + const R = 1.5; // [ASSUMED] tornado radius (no blackboard key) + const force = num(bb.force, num(bb['attack@force'], 0)); + const iv = Math.max(0.1, num(bb.interval, 1.5)); + const g = grid(def.skill?.rangeGrid); + const dragDmg = (battle, unit, e, moved) => { // module: dragged enemies take arts damage ∝ distance + if (!(moved > 0) || !(num(tb.value, 0) > 0) || !e.alive) return; + battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(1e-6, num(tb.dist, 1)), type: 'arts', isSkill: true, tags: ['drag'] }); + }; + // module HOK-Y: "向自身拖拽较远的敌人时力度提升一个等级" + const farR = num(mb['skill@range_radius'], num(mb['attack@range_radius'], 0)), farF = num(mb['skill@delta_force'], num(mb['attack@delta_force'], 0)); + const selfForce = (unit, e, f) => (farR > 0 && farF > 0 && Math.hypot(e.x - unit.x, e.y - unit.y) > farR + 1e-9 ? f + farF : f); + const pullSelf = (battle, unit, e, f) => { if (e && e.alive) dragDmg(battle, unit, e, pullToFront(battle, unit, e, selfForce(unit, e, f))); }; + return { + skills: alt(def, { + skchr_glady_1: () => ({ + kind: instantKind(def), + attack: { atkScale: num(bb.atk_scale, 1.5), onHit({ battle, unit, target }) { pullSelf(battle, unit, target, num(bb.force, 1)); } }, + }), + skchr_glady_2: () => ({ + kind: 'duration', + mods: { batPct: batFlat(def, bb.base_attack_time) }, + // (target sorting already puts the enemies she blocks first) + targeting: { ...(g ? { rangeGrid: g } : {}), maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))) }, + attack: { atkScale: num(bb['attack@atk_scale'], 1.35), onEachHit({ battle, unit, target }) { pullSelf(battle, unit, target, num(bb['attack@force'], 1)); } }, + onStart({ battle, unit }) { battle.fx('wake', { x: unit.x, y: unit.y, id: unit.id }); }, + }), + }), + skill: { + kind: 'duration', + onStart({ battle, unit, skill }) { + const foes = targetsInRange(battle, unit).sort((a, b) => bodyDist(b, unit.x, unit.y) - bodyDist(a, unit.x, unit.y) || a.id - b.id); + const t = foes[0]; + unit.mem.tornado = t ? { x: t.x, y: t.y, acc: 0 } : null; + if (!t) return; + battle.applyStatus(t, 'bind', { duration: skill.duration, source: unit }); + battle.fx('tornado', { x: t.x, y: t.y, id: unit.id, r: R, duration: skill.duration }); + }, + onTick({ battle, unit, dt }) { + const T = unit.mem.tornado; + if (!T) return; + const inside = battle.foesInRadius(T.x, T.y, R); + for (const e of inside) pulse(battle, e, `glady:slow:${unit.id}`, { moveMul: Math.max(0, 1 + num(bb.move_speed, -0.5)) }); + T.acc += dt; + if (T.acc + 1e-9 < iv) return; + T.acc -= iv; + for (const e of inside) { + if (!e.alive || e.s.flags.untargetable) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 0.85), type: 'arts', isSkill: true, tags: ['skill', 'tornado'] }); + // "小力地拖拽至中心": a pull to the marked point (PRTS 推与拉: its 6 tornado pulls stop 0.05 from it) + if (e.alive) dragDmg(battle, unit, e, battle.pull(e, force, { to: T, stop: 0.05 })); + } + battle.fx('tornadoPulse', { x: T.x, y: T.y, id: unit.id, r: R }); + }, + onEnd({ battle, unit, reason }) { + const T = unit.mem.tornado; + unit.mem.tornado = null; + if (!T || reason === 'death' || !unit.alive) return; + for (const e of battle.foesInRadius(T.x, T.y, R)) pullSelf(battle, unit, e, force); + battle.fx('pull', { x: T.x, y: T.y, id: unit.id }); + }, + }, + talents: [ + { install(battle, unit) { // 阿戈尔的波涛: 深海猎人 regen 2.5 %/s, −25 % phys/arts damage from 海怪 + whileDeployed(battle, unit, AURA, () => { + for (const a of battle.allies(unit.ownerId)) if (isAbyssal(a)) pulse(battle, a, 'glady:tide', { hpRegenRatio: num(t0.hp_recovery_per_sec_by_max_hp_ratio, 0.025) }); + }); + battle.on('hit', (c) => { + if (!c.target.findBuff?.('glady:tide') || !enemyHasTag(c.source, 'seamonster') || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return; + c.dmg.mul *= 1 - num(t0.damage_resistance, 0.25); + }, { owner: unit }); + } }, + { install(battle, unit) { // 弱肉强食: ×1.3 against enemies of weight ≤ 3 + battle.on('hit', (c) => { + if (c.source === unit && c.dmg.isAttack && c.target.side === 'enemy' && c.target.s.massLevel <= num(t1.value, 3)) c.dmg.mul *= num(t1.atk_scale, 1.3); + }, { owner: unit }); + } }, + ], + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_13-gnosis.js b/server/sim/content/kits/ops/chess_char_4_13-gnosis.js new file mode 100644 index 00000000..226db335 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_13-gnosis.js @@ -0,0 +1,112 @@ +// server/sim/content/kits/ops/chess_char_4_13-gnosis.js — 灵知 (char_206_gnosis) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets } from '../../../targeting.js'; +import { + AURA, AURA_DUR, num, tbb, nationOf, enemiesOnRange, targetsInRange, whileDeployed, toggleBuff, skillActive, isSel, + alt, instantKind, withDefaults, +} from '../shared/tier4.js'; + +/** Enemy leaders / elites (精英或领袖敌人). */ +const isEliteEnemy = (e) => !!e && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS'); + +export default withDefaults({ + // ===== 灵知 (underminer) S2 零度爆发 — charges: 130 % arts + cold to all in range, charged ⇒ 2nd cold; 坚冰 / 殊途同归 + // S1 高速思考 (next attack: 2 × 135 %/150 % arts); S3 失温症 (ASPD up, 2 targets — unfrozen first; frozen enemies + // in range stay frozen until the skill ends, then take 300 %/400 % arts and thaw); module UMD-Y (一号项目模型): + // +0.25 SP/s while an elite / leader enemy is in range + chess_char_4_13_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const tb = def.traitBb || {}; + const S3 = isSel(def, 'skchr_gnosis_3'); + const frozen = (e) => !!e.findBuff('freeze'); + return { + skills: alt(def, { + skchr_gnosis_1: () => ({ kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 1.35), hits: 2 } }), + skchr_gnosis_3: () => ({ + kind: 'duration', + mods: { aspd: num(bb.attack_speed) }, + targeting: { maxTargets: Math.max(1, Math.floor(num(bb.max_target, 2))) }, + onStart({ battle, unit }) { unit.mem.hypothermia = new Set(); battle.fx('coldWind', { x: unit.x, y: unit.y, id: unit.id }); }, + onTick({ battle, unit, skill }) { + const H = unit.mem.hypothermia; + if (!H) return; + // 范围内所有敌人的冻结延长至技能结束 + for (const e of enemiesOnRange(battle, unit)) { + const f = e.findBuff('freeze'); + if (!f) continue; + H.add(e.id); + if (f.timeLeft < skill.timeLeft) { f.timeLeft = skill.timeLeft; f.duration = Math.max(f.duration, skill.timeLeft); } + } + }, + onEnd({ battle, unit, reason }) { + const H = unit.mem.hypothermia; + unit.mem.hypothermia = null; + if (!H || reason === 'death' || !unit.alive) return; + // 技能结束时对所有冻结的敌人造成N%的法术伤害并结束冻结 (the frozen enemies of her range) + const list = enemiesOnRange(battle, unit).filter(frozen); + for (const id of H) { const e = battle.unitById(id); if (e && e.alive && frozen(e) && !list.includes(e)) list.push(e); } + for (const e of list) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 3), type: 'arts', isSkill: true, tags: ['skill', 'shatter'] }); + if (e.alive) battle.removeStatus(e, 'freeze'); + } + battle.fx('iceSpike', { x: unit.x, y: unit.y, id: unit.id, n: list.length }); + }, + }), + }), + install(battle, unit) { + if (S3) { + // 优先攻击未冻结的单位 (while S3 runs) + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !skillActive(unit)) return; + const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile); + for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3 + if (cands.length <= 1) return; + sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null); + const n = Math.max(1, Math.floor((c.profile?.maxTargets || 1) + unit.s.maxTargets)); + c.targets = [...cands.filter((e) => !frozen(e)), ...cands.filter(frozen)].slice(0, n); + }, { owner: unit, priority: 5 }); + } + const sp = num(tb.sp_recovery_per_sec, 0); // module UMD-Y + if (sp > 0) whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'gnosis:module', enemiesOnRange(battle, unit).some(isEliteEnemy), { spRecoveryFlat: sp })); + }, + skill: { + kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', + onStart({ battle, unit, skill }) { + const charged = skill.maxCharges > 1 && skill.charges + 1 >= skill.maxCharges; // cast with every charge stored + for (const e of targetsInRange(battle, unit)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.3), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); + if (!e.alive) continue; + battle.applyStatus(e, 'cold', { duration: num(bb.cold, 2.5), source: unit }); + if (charged && e.alive) battle.applyStatus(e, 'cold', { duration: num(bb.cold, 2.5), source: unit }); + } + battle.fx('frostNova', { x: unit.x, y: unit.y, id: unit.id, charged }); + }, + }, + talents: [ + { install(battle, unit) { // 坚冰: attacks chill 1 s; cold enemies in range fragile 25 %, frozen 50 % + battle.on('damaged', (c) => { + if (c.source === unit && c.dmg?.isAttack && !c.dmg.isSplash && c.target.side === 'enemy' && c.target.alive) battle.applyStatus(c.target, 'cold', { duration: num(t0.cold, 1), source: unit }); + }, { owner: unit }); + whileDeployed(battle, unit, 0.1, () => { + for (const e of enemiesOnRange(battle, unit)) { + const f = e.s.flags; + const m = f.freeze ? num(t0.damage_scale_freeze, 1.5) : f.cold ? num(t0.damage_scale_cold, 1.25) : 0; + // 同名效果取最高: one 坚冰 per enemy, the strongest — two 灵知 (a pair / 联防 partner's copy) never compound + if (m) battle.applyStrongest(e, 'gnosis:fragile', { duration: 0.15, value: m, mods: (v) => ({ dmgTakenMul: v }), source: unit }); + } + }); + } }, + { install(battle, unit) { // 殊途同归: 10 s after deployment every 谢拉格 operator gains 抵抗 (negative status durations ×0.5) + // engine `resist` status (applied before a new status lands, so a longer one already running is never + // shortened; 同名效果不叠加: several 灵知 — or 流明's 抵抗 — never compound) + const value = Math.min(1, Math.max(0, -num(t1.one_minus_status_resistance, -0.5))); + whileDeployed(battle, unit, AURA, () => { + if (battle.time - unit.deployedAt < num(t1.interval, 10) - 1e-9 || !(value > 0)) return; + for (const a of battle.allies(unit.ownerId)) if (a.kind === 'op' && nationOf(a) === 'kjerag') battle.applyStatus(a, 'resist', { duration: AURA_DUR, value, source: unit }); + }); + } }, + ], + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_14-lionhd.js b/server/sim/content/kits/ops/chess_char_4_14-lionhd.js new file mode 100644 index 00000000..4bcb7b3d --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_14-lionhd.js @@ -0,0 +1,37 @@ +// server/sim/content/kits/ops/chess_char_4_14-lionhd.js — 莱恩哈特 (char_373_lionhd) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { + num, tbb, grid, targetsInRange, whileDeployed, toggleBuff, resCut, alt, applyModuleRange, withDefaults, +} from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 莱恩哈特 (splashcaster) S2 解构与爆破 — charges: 170 % arts to all in the wider range + RES −8 % for 6 s; 破片杀伤 + chess_char_4_14_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const g = grid(def.skill?.rangeGrid); + const mr = num(bb.magic_resistance, 0); + return { + skills: alt(def, { 'skcom_atk_up[3]': () => ({ kind: 'duration', mods: { atkPct: num(bb.atk) } }) }), // S1 攻击力强化·γ型 + skill: { + kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', + targeting: g ? { rangeGrid: g } : undefined, + onStart({ battle, unit }) { + for (const e of targetsInRange(battle, unit)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.7), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); + // 同名效果取最高: one RES cut per enemy (the strongest), never one per 莱恩哈特 + if (e.alive && mr) battle.applyStrongest(e, 'lionhd:res', { duration: num(bb.duration, 6), value: mr, mods: resCut, source: unit }); + } + battle.fx('explosion', { x: unit.x, y: unit.y, id: unit.id }); + }, + }, + talents: [{ install(battle, unit) { // 破片杀伤: ATK +4 % per enemy in range (≤ 5) + whileDeployed(battle, unit, 0.1, () => { + const n = Math.min(num(t0.max_valid_stack_cnt, 5), targetsInRange(battle, unit).length); + toggleBuff(battle, unit, 'lionhd:t1', n > 0, { atkPct: num(t0.atk, 0.04) * n }); + }); + } }], + install(battle, unit) { applyModuleRange(battle, unit, def); }, // module SPC-X (elite default): 攻击范围扩大 + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_15-reckpr.js b/server/sim/content/kits/ops/chess_char_4_15-reckpr.js new file mode 100644 index 00000000..dc83483e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_15-reckpr.js @@ -0,0 +1,41 @@ +// server/sim/content/kits/ops/chess_char_4_15-reckpr.js — 录武官 (char_4196_reckpr) kit, tier 4 (hidden). +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { isHpLoss } from '../../../damage.js'; +import { num, tbb, keySet, skillActive, installLowHpHealBonus, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 录武官 (physician, hidden) S2 一点关窍 — +ATK; healed allies regain 80 HP per hit taken for 10 s; 学成于聚 + chess_char_4_15_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const tb = def.traitBb || {}; + return { + skill: { + kind: 'duration', heal: true, + mods: { atkPct: num(bb.atk) }, + onStart({ battle, unit }) { battle.fx('knack', { x: unit.x, y: unit.y, id: unit.id }); }, + onHit({ battle, unit, target }) { + if (target && target.alive && target.side === 'ally' && target.kind === 'op') battle.addBuff(target, { key: `reckpr:guard:${unit.id}`, duration: num(bb['attack@buff_duration'], 10), visible: true, source: unit }); + }, + }, + talents: [{ install(battle, unit) { // 学成于聚: an operator in range casts ⇒ +1 SP and ASPD +16 for 8 s + battle.on('skillStart', (c) => { + const a = c.unit; + if (a === unit || a.side !== 'ally' || a.kind !== 'op' || !unit.alive || !unit.deployed || !keySet(unit).has(a.tileR * COLS + a.tileC)) return; + if (!battle.rng.chance(num(t0.prob, 1))) return; + if (!skillActive(unit)) unit.skill?.gainSp(num(t0.sp, 1), 'talent'); + battle.addBuff(unit, { key: 'reckpr:t1', duration: num(t0.duration, 8), mods: { aspd: num(t0.attack_speed, 16) }, maxStacks: Math.max(1, num(t0.max_stack_cnt, 1)) }); + }, { owner: unit }); + } }], + install(battle, unit) { + battle.on('damaged', (c) => { + const t = c.target; + if (t.side !== 'ally' || !t.alive || !(c.amount > 0) || isHpLoss(c.dmg) || !t.findBuff(`reckpr:guard:${unit.id}`)) return; + battle.heal(unit, t, num(bb['attack@fixed_heal_value'], 80)); + }, { owner: unit }); + installLowHpHealBonus(battle, unit, tb); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_16-texas2.js b/server/sim/content/kits/ops/chess_char_4_16-texas2.js new file mode 100644 index 00000000..b682bb76 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_16-texas2.js @@ -0,0 +1,115 @@ +// server/sim/content/kits/ops/chess_char_4_16-texas2.js — 缄默德克萨斯 (char_1028_texas2) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets } from '../../../targeting.js'; +import { + AURA, num, tbb, grid, targetsInGrid, whileDeployed, toggleBuff, resCut, isSel, alt, lonely, withDefaults, +} from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 缄默德克萨斯 (executor) S3 剑雨滂沱 (passive) — deploy burst 2×115 % + 1.5 s stun, sword rain 1/s; talents + // S1 细雨无声 (passive, for the skill duration: ATK up; hits silence the target 5 s/8 s — 失去特殊能力 — with + // 260/320 arts per s meanwhile); S2 阵雨连绵 (passive: deploy burst 150 %/180 % arts + RES −15 %/−20 % around her; + // for the skill duration ATK up and attacks become arts double hits). Talent 德克萨斯传统 applies to every passive. + // The S3 rain hits air units (PRTS 备注 "效果可对空"); so does the S2 deploy burst [ASSUMED: no note]. + chess_char_4_16_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const tb = def.traitBb || {}; + const g = grid(def.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]]; + const S1 = isSel(def, 'skchr_texas2_1'), S2 = isSel(def, 'skchr_texas2_2'); + const dur = num(def.skill?.duration, S1 ? 11 : S2 ? 8 : 6); + const talentAtk = (battle, unit) => battle.addBuff(unit, { key: 'texas2:rainAtk', duration: dur, mods: { atkPct: num(t0.atk, 0.2) } }); + const castS1 = (battle, unit) => { + if (!unit.alive || !unit.deployed) return; + talentAtk(battle, unit); + battle.addBuff(unit, { key: 'texas2:drizzle', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true }); + battle.fx('swordRain', { x: unit.x, y: unit.y, id: unit.id }); + }; + const castS2 = (battle, unit) => { + if (!unit.alive || !unit.deployed) return; + talentAtk(battle, unit); + battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true }); + const mr = num(bb.magic_resistance, 0); + for (const e of targetsInGrid(battle, unit, g)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); + // 同名效果取最高: one RES cut per enemy (the strongest), never one per 缄默德克萨斯 + if (e.alive && mr) battle.applyStrongest(e, 'texas2:resDown', { duration: num(bb.debuff_duration, 8), value: mr, mods: resCut, source: unit }); + } + battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id }); + }; + const castS3 = (battle, unit) => { + if (!unit.alive || !unit.deployed) return; + talentAtk(battle, unit); + for (const e of targetsInGrid(battle, unit, g)) { + for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['appear.atk_scale'], 1.15), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); + if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb['appear.stun'], 1.5), source: unit }); + } + battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id }); + unit.mem.rainUntil = battle.time + dur; + unit.mem.rainTimer?.cancel(); + unit.mem.rainTimer = battle.every(Math.max(0.1, num(bb['texas2_s_3[sword].interval'], 1)), (b, sched) => { + if (!unit.alive || !unit.deployed || b.time > unit.mem.rainUntil + 1e-9) { sched.cancel(); return; } + const list = targetsInGrid(b, unit, g); + sortEnemyTargets(b, unit, list, null); + for (const e of list.slice(0, Math.max(1, Math.floor(num(bb.max_target, 2))))) { + b.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 0.85), type: 'arts', isSkill: true, tags: ['skill', 'swordRain'] }); + if (e.alive) b.applyStatus(e, 'stun', { duration: num(bb.stun, 0.2), source: unit }); + b.fx('swordRain', { x: e.x, y: e.y, id: e.id }); + } + }, { owner: unit }); + }; + const cast = S1 ? castS1 : S2 ? castS2 : castS3; + return { + skill: { kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }, + skills: alt(def, { + skchr_texas2_1: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }), + skchr_texas2_2: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }), + }), + // S2: 攻击变为二连击 while the passive lasts + trait: S2 ? { hitsFn: (b, u) => (u.findBuff('texas2:shower') ? 2 : 1) } : undefined, + talents: [ + { install(battle, unit) { // 德克萨斯传统 (2nd half): first kill of each deployment ⇒ full heal + recast + // (the passive's deploy burst runs inside skill.reset, BEFORE the `deploy` hook: the per-deployment state is + // therefore reset when she leaves the field, so a kill by the deploy burst itself counts) + battle.on('death', (c) => { if (c.unit === unit) unit.mem.texasKilled = false; }, { owner: unit }); + battle.on('kill', (c) => { + if (c.killer !== unit || !unit.alive || c.victim.side !== 'enemy' || unit.mem.texasKilled) return; + unit.mem.texasKilled = true; + battle.removeBuff(unit, 'texas2:swordplay'); + battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio, 1), { self: true }); + cast(battle, unit); + }, { owner: unit }); + } }, + { install(battle, unit) { // 德克萨斯剑术: until her first kill after each deployment: ASPD +8, −25 % damage taken + battle.on('deploy', (c) => { + if (c.unit !== unit || unit.mem.texasKilled) return; // the deploy burst may already have scored the kill + battle.addBuff(unit, { key: 'texas2:swordplay', mods: { aspd: num(t1.attack_speed, 8), dmgTakenMul: 1 - num(t1.damage_resistance, 0.25) } }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + if (S1) { + // 攻击使命中目标失去特殊能力N秒,期间目标每秒受到X点法术伤害 (landed normal attacks while the passive lasts) + const sil = num(bb['attack@silence'], 5), dotDur = num(bb['attack@texas2_s_1[dot].duration'], sil); + const dot = num(bb['attack@texas2_s_1[dot].dot_damage'], 260), dotIv = Math.max(0.1, num(bb['attack@texas2_s_1[dot].interval'], 1)); + battle.on('damaged', (c) => { + const e = c.target; + if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !unit.findBuff('texas2:drizzle')) return; + battle.applyStatus(e, 'silence', { duration: sil, source: unit }); + battle.addBuff(e, { key: `texas2:drizzleDot:${unit.id}`, duration: dotDur + 1e-6, interval: dotIv, source: unit, refresh: 'extend', // a re-hit refreshes it, the per-second ticks keep their rhythm + onTick: ({ unit: t }) => battle.dealDamage(unit, t, { amount: dot, type: 'arts', isSkill: true, tags: ['skill', 'dot'] }) }); + }, { owner: unit }); + } + if (S2) { + // 攻击…造成法术伤害 while the passive lasts (the double hit is the kit trait's hitsFn) + battle.on('hit', (c) => { + if (c.source === unit && c.dmg.isAttack && c.dmg.type === 'phys' && unit.findBuff('texas2:shower')) c.dmg.type = 'arts'; + }, { owner: unit, priority: 50 }); + } + const a = num(tb.atk, 0); // module (elite): ATK +10 % with no ally on the 4 adjacent tiles + if (!a) return; + whileDeployed(battle, unit, AURA, () => toggleBuff(battle, unit, 'texas2:module', lonely(battle, unit), { atkPct: a })); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_17-hsguma.js b/server/sim/content/kits/ops/chess_char_4_17-hsguma.js new file mode 100644 index 00000000..a58be9fc --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_17-hsguma.js @@ -0,0 +1,50 @@ +// server/sim/content/kits/ops/chess_char_4_17-hsguma.js — 星熊 (char_136_hsguma) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { AURA, num, tbb, whileDeployed, pulse, toggleBuff, isSel, alt, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 星熊 (protector) S2 荆棘 (passive) — DEF +13 %, counters every attacker for 65 % ATK phys; talents + // S1 战意 (TAKE_DAMAGE: DEF/ATK up); S3 力之锯 (ATK/DEF up, cuts every enemy on her front tile — all enemies of her + // range); module PRO-X (护身符): DEF +20 % while blocking + chess_char_4_17_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const tb = def.traitBb || {}; + const S2 = isSel(def, 'skchr_hsguma_2'); + return { + skills: alt(def, { + skchr_hsguma_1: () => ({ kind: 'duration', mods: { defPct: num(bb.def), atkPct: num(bb.atk) } }), + skchr_hsguma_3: () => ({ + kind: 'duration', + mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, + targeting: { allInRange: true }, + onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); }, + }), + }), + skill: { kind: 'passive', mods: { defPct: num(bb.def) } }, + talents: [ + { install(battle, unit) { // 战术装甲: 25 % 伤害抵挡 — negates an enemy phys/arts damage instance ("抵挡一次物理或法术伤害") + battle.on('hit', (c) => { // 抵挡 is target-side: a 无来源 burst (source null) counts via the enemy credited with it + const src = c.source || c.credit; + if (c.target !== unit || !src || src.side !== 'enemy' || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return; + if (battle.rng.chance(num(t0.prob, 0.25))) { c.dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); } + }, { owner: unit }); + } }, + { install(battle, unit) { // 特种作战策略: 重装 allies DEF +6 % + whileDeployed(battle, unit, AURA, () => { for (const a of battle.allies(unit.ownerId)) if (a.def?.profession === 'TANK') pulse(battle, a, 'hsguma:def', { defPct: num(t1.def, 0.06) }); }); + } }, + ], + install(battle, unit) { + const bd = num(tb.def, 0); // module PRO-X: 阻挡敌人时防御力+20% + if (bd) whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'hsguma:module', unit.blocking.length > 0, { defPct: bd })); + if (!S2) return; + battle.on('damaged', (c) => { + const src = c.source; + if (c.target !== unit || !unit.alive || !src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return; + battle.dealDamage(unit, src, { amount: unit.s.atk * num(bb.atk_scale, 0.65), type: 'phys', canDodge: false, isSkill: true, tags: ['counter'] }); + battle.fx('thorns', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_18-mudrok.js b/server/sim/content/kits/ops/chess_char_4_18-mudrok.js new file mode 100644 index 00000000..385fdd38 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_18-mudrok.js @@ -0,0 +1,101 @@ +// server/sim/content/kits/ops/chess_char_4_18-mudrok.js — 泥岩 (char_311_mudrok) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { + AURA, num, tbb, grid, batFlat, enemyHasTag, targetsInGrid, whileDeployed, pulse, toggleBuff, alt, lonely, + withDefaults, +} from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 泥岩 (unyield) S2 岩崩锤 — next attack: heal 4 %, 190 % phys to all ground enemies around, 30 % stun; talents + // S1 防御力强化·γ型 (TAKE_DAMAGE); S3 秽壤的血脉 (10 s dormant — cannot act, takes no damage, keeps blocking — + // with enemies around −60 % speed; then ground enemies around stunned 3 s/3.5 s and for the rest of the skill + // BAT −0.3 s, ATK/DEF up, attacks every blocked enemy); module UNY-Y (沃土的愿景): ATK/DEF +8 % with no ally + // on the 8 tiles around + chess_char_4_18_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const tb = def.traitBb || {}; + const g = grid(def.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]]; + const sleep = num(bb.sleep, 10); + return { + skills: alt(def, { + 'skcom_def_up[3]': () => ({ kind: 'duration', mods: { defPct: num(bb.def) } }), + skchr_mudrok_3: () => ({ + kind: 'duration', + attack: { hitAllBlocked: true }, + onStart({ battle, unit }) { + unit.mem.mudS3 = { awake: false, t: 0 }; + battle.addBuff(unit, { key: 'mudrok:dormant', duration: sleep + 1, flags: { invulnerable: true, disarm: true }, visible: true }); + battle.fx('stone', { x: unit.x, y: unit.y, id: unit.id }); + }, + onTick({ battle, unit, dt }) { + const M = unit.mem.mudS3; + if (!M || M.awake) return; + M.t += dt; + for (const e of targetsInGrid(battle, unit, g)) pulse(battle, e, `mudrok:slow:${unit.id}`, { moveMul: Math.max(0, 1 + num(bb.move_speed, -0.6)) }); + if (M.t + 1e-9 < sleep) return; + M.awake = true; + battle.removeBuff(unit, 'mudrok:dormant'); + for (const e of targetsInGrid(battle, unit, g)) if (!e.isFlying && e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 3), source: unit }); + battle.addBuff(unit, { key: 'mudrok:awake', mods: { atkPct: num(bb.atk), defPct: num(bb.def), batPct: batFlat(def, bb.base_attack_time) } }); + battle.fx('rockfall', { x: unit.x, y: unit.y, id: unit.id }); + }, + onEnd({ battle, unit }) { + unit.mem.mudS3 = null; + battle.removeBuff(unit, 'mudrok:dormant'); + battle.removeBuff(unit, 'mudrok:awake'); + }, + }), + }), + skill: { + kind: 'instant', + targeting: { rangeGrid: g, allInRange: true, canHitFly: false }, + attack: { + atkScale: num(bb.atk_scale, 1.9), groundOnly: true, + onHit({ battle, unit, target }) { + if (target && target.alive && battle.rng.chance(num(bb.buff_prob, 0.3))) battle.applyStatus(target, 'stun', { duration: num(bb.stun, 0.5), source: unit }); + }, + }, + onStart({ battle, unit }) { + battle.heal(unit, unit, unit.s.maxHp * num(bb.hp_ratio, 0.04), { self: true }); + battle.fx('rockslide', { x: unit.x, y: unit.y, id: unit.id }); + }, + }, + talents: [ + { install(battle, unit) { // 沃土予身: a hit-negating layer every 9 s (≤ 3, 1 on deploy); each broken layer heals 20 % + const setLayers = (n) => { + unit.mem.layers = n; + if (n > 0) battle.addBuff(unit, { key: 'mudrok:layers', shieldHits: n, visible: true }); + else battle.removeBuff(unit, 'mudrok:layers'); + }; + battle.on('deploy', (c) => { + if (c.unit !== unit) return; + setLayers(Math.min(num(t0.max_times, 3), num(t0.times, 1))); + unit.mem.layerTimer?.cancel(); + unit.mem.layerTimer = whileDeployed(battle, unit, num(t0.interval, 9), () => { + if ((unit.mem.layers ?? 0) < num(t0.max_times, 3)) setLayers(Math.min(num(t0.max_times, 3), (unit.mem.layers ?? 0) + num(t0.times, 1))); + }); + }, { owner: unit }); + battle.on('damaged', (c) => { + if (c.target !== unit || !unit.alive) return; + const left = unit.findBuff('mudrok:layers')?.shieldHits ?? 0; + const broken = (unit.mem.layers ?? 0) - left; + if (broken <= 0) return; + unit.mem.layers = left; + battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio, 0.2) * broken, { self: true }); + battle.fx('shieldBreak', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 手足相惜: −30 % damage from 萨卡兹 enemies + battle.on('hit', (c) => { if (c.target === unit && enemyHasTag(c.source, 'sarkaz')) c.dmg.mul *= 1 - num(t1.damage_resistance, 0.3); }, { owner: unit }); + } }, + ], + install(battle, unit) { + const ds = num(tb.damage_scale, 0); // module (elite): −15 % damage from enemies it blocks + if (ds > 0) battle.on('hit', (c) => { if (c.target === unit && c.source && c.source.blockedBy === unit) c.dmg.mul *= ds; }, { owner: unit }); + const ma = num(tb.atk, 0), md = num(tb.def, 0); // module UNY-Y: 周围8格没有友方干员时攻击力和防御力+8% + if (ma || md) whileDeployed(battle, unit, AURA, () => toggleBuff(battle, unit, 'mudrok:module', lonely(battle, unit, true), { atkPct: ma, defPct: md })); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_19-flamtl.js b/server/sim/content/kits/ops/chess_char_4_19-flamtl.js new file mode 100644 index 00000000..ecdb9abb --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_19-flamtl.js @@ -0,0 +1,118 @@ +// server/sim/content/kits/ops/chess_char_4_19-flamtl.js — 焰尾 (char_420_flamtl) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; +import { + AURA, num, tbb, grid, nationOf, targetsInGrid, whileDeployed, pulse, toggleBuff, skillActive, isSel, alt, + withDefaults, +} from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 焰尾 (pioneer) S3 焰心 — 8 DP over the skill, faster, +ATK, block +1, 60 % dodge; talents 前锋剑术 / 红松骑士团团长 + // S1 迅敏直觉 (+6 DP, dodges the next physical attack); S2 “红松林” (+11/12 DP; ≤ 6 enemies around: 2 × 180 %/210 % + // phys + 0.5 s stun, air units too — PRTS 备注 "※可对空"; allies around +40 %/45 % physical dodge for 10 s); + // module SOL-X (她们的未来): ATK/DEF +8 % while blocking. Her dodges (any source) feed talent 前锋剑术. + chess_char_4_19_a: (bb, chess, def) => { + const t1 = tbb(def, 1); + const tb = def.traitBb || {}; + const total = Math.max(0, Math.floor(num(bb.value, 8))), per = num(bb.cost, 1); + const p = num(bb.prob, 0.6); + const S1 = isSel(def, 'skchr_flamtl_1'), S3 = isSel(def, 'skchr_flamtl_3'); + const g = grid(def.skill?.rangeGrid); + return { + skills: alt(def, { + // (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1) + skchr_flamtl_1: () => ({ + kind: 'instant', + trigger: 'SP_FULL', + onStart({ battle, unit }) { + battle.addDp(unit.ownerId, num(bb.cost, 6)); + battle.addBuff(unit, { key: 'flamtl:evade', visible: true }); + battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost, 6) }); + }, + }), + skchr_flamtl_2: () => ({ + kind: 'instant', + onStart({ battle, unit }) { + battle.addDp(unit.ownerId, num(bb.cost, 11)); + const area = g || [[0, 0], [0, 1]]; + const foes = targetsInGrid(battle, unit, area); + sortEnemyTargets(battle, unit, foes, null); + for (const e of foes.slice(0, Math.max(1, Math.floor(num(bb.max_target, 6))))) { + for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.8), type: 'phys', isSkill: true, tags: ['skill', 'redPine'] }); + if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 0.5), source: unit }); + } + for (const a of battle.unitsInGrid(unit, area, { side: 'ally' })) { + if (a.kind === 'device' || !battle.allySelectable(a, unit)) continue; + battle.addBuff(a, { key: 'flamtl:redPine', duration: num(bb['flamtl_s_2.duration'], 10), mods: { dodgePhys: num(bb['flamtl_s_2.prob'], 0.4) }, visible: true, source: unit }); + } + battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id }); + }, + }), + }), + skill: { + kind: 'duration', + // the 60 % dodge is rolled on its own in the `hit` hook below (independent of other dodge sources such as her + // talent's 22 %: 1 − (1−p)(1−q), never an additive 80 % + 22 % = 100 % immunity) + mods: { atkPct: num(bb.atk), batPct: Math.max(-0.9, num(bb.base_attack_time, 1) - 1), blockCnt: num(bb.block_cnt, 1) }, + onStart({ battle, unit, skill }) { unit.mem.flame = { acc: 0, given: 0, step: skill.duration / Math.max(1, total) }; battle.fx('flame', { x: unit.x, y: unit.y, id: unit.id }); }, + onTick({ battle, unit, dt }) { + const F = unit.mem.flame; + if (!F) return; + F.acc += dt; + while (F.acc + 1e-9 >= F.step && F.given < total) { F.acc -= F.step; F.given++; battle.addDp(unit.ownerId, per); } + }, + onEnd({ battle, unit, reason }) { + const F = unit.mem.flame; + unit.mem.flame = null; + if (F && reason === 'duration' && F.given < total) battle.addDp(unit.ownerId, per * (total - F.given)); + }, + }, + install(battle, unit) { + if (S1) { + battle.on('hit', (c) => { // S1: "闪避下次物理攻击" + const d = c.dmg; + if (c.target !== unit || d.cancel || d.type !== 'phys' || !d.isAttack || !c.source || c.source.side !== 'enemy' || !unit.findBuff('flamtl:evade')) return; + battle.removeBuff(unit, 'flamtl:evade'); + d.cancel = true; + battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); + battle.emit('dodge', { source: c.source, target: unit, dmg: d }); + }, { owner: unit, priority: 20 }); + } + const ma = num(tb.atk, 0), md = num(tb.def, 0); // module SOL-X: 阻挡敌人时攻击力和防御力各+8% + if (ma || md) whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'flamtl:module', unit.blocking.length > 0, { atkPct: ma, defPct: md })); + if (!S3) return; + battle.on('hit', (c) => { // S3: "获得60%的物理和法术闪避" + const d = c.dmg; + if (c.target !== unit || !skillActive(unit) || d.cancel || !d.canDodge || (d.type !== 'phys' && d.type !== 'arts')) return; + if (!battle.rng.chance(p)) return; + d.cancel = true; + battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); + battle.emit('dodge', { source: c.source, target: unit, dmg: d }); + }, { owner: unit, priority: 20 }); + }, + talents: [ + { install(battle, unit) { // 前锋剑术: after a dodge the next attack hits twice and every blocked enemy + battle.on('dodge', (c) => { if (c.target === unit) unit.mem.riposte = true; }, { owner: unit }); + battle.on('death', (c) => { if (c.unit === unit) unit.mem.riposte = unit.mem.riposteNow = false; }, { owner: unit }); + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !unit.mem.riposte) return; + unit.mem.riposte = false; + unit.mem.riposteNow = true; + const extra = unit.blocking.filter((e) => e.alive && canTargetEnemy(unit, e, c.profile) && !c.targets.includes(e)); + c.targets = [...c.targets, ...extra]; + }, { owner: unit }); + battle.on('attack', (c) => { + if (c.attacker !== unit || !unit.mem.riposteNow) return; + unit.mem.riposteNow = false; + for (const e of c.targets) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * unit.s.atkScaleMul, type: 'phys', isAttack: true, tags: ['riposte'] }); + battle.fx('riposte', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 红松骑士团团长: 卡西米尔 operators +22 % physical dodge + whileDeployed(battle, unit, AURA, () => { for (const a of battle.allies(unit.ownerId)) if (nationOf(a) === 'kazimierz') pulse(battle, a, 'flamtl:dodge', { dodgePhys: num(t1.prob, 0.22) }); }); + } }, + ], + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_20-fartth.js b/server/sim/content/kits/ops/chess_char_4_20-fartth.js new file mode 100644 index 00000000..bdccdae5 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_20-fartth.js @@ -0,0 +1,91 @@ +// server/sim/content/kits/ops/chess_char_4_20-fartth.js — 远牙 (char_430_fartth) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { bodyInKeys } from '../../../body.js'; +import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; +import { isHpLoss } from '../../../damage.js'; +import { num, tbb, whileDeployed, toggleBuff, skillActive, isSel, alt, withDefaults } from '../shared/tier4.js'; + +const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i]))); +const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x); +/** HP at 0 (boss pool: pool HP) — a `damaged` hook sees the lethal hit before the kill, while the unit is still `alive`. */ +const downed = (u) => (u.bossPool ? !(u.bossPool.hp > 0) : !(u.hp > 0)); + +export default withDefaults({ + // ===== 远牙 (longrange) S3 光羽箭 — infinite line, +ATK, ×1.25 beyond the normal range; talents 凝神 / 屏息; module + // S1 迅捷打击·γ型; S2 同盟支援 (ASPD up; enemies blocked by any ally anywhere on the field are in range — their + // tiles are extra range keys while the skill runs). Talent 屏息 (taunt −1, ignores dodge) covers every skill. + // Module DEA-Y (支持者来信): +1 SP when an attacked enemy survives the hit + chess_char_4_20_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const tb = def.traitBb || {}; + const S3 = isSel(def, 'skchr_fartth_3'); + const taunt = num(t1.taunt_level, -1); + const setAllied = (battle, unit, keys) => { + const sig = keys ? keys.join(',') : ''; + if (sig === (unit.mem.alliedSig ?? '')) return; + unit.mem.alliedSig = sig; + battle.setExtraRange(unit, keys); + }; + return { + skills: alt(def, { + 'skcom_quickattack[3]': () => ({ kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed), taunt } }), + skchr_fartth_2: () => ({ + kind: 'duration', + mods: { aspd: num(bb.attack_speed), taunt }, + onStart({ battle, unit }) { battle.fx('link', { x: unit.x, y: unit.y, id: unit.id }); }, + onTick({ battle, unit }) { + const keys = []; + for (const e of battle.enemies) if (e.alive && !e.hidden && e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive) keys.push(tileKey(e)); + keys.sort((a, b) => a - b); + setAllied(battle, unit, keys.length ? keys : null); + }, + onEnd({ battle, unit }) { setAllied(battle, unit, null); }, + }), + }), + skill: { kind: 'duration', mods: { atkPct: num(bb.atk), taunt: num(t1.taunt_level, -1) }, targeting: { rangeGrid: LINE }, + onStart({ battle, unit }) { battle.fx('featherArrow', { x: unit.x, y: unit.y, id: unit.id }); } }, + talents: [ + { install(battle, unit) { // 凝神: ATK +15 % when not hurt for 10 s + battle.on('damaged', (c) => { if (c.target === unit && c.amount > 0 && !isHpLoss(c.dmg)) unit.mem.lastHurt = battle.time; }, { owner: unit }); // (a 流失 is not 受伤害) + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.lastHurt = -Infinity; }, { owner: unit }); + whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'fartth:focus', battle.time - (unit.mem.lastHurt ?? -Infinity) >= num(t0.delay, 10) - 1e-9, { atkPct: num(t0.atk, 0.15) })); + } }, + { install(battle, unit) { // 屏息 (2nd half): skill attacks ignore physical dodge + battle.on('hit', (c) => { if (c.source === unit && skillActive(unit) && c.dmg.type === 'phys') c.dmg.canDodge = false; }, { owner: unit }); + } }, + ], + install(battle, unit) { + if (isSel(def, 'skchr_fartth_2')) { + // 同盟支援 reaches BLOCKED enemies outside her range only: the blocked enemies' tiles are extra range keys, so an + // unblocked enemy (a flyer…) standing on such a tile outside her own range is dropped for the next valid target + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !skillActive(unit) || !unit.extraRangeKeys) return; + const own = new Set(absoluteRangeKeys(unit.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend)); + const ok = (e) => bodyInKeys(e, own) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive); + if (c.targets.every(ok)) return; + const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile); + for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3 + for (let i = cands.length - 1; i >= 0; i--) if (!ok(cands[i])) cands.splice(i, 1); + sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null); + c.targets = cands.slice(0, Math.max(1, c.targets.length)); + }, { owner: unit, priority: 5 }); + } + const sp = num(tb.sp, 0); // module DEA-Y: 攻击的敌人未被击倒时自身额外获得1点技力 + if (sp > 0) { + // (`damaged` fires before the kill: a lethal hit leaves the target `alive` with 0 HP — that one gives no SP) + battle.on('damaged', (c) => { + if (c.source === unit && c.dmg?.isAttack && !c.dmg.isSplash && c.target.side === 'enemy' && c.target.alive && !downed(c.target)) unit.skill?.gainSp(sp, 'module'); + }, { owner: unit }); + } + const ds = num(tb.damage_scale, 0), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5); + battle.on('hit', (c) => { + if (c.source !== unit || !c.dmg.isAttack) return; + if (S3 && skillActive(unit) && unit.baseRangeKeys && !bodyInKeys(c.target, unit.baseRangeKeys)) c.dmg.mul *= num(bb.damage_scale, 1.25); + if (ds > 0) { const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); } + }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_21-plosis.js b/server/sim/content/kits/ops/chess_char_4_21-plosis.js new file mode 100644 index 00000000..399cf248 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_21-plosis.js @@ -0,0 +1,19 @@ +// server/sim/content/kits/ops/chess_char_4_21-plosis.js — 白面鸮 (char_128_plosis) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { num, tbb, grid, batFlat, spAura, alt, applyModuleRange, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 白面鸮 (ringhealer) S2 脑啡肽 — wider range, much faster heals; talent 技力光环 (+0.3 SP/s to all allies) + chess_char_4_21_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const g = grid(def.skill?.rangeGrid); + return { + skills: alt(def, { 'skcom_heal_up[3]': () => ({ kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } }) }), // S1 治疗强化·γ型 + skill: { kind: 'duration', heal: true, mods: { batPct: batFlat(def, bb.base_attack_time) }, targeting: g ? { rangeGrid: g } : undefined, + onStart({ battle, unit }) { battle.fx('healField', { x: unit.x, y: unit.y, id: unit.id }); } }, + talents: [{ install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec, 0.3), (a) => a.kind !== 'device'); } }], + install(battle, unit) { applyModuleRange(battle, unit, def); }, // module RIN-X (elite default): 攻击范围扩大 + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_22-svrash.js b/server/sim/content/kits/ops/chess_char_4_22-svrash.js new file mode 100644 index 00000000..309749fb --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_22-svrash.js @@ -0,0 +1,52 @@ +// server/sim/content/kits/ops/chess_char_4_22-svrash.js — 银灰 (char_172_svrash) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { + AURA, num, tbb, grid, enemiesOnRange, whileDeployed, reveal, alt, instantKind, withDefaults, +} from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 银灰 (lord) S3 真银斩 — DEF −70 %, ATK +125 %, wider range, ≤ 4 targets at melee scale; 领袖 / 鹰眼视觉; module + // S1 强力击·γ型 (attack SP: next attack 205 %/225 %); S2 雪境生存法则 (toggle — [ASSUMED] once switched on it stays + // on for the deployment: smaller range, DEF up, 3.5 %/4 % max HP regen per s) + chess_char_4_22_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const tb = def.traitBb || {}; + const g = grid(def.skill?.rangeGrid); + return { + skills: alt(def, { + skchr_svrash_1: () => ({ kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 2.05) } }), + skchr_svrash_2: () => ({ + kind: 'toggle', + mods: { defPct: num(bb.def), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }, + targeting: g ? { rangeGrid: g } : undefined, + onStart({ battle, unit }) { battle.fx('snow', { x: unit.x, y: unit.y, id: unit.id }); }, + }), + }), + skill: { + kind: 'duration', + mods: { defPct: num(bb.def), atkPct: num(bb.atk) }, + targeting: { ...(g ? { rangeGrid: g } : {}), maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 4))) }, + attack: { projectile: 'none', dmgMul: () => 1 }, // 视为近距离攻击: no ranged ×0.8 + onStart({ battle, unit }) { battle.fx('truesilver', { x: unit.x, y: unit.y, id: unit.id }); }, + onAttack({ battle, unit }) { battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id }); }, + }, + talents: [ + { install(battle, unit) { // 领袖: ATK +10 %; every unit of the team redeploys 10 % faster + battle.addBuff(unit, { key: 'svrash:t1', mods: { atkPct: num(t0.atk, 0.1) }, persist: true, allowDead: true }); + const p = battle.getPlayer(unit.ownerId); + for (const a of p ? p.units : []) if (a.kind === 'op') battle.addBuff(a, { key: 'svrash:leader', mods: { redeployMul: Math.max(0, 1 + num(t0.respawn_time, -0.1)) }, persist: true, allowDead: true }); + } }, + { install(battle, unit) { whileDeployed(battle, unit, AURA, () => reveal(battle, enemiesOnRange(battle, unit))); } }, // 鹰眼视觉 + ], + install(battle, unit) { + const m = num(tb.atk_scale_m, 0); // module (elite): attacks add 10 % ATK arts damage + if (!(m > 0)) return; + battle.on('damaged', (c) => { + if (c.source !== unit || !c.dmg?.isAttack || c.type !== 'phys' || !c.target.alive || c.target.side !== 'enemy') return; + battle.dealDamage(unit, c.target, { amount: unit.s.atk * m, type: 'arts', tags: ['module'] }); + }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_23-gvial2.js b/server/sim/content/kits/ops/chess_char_4_23-gvial2.js new file mode 100644 index 00000000..db7fb71a --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_23-gvial2.js @@ -0,0 +1,78 @@ +// server/sim/content/kits/ops/chess_char_4_23-gvial2.js — 百炼嘉维尔 (char_1026_gvial2) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { + num, tbb, grid, whileDeployed, toggleBuff, skillActive, isSel, alt, pullToFront, withDefaults, +} from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 百炼嘉维尔 (centurion) S3 丛林之魂 — ATK/ASPD/block up, takes 50 % now, the rest as 20 s HP loss; talents; module + // S1 精准痛击 (ATK up, heals herself 30 %/35 % of the damage dealt); S2 链锯强袭 (wider range, ATK/DEF up, pulls + // unblocked enemies it hits to her front); module CEN-Y (好锯多磨): −20 % physical damage taken above 50 % HP + chess_char_4_23_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const tb = def.traitBb || {}; + const dr = Math.max(0, Math.min(0.99, num(bb.damage_resistance, 0.5))); + const fin = num(bb.final_duration, 20), iv = Math.max(1 / 30, num(bb.interval, 0.1)); + const S3 = isSel(def, 'skchr_gvial2_3'); + const g = grid(def.skill?.rangeGrid); + return { + skills: alt(def, { + skchr_gvial2_1: () => ({ + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + onHit({ battle, unit, dealt }) { if (dealt > 0 && unit.alive) battle.heal(unit, unit, dealt * num(bb.heal_scale, 0.3), { self: true }); }, + }), + skchr_gvial2_2: () => ({ + kind: 'duration', + mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, + targeting: g ? { rangeGrid: g } : undefined, + attack: { onEachHit({ battle, unit, target }) { if (target && target.alive && !target.blockedBy) pullToFront(battle, unit, target, num(bb['attack@force'], 1)); } }, + onStart({ battle, unit }) { battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); }, + }), + }), + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed), blockCnt: num(bb.block_cnt, 2) }, + onStart({ battle, unit }) { unit.mem.gvialDebt = 0; battle.fx('jungleSoul', { x: unit.x, y: unit.y, id: unit.id }); }, + onEnd({ battle, unit, reason }) { + const debt = unit.mem.gvialDebt ?? 0; + unit.mem.gvialDebt = 0; + if (reason === 'death' || !unit.alive || !(debt > 0)) return; + const per = debt * iv / fin; + battle.addBuff(unit, { key: 'gvial:bleed', refresh: 'independent', maxStacks: 10, duration: fin + 1e-6, interval: iv, visible: true, + onTick: ({ unit: u }) => battle.loseHp(u, per) }); + }, + }, + talents: [ + { install(battle, unit) { // 战地巨斧: ATK/DEF +10 %, +4 % per extra blocked enemy + whileDeployed(battle, unit, 0.1, () => { + const extra = Math.max(0, unit.blocking.length - 1); + toggleBuff(battle, unit, 'gvial:t1', true, { atkPct: num(t0.atk, 0.1) + num(t0.atk_add, 0.04) * extra, defPct: num(t0.def, 0.1) + num(t0.def_add, 0.04) * extra }); + }); + } }, + { install(battle, unit) { // 医学背景: healing received +20 %, +40 % below half HP + battle.on('heal', (c) => { if (c.target === unit) c.amount *= unit.hpRatio < num(t1.hp_ratio, 0.5) ? num(t1.heal_scale_2, 1.4) : num(t1.heal_scale_1, 1.2); }, { owner: unit }); + } }, + ], + install(battle, unit) { + const s = num(tb.atk_scale, 0); // module (elite): ×1.1 against blocked enemies + if (s > 0) battle.on('hit', (c) => { if (c.source === unit && c.dmg.isAttack && c.target.blockedBy === unit) c.dmg.mul *= s; }, { owner: unit }); + const yr = num(tb.hp_ratio, 0), yd = num(tb.damage_resistance, 0); // module CEN-Y: 生命值高于50%时受到的物理伤害降低20% + if (yr > 0 && yd > 0) battle.on('hit', (c) => { if (c.target === unit && c.dmg.type === 'phys' && unit.hpRatio > yr) c.dmg.mul *= 1 - yd; }, { owner: unit }); + if (!S3) return; + const deferred = new WeakSet(); + battle.on('hit', (c) => { + if (c.target !== unit || !skillActive(unit)) return; + c.dmg.mul *= 1 - dr; + deferred.add(c.dmg); + }, { owner: unit, priority: -5 }); + battle.on('damaged', (c) => { + if (c.target !== unit || !deferred.has(c.dmg)) return; + deferred.delete(c.dmg); + if (c.amount > 0) unit.mem.gvialDebt = (unit.mem.gvialDebt ?? 0) + (c.amount * dr) / (1 - dr); + }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_24-billro.js b/server/sim/content/kits/ops/chess_char_4_24-billro.js new file mode 100644 index 00000000..57afbe09 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_24-billro.js @@ -0,0 +1,148 @@ +// server/sim/content/kits/ops/chess_char_4_24-billro.js — 卡涅利安 (char_426_billro) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { + num, tbb, moduleBb, grid, batFlat, enemiesOnRange, whileDeployed, toggleBuff, skillActive, isSel, alt, withDefaults, +} from '../shared/tier4.js'; + +/** 卡涅利安 S3 食噬之印: the ATK bonus climbs in 1 s steps over 20 s (PRTS 备注; the blackboard has no ramp time). */ +const BILLRO_S3_RAMP = 20; +/** 卡涅利安 S3 charged mark: one buff per enemy, shared by every 卡涅利安 (PRTS 备注), ≤ 5 stacks (skill text). */ +const BILLRO_MARK = 'billro:mark'; +const BILLRO_MARK_MAX = 5; + +export default withDefaults({ + // ===== 卡涅利安 (phalanx) S2 沙缚镣锁 — faster attacks on every enemy in range (the trait's 群体法术伤害), 0.3 s + // sluggish on each; charged: ATK +10 % and bind; talents + // S1 沙暴守卫 (SEARCH: ATK/DEF up; charged — cast with every charge stored — "特性效果在技能期间继续生效", PRTS 备注 + // "应用蓄力时:应用技能未开启时的特性": the skill-off trait stays in force, the DEF/RES guard AND 不攻击 — she makes + // no attack until the skill ends, a pure guard); + // S3 食噬之印 (wider range, ATK +0 % → +140 %/+200 % in 1 s steps over 20 s — PRTS 备注; charged: each attack adds a + // stack of the mark BEFORE its damage — PRTS 备注 "于攻击造成伤害前生效,多层效果之间加算叠加" — +20 % damage from + // her per stack, ≤ 5, so ×1.2 on the first hit and ×2.0 from the 5th, until the skill ends; one mark per enemy: + // another 卡涅利安 only adds stacks to it, the bonus is the setter's — PRTS 备注 popup). Talent 生命之餐 heals on + // every skill; module PLX-X keeps part of the guard during any skill, PLX-Y (乡音): +3 % damage per enemy in range + // (≤ 5) + chess_char_4_24_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); + const tb = def.traitBb || {}; + const keepDef = num(tb['billro_e_002[buff].def'], 0), keepRes = num(tb['billro_e_002[buff].magic_resistance'], 0); + const S1 = isSel(def, 'skchr_billro_1'), S2 = isSel(def, 'skchr_billro_2'), S3 = isSel(def, 'skchr_billro_3'); + const g = grid(def.skill?.rangeGrid); + const isCharged = (skill) => skill.maxCharges > 1 && skill.charges + 1 >= skill.maxCharges; // cast with every charge stored + return { + // charged S1: the skill-off trait's 不攻击 stays in force while the skill lasts (PRTS S1 备注) — no attack at all + trait: S1 ? { canAttack: (battle, u) => !(skillActive(u) && u.mem.billroCharged) } : null, + skills: alt(def, { + skchr_billro_1: () => ({ + kind: 'duration', + mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, + onStart({ battle, unit, skill }) { + unit.mem.billroCharged = isCharged(skill); + // 蓄力额外效果:特性效果在技能期间继续生效 (the profession removes its own guard buff when a skill starts; the + // kit trait's canAttack keeps the trait's 不攻击) + if (unit.mem.billroCharged) battle.addBuff(unit, { key: 'billro:s1guard', mods: { defPct: num(unit.profile?.guardDef, 2), resFlat: num(unit.profile?.guardRes, 20) } }); + battle.fx('shell', { x: unit.x, y: unit.y, id: unit.id }); + }, + onEnd({ battle, unit }) { unit.mem.billroCharged = false; battle.removeBuff(unit, 'billro:s1guard'); }, + }), + skchr_billro_3: () => ({ + kind: 'duration', + targeting: g ? { rangeGrid: g } : undefined, + onStart({ battle, unit, skill }) { + unit.mem.billroCharged = isCharged(skill); + unit.mem.billroRamp = 0; + unit.mem.billroMarked = new Set(); + battle.addBuff(unit, { key: 'billro:s3atk', mods: { atkPct: 0 } }); + battle.fx('devour', { x: unit.x, y: unit.y, id: unit.id }); + }, + onTick({ unit, dt }) { // 攻击力逐渐增至+N%: PRTS 备注 "从+0%开始在20秒内线性增加,攻击力每1秒更新1次" + unit.mem.billroRamp = (unit.mem.billroRamp ?? 0) + dt; + const steps = Math.min(BILLRO_S3_RAMP, Math.floor(unit.mem.billroRamp + 1e-9)); + const b = unit.findBuff('billro:s3atk'); + if (b && b.mods.atkPct !== num(bb.atk) * steps / BILLRO_S3_RAMP) { b.mods = { atkPct: num(bb.atk) * steps / BILLRO_S3_RAMP }; unit.markDirty(); } + }, + onEnd({ battle, unit }) { + battle.removeBuff(unit, 'billro:s3atk'); + // "持续至技能结束": the marks she set end with her skill (stacks another 卡涅利安 added to them included) + for (const id of unit.mem.billroMarked || []) { + const e = battle.unitById(id); + if (e && e.findBuff(BILLRO_MARK)?.source === unit) battle.removeBuff(e, BILLRO_MARK); + } + unit.mem.billroMarked = null; + unit.mem.billroCharged = false; + }, + }), + }), + skill: { + kind: 'duration', + mods: { batPct: batFlat(def, bb.base_attack_time) }, + onStart({ battle, unit, skill }) { + const charged = isCharged(skill); + unit.mem.billroCharged = charged; + if (charged) battle.addBuff(unit, { key: 'billro:charged', mods: { atkPct: num(bb.atk, 0.1) } }); + battle.fx(charged ? 'sandChainsCharged' : 'sandChains', { x: unit.x, y: unit.y, id: unit.id }); + }, + onEnd({ battle, unit }) { unit.mem.billroCharged = false; battle.removeBuff(unit, 'billro:charged'); }, + }, + talents: [ + { install(battle, unit) { // 生命之餐: every skill start heals 40 % max HP (×2 when charged) + battle.on('skillStart', (c) => { + if (c.unit !== unit) return; + const charged = isCharged(c.skill); + battle.heal(unit, unit, unit.s.maxHp * (charged ? num(t0['billro_t_1[enhance].heal_scale'], 0.8) : num(t0.heal_scale, 0.4)), { self: true }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 蓄势待发: +0.6 SP/s once a charge is stored + whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'billro:t2', !!unit.skill && unit.skill.charges >= 1 && !skillActive(unit), { spRecoveryFlat: num(t1.sp_recovery_per_sec, 0.6) })); + } }, + ], + install(battle, unit) { + // module PLX-X (elite default): 技能开启时保留部分效果 (any skill; a charged S1 keeps the whole guard instead) + if (keepDef || keepRes) { + battle.on('skillStart', (c) => { + if (c.unit !== unit || (S1 && unit.mem.billroCharged)) return; + battle.addBuff(unit, { key: 'billro:keep', mods: { defPct: keepDef, resFlat: keepRes } }); + }, { owner: unit }); + battle.on('skillEnd', (c) => { if (c.unit === unit) battle.removeBuff(unit, 'billro:keep'); }, { owner: unit }); + } + // module PLX-Y: 范围内敌人越多造成的伤害越高(每个 +3 %,最多 5 个) + const per = num(mb.damage_scale, 0), cap = num(mb.max_valid_stack_cnt, 5); + if (per > 0) { + battle.on('hit', (c) => { + if (c.source !== unit || c.target.side !== 'enemy') return; + const n = Math.min(cap, enemiesOnRange(battle, unit).length); + if (n > 0) c.dmg.mul *= 1 + per * n; + }, { owner: unit }); + } + if (S3) { + // charged S3 食噬之印: each attack adds a stack BEFORE its damage (PRTS 备注 "于攻击造成伤害前生效"); stacks add up, + // +20 % damage taken from the mark's setter per stack. One mark per enemy (PRTS 备注 popup: "存在则为已有的BUFF叠加 + // 一次加成,不存在则给与敌人仅对此卡涅利安的伤害生效的一个食噬之印BUFF"): another 卡涅利安 stacks it without the bonus + const per = num(bb['attack@damage_scale'], 0.2); + battle.on('hit', (c) => { + const e = c.target; + if (c.source !== unit || e.side !== 'enemy') return; + let m = e.findBuff?.(BILLRO_MARK) || null; + if (c.dmg.isAttack && skillActive(unit) && unit.mem.billroCharged) { + if (m) battle.addBuff(e, { key: BILLRO_MARK, refresh: 'stack', maxStacks: BILLRO_MARK_MAX, duration: m.timeLeft }); + else { + m = battle.addBuff(e, { key: BILLRO_MARK, refresh: 'stack', maxStacks: BILLRO_MARK_MAX, duration: Math.max(0.1, (unit.skill?.timeLeft ?? 0) + 0.1), source: unit, visible: true }); + if (m) unit.mem.billroMarked?.add(e.id); + } + } + if (m && m.source === unit) c.dmg.mul *= 1 + per * (m.stacks || 1); + }, { owner: unit }); + } + if (!S2) return; + // "每次攻击对目标造成0.3秒停顿" (charged: 束缚): every enemy her attack damages — each one on her range + battle.on('damaged', (c) => { + const e = c.target; + if (c.source !== unit || !c.dmg || !c.dmg.isAttack || e.side !== 'enemy' || !e.alive || !skillActive(unit)) return; + if (unit.mem.billroCharged) battle.applyStatus(e, 'bind', { duration: num(bb['attack@root'], 0.3), source: unit }); + else battle.applyStatus(e, 'sluggish', { duration: num(bb['attack@sluggish'], 0.3), source: unit }); + }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_25-cetsyr.js b/server/sim/content/kits/ops/chess_char_4_25-cetsyr.js new file mode 100644 index 00000000..9340c177 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_25-cetsyr.js @@ -0,0 +1,150 @@ +// server/sim/content/kits/ops/chess_char_4_25-cetsyr.js — 魔王 (char_4134_cetsyr) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { canTargetEnemy } from '../../../targeting.js'; +import { aggregateMods } from '../../../buffs.js'; +import { + AURA, AURA_DUR, num, tbb, moduleBb, grid, enemyHasTag, whileDeployed, toggleBuff, skillActive, isSel, alt, + withDefaults, +} from '../shared/tier4.js'; + +/** + * 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK (or max HP) on top of the target's own + * percentage multipliers (the flat mod is compensated, so an ATK+% skill does not scale it), the strongest source wins + * (the ATK key `inspire` is shared with tier6's 鼓舞); units flagged `mem.noInspire` (bards: 自身不受鼓舞影响) never + * receive it. Refreshed by an aura: lapses within AURA_DUR once the source stops. + */ +function inspire(battle, target, val, src, stat = 'atk') { + if (!(val > 0) || !target || !target.alive || target.mem?.noInspire) return; + const key = stat === 'hp' ? 'inspire:hp' : 'inspire'; + const cur = target.findBuff(key); + if (cur && cur.data && cur.data.src !== src.id && cur.data.val > val && cur.timeLeft > 1e-6) return; + const { add, mul } = aggregateMods(target.buffs.filter((b) => b.key !== key)); + const f = stat === 'hp' ? Math.max(0, 1 + (add.hpPct ?? 0)) * (mul.hpMul ?? 1) : Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1); + const flat = f > 1e-6 ? val / f : val; + battle.addBuff(target, { key, mods: stat === 'hp' ? { hpFlat: flat } : { atkFlat: flat }, duration: AURA_DUR, source: src, visible: true, data: { src: src.id, val } }); +} + +export default withDefaults({ + // ===== 魔王 (bard) S3 编织重构现世 — wider range, trait 65 %, motes stay, 鼓舞 +65 % of her max HP, HP redistribution + // S1 往昔萦绕身旁 (SP_FULL toggle, 持续时间无限: trait 20 %/28 %, motes respawn in `talent_cool_down` s); S2 明日渺远不及 + // (motes cap +3 and 6 at once, wider orbit — enemies within outside_radius — no longer touch operators: a mote + // hitting an enemy deals 220 %/260 % ATK true damage + 3 s bind and respawns like any mote [ASSUMED]; 鼓舞: other + // allies in range ATK + 65 %/80 % of her ATK) + chess_char_4_25_a: (bb, chess, def) => { + const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); + const g = grid(def.skill?.rangeGrid); + const redistIv = Math.max(0.5, num(bb['attack@cetsyr_s_3[cal_hp_ratio].interval'], 2)); + const S1 = isSel(def, 'skchr_cetsyr_1'), S2 = isSel(def, 'skchr_cetsyr_2'), S3 = isSel(def, 'skchr_cetsyr_3'); + const baseCnt = Math.max(0, Math.floor(num(t0.cnt, 3))); + const MOTE_GAP = 0.5; // [ASSUMED] minimal time between two motes hitting the same enemy (s) + const setTrait = (unit, v) => { unit.mem.bardBase ??= unit.profile.auraRatio; unit.profile.auraRatio = num(v, unit.mem.bardBase); }; + const resetTrait = (unit) => { if (unit.mem.bardBase != null) unit.profile.auraRatio = unit.mem.bardBase; }; + return { + skills: alt(def, { + skchr_cetsyr_1: () => ({ + kind: 'toggle', + trigger: 'SP_FULL', // 自动触发 (no 技能策略: the 吟游者 row is for MANUAL skills): on as soon as it is ready + onStart({ battle, unit }) { setTrait(unit, bb['attack@atk_to_hp_recovery_ratio']); battle.fx('mote', { x: unit.x, y: unit.y, id: unit.id }); }, + onEnd({ unit }) { resetTrait(unit); }, + }), + skchr_cetsyr_2: () => ({ + kind: 'duration', + onStart({ battle, unit }) { + // “微尘”上限+3,立刻获得6枚“微尘” + unit.mem.motes = new Array(baseCnt + 3).fill(-Infinity); + unit.mem.moteHit = new Map(); + battle.fx('reweave', { x: unit.x, y: unit.y, id: unit.id, n: unit.mem.motes.length }); + }, + onTick({ battle, unit }) { // 鼓舞: other allies in range ATK + x % of her ATK + for (const a of battle.alliesInGrid(unit)) if (a !== unit) inspire(battle, a, unit.s.atk * num(bb['attack@atk'], 0.65), unit); + }, + onEnd({ battle, unit }) { + // back to the normal cap: the motes that come back first are kept + unit.mem.motes = (unit.mem.motes || []).slice().sort((a, b) => a - b).slice(0, baseCnt); + unit.mem.moteHit = null; + }, + }), + }), + skill: { + kind: 'duration', + targeting: g ? { rangeGrid: g } : undefined, + onStart({ battle, unit }) { + setTrait(unit, bb['attack@atk_to_hp_recovery_ratio']); + unit.mem.redist = 0; + battle.fx('reweave', { x: unit.x, y: unit.y, id: unit.id }); + }, + onTick({ battle, unit, dt }) { + const others = battle.alliesInGrid(unit).filter((a) => a !== unit); + for (const a of others) inspire(battle, a, unit.s.maxHp * num(bb.max_hp, 0.65), unit, 'hp'); + unit.mem.redist += dt; + if (unit.mem.redist + 1e-9 < redistIv) return; + unit.mem.redist -= redistIv; + const pool = battle.alliesInGrid(unit).filter((a) => !(a.s.flags.noHeal || a.profile?.noHeal) || a === unit); + let hp = 0, max = 0; + for (const a of pool) { hp += a.hp; max += a.s.maxHp; } + if (!(max > 0) || pool.length < 2) return; + const ratio = Math.min(1, hp / max); + for (const a of pool) a.hp = Math.max(1, Math.min(a.s.maxHp, a.s.maxHp * ratio)); + battle.fx('redistribute', { x: unit.x, y: unit.y, id: unit.id, ratio: Math.round(ratio * 1000) / 1000 }); + }, + onEnd({ unit }) { if (unit.mem.bardBase != null) unit.profile.auraRatio = unit.mem.bardBase; }, + }, + talents: [ + { install(battle, unit) { // 过往尘埃: 3 motes; touching an operator ⇒ her trait heal ×1.5 on it for 6 s; mote back after 6 s + unit.mem.motes = new Array(baseCnt).fill(-Infinity); + unit.mem.noInspire = true; // trait: 自身不受鼓舞影响 (another bard's 鼓舞 skips her) + const cooldown = () => (S1 && skillActive(unit) ? num(bb.talent_cool_down, 3) : num(t0.cooldown, 6)); // S1: 重生速度加快 + const takeMote = () => { + const i = unit.mem.motes.findIndex((t) => t <= battle.time + 1e-9); + if (i < 0) return false; + unit.mem.motes[i] = battle.time + cooldown(); + return true; + }; + whileDeployed(battle, unit, 0.25, () => { + if (S2 && skillActive(unit)) { + // S2: motes orbit wider and hit enemies instead of operators + const hitAt = unit.mem.moteHit || (unit.mem.moteHit = new Map()); + const foes = battle.foesInRadius(unit.x, unit.y, num(bb.outside_radius, 2)).filter((e) => canTargetEnemy(unit, e, { canHitFly: true })) + .sort((a, b) => a.id - b.id); + for (const e of foes) { + if ((hitAt.get(e.id) ?? -Infinity) > battle.time + 1e-9) continue; + if (!takeMote()) break; + hitAt.set(e.id, battle.time + MOTE_GAP); + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 2.2), type: 'true', isSkill: true, tags: ['skill', 'mote'] }); + if (e.alive) battle.applyStatus(e, 'bind', { duration: num(bb.unmoveable_duration, 3), source: unit }); + battle.fx('mote', { x: e.x, y: e.y, id: e.id }); + } + return; + } + const keep = S3 && skillActive(unit); // S3: “微尘”不再消失 + for (const a of battle.alliesInRadius(unit.x, unit.y, num(t0.range_radius, 1.15), unit.ownerId)) { + if (a === unit || a.kind !== 'op' || a.findBuff(`cetsyr:mote:${unit.id}`)) continue; + if (!keep && !takeMote()) break; + battle.addBuff(a, { key: `cetsyr:mote:${unit.id}`, duration: num(t0.talent_duration, 6), visible: true, source: unit }); + battle.fx('mote', { x: a.x, y: a.y, id: a.id }); + } + }); + battle.on('heal', (c) => { + if (c.source === unit && c.opts?.aura && c.target.findBuff(`cetsyr:mote:${unit.id}`)) c.amount *= num(t0['attack@trait_mul'], 1.5); + }, { owner: unit }); + } }, + { install(battle, unit) { // 魔王残响: every ally takes −10 % damage from 萨卡兹 enemies (while she is in the squad) + battle.on('hit', (c) => { + if (c.target.side === 'ally' && c.target.ownerId === unit.ownerId && enemyHasTag(c.source, 'sarkaz')) c.dmg.mul *= 1 - num(t1.damage_resistance, 0.1); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + const a = num(mb.atk, 0), cnt = num(mb.cnt, 2); // module (elite): ≥ 2 other operators in her normal range ⇒ ATK +8 % + if (!a) return; + whileDeployed(battle, unit, AURA, () => { + const set = new Set(unit.baseRangeKeys || []); + const n = battle.allies(unit.ownerId).filter((o) => o !== unit && o.kind === 'op' && set.has(o.tileR * COLS + o.tileC)).length; + toggleBuff(battle, unit, 'cetsyr:module', n >= cnt, { atkPct: a }); + }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_4_26-bldsk.js b/server/sim/content/kits/ops/chess_char_4_26-bldsk.js new file mode 100644 index 00000000..34305991 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_4_26-bldsk.js @@ -0,0 +1,66 @@ +// server/sim/content/kits/ops/chess_char_4_26-bldsk.js — 华法琳 (char_171_bldsk) kit, tier 4. +// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md. + +import { bodyInKeys } from '../../../body.js'; +import { num, tbb, keySet, isSel, alt, installLowHpHealBonus, withDefaults } from '../shared/tier4.js'; + +export default withDefaults({ + // ===== 华法琳 (physician) S1 紧急包扎 — charges: a heal on an ally below 50 % adds 15 % of its max HP; 血液样本回收 + // S2 不稳定血浆 (she and one random other ally in range: ATK +45 %/+60 %, lose 3 % max HP per s, 15 s) + chess_char_4_26_a: (bb, chess, def) => { + const t0 = tbb(def, 0); + const tb = def.traitBb || {}; + const S1 = isSel(def, 'skchr_bldsk_1'); + const plasma = (battle, unit, a) => battle.addBuff(a, { + key: 'bldsk:plasma', duration: num(bb.duration, 15) + 1e-6, interval: Math.max(0.1, num(bb.interval, 1)), mods: { atkPct: num(bb.atk) }, visible: true, source: unit, + onTick: ({ unit: t }) => battle.loseHp(t, t.s.maxHp * num(bb.hp_ratio, 0.03), { tags: ['skill', 'plasma'] }), // (no source: not damage dealt) + }); + return { + skills: alt(def, { + skchr_bldsk_2: () => ({ + kind: 'instant', heal: true, + onStart({ battle, unit }) { + plasma(battle, unit, unit); + const pick = battle.rng.pick(battle.alliesInGrid(unit).filter((a) => a !== unit)); + if (pick) plasma(battle, unit, pick); + battle.fx('bloodBattle', { x: unit.x, y: unit.y, id: unit.id, target: pick ? pick.id : null }); + }, + }), + }), + skill: { + kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', + heal: true, + trigger: { rule: 'CUSTOM_RANGE', grid: [] }, // never auto-cast by the engine: fired by the kit on a < 50 % heal target + }, + talents: [{ install(battle, unit) { // 血液样本回收: an enemy falls in range ⇒ +2 SP to her and a random ally in range + battle.on('death', (c) => { + const e = c.unit; + if (c.reason !== 'killed' || e.side !== 'enemy' || !unit.alive || !unit.deployed || !bodyInKeys(e, keySet(unit))) return; + unit.skill?.gainSp(num(t0['bldsk_t_1[self].sp'], 2), 'talent'); + // SP cannot be gained while a timed skill runs (engine gainSp ignores it): such allies are not picked + const pick = battle.rng.pick(battle.alliesInGrid(unit).filter((a) => a !== unit && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive' && !(a.skill.active && a.skill.isTimed))); + if (pick) { pick.skill.gainSp(num(t0['bldsk_t_1[rand].sp'], 2), 'talent'); battle.fx('spGift', { x: pick.x, y: pick.y, id: pick.id }); } + }, { owner: unit }); + } }], + install(battle, unit) { + installLowHpHealBonus(battle, unit, tb); + if (!S1) return; + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit) return; + const t = c.targets[0]; + const sk = unit.skill; + if (!t || t.side !== 'ally' || t.hpRatio >= 0.5 || !sk || !sk.ready || unit.s.flags.silence) return; + if (sk.activate('DEFAULT')) unit.mem.bandage = t; + }, { owner: unit, priority: 5 }); + battle.on('attack', (c) => { + if (c.attacker !== unit || !unit.mem.bandage) return; + const t = unit.mem.bandage; + unit.mem.bandage = null; + unit.mem.skipSp = true; // the skill heal recovers no attack SP (AK) + if (t.alive) { battle.heal(unit, t, t.s.maxHp * num(bb.hp_ratio, 0.15)); battle.fx('bandage', { x: t.x, y: t.y, id: t.id }); } + }, { owner: unit }); + battle.on('spGain', (c) => { if (c.unit === unit && c.reason === 'attack' && unit.mem.skipSp) { unit.mem.skipSp = false; c.amount = 0; } }, { owner: unit }); + }, + }; + }, +}); diff --git a/server/sim/content/kits/ops/chess_char_5_01-excu2.js b/server/sim/content/kits/ops/chess_char_5_01-excu2.js new file mode 100644 index 00000000..a23f37ce --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_01-excu2.js @@ -0,0 +1,124 @@ +// server/sim/content/kits/ops/chess_char_5_01-excu2.js — 圣约送葬人 (char_1032_excu2) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { + num, on, talent, batPct, mods, inFaction, isOp, selectedId, lazySkills, skillRange, crowdAspd, +} from '../shared/tier5.js'; + +const isLaterano = (u) => inFaction(u, 'lateranoShip', ['laterano']); + +/** Enemy `e` attacks `t` in melee (not a ranged shot), mirroring ai.js enemyAttack. */ +function meleeAttacker(e, t) { + const melee = e.profile?.melee ?? e.def?.applyWay === 'MELEE'; + const r = num(e.base?.rangeRadius, 0); + return melee || !(r > 0) || (e.blockedBy === t && r < 1); +} + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 圣约送葬人 — S2 近身铳斗 (ammo, attack SP): ATK/DEF +, block +1, melee hits dodged with prob (+ammo refill). + // T1 受选之人: extra attack chance (+prob_add per ammo spent in the skill). T2 铳弹共感: +ammo per Laterano op. + // S1 遗嘱执行 (ammo 8, attack SP): skill range, ATK +, ignores def_penetrate_fixed DEF. + // S3 圣约决裁 (ammo 16): skill range, BAT +0.5 s, ATK +; +attack@atk ATK per bullet spent (≤ attack@max_stack_cnt), + // reaper self-heal ×trait_ratio; at the end every enemy attacked during the skill takes attack@final_atk_scale × ATK phys. + // Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range. + chess_char_5_01_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); + const sid = selectedId(chess, def); + const prob = num(bb.prob), refill = num(bb.recover_cnt, 1); + return { + skills: lazySkills({ + skchr_excu2_1: () => ({ + kind: 'ammo', ammo: num(bb['attack@trigger_time'], 8), + mods: mods({ atkPct: num(bb.atk), defIgnoreFlat: num(bb.def_penetrate_fixed) }), + targeting: skillRange(chess, def), + }), + skchr_excu2_3: () => ({ + kind: 'ammo', ammo: num(bb['attack@trigger_time'], 16), + mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), + targeting: skillRange(chess, def), + attack: { onEachHit({ unit, target }) { if (target && target.side === 'enemy') unit.mem.verdict?.add(target); } }, + onStart({ unit }) { + unit.mem.verdict = new Set(); + // 特性的回复生命效果提高至2倍 (the reaper profile reads profile.selfHeal on every attack) + if (unit.profile) { unit.mem.verdictHeal0 ??= num(unit.profile.selfHeal, 50); unit.profile.selfHeal = unit.mem.verdictHeal0 * num(bb.trait_ratio, 1); } + }, + onEnd({ battle, unit }) { + const hit = unit.mem.verdict; + unit.mem.verdict = null; + if (unit.profile && unit.mem.verdictHeal0 != null) unit.profile.selfHeal = unit.mem.verdictHeal0; + if (hit && unit.alive) { + for (const e of hit) { + if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@final_atk_scale']), type: 'phys', isSkill: true, tags: ['skill', 'verdict'] }); + } + battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1.5, skill: 'excu2' }); + } + battle.removeBuff(unit, 'excu2:verdict'); + }, + }), + }), + skill: { + kind: 'ammo', ammo: num(bb['attack@trigger_time'], 12), + mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt) }), + }, + // REA-Y: its trait part is DISPLAY-only (official battle_equip_table uniequip_003_excu2: target DISPLAY, the ASPD + // text "{value}" = 12; the ASPD rider itself is the talent −1 part). data/chess.json merges that display blackboard + // into trait.bb ({value: 12}), which the reaper profile would read as its per-hit heal: the heal stays the base + // trait's (50). + trait: tm['trigger_cnt[equip]'] != null && chess?.traitBase?.bb?.value != null ? { selfHeal: num(chess.traitBase.bb.value, 50) } : undefined, + talents: [ + { install(battle, unit) { // 受选之人 + let spent = 0; + const reset = (c) => { if (c.unit === unit) spent = 0; }; + battle.on('skillStart', reset, { owner: unit }); + battle.on('skillEnd', reset, { owner: unit }); + battle.on('ammoUsed', (c) => { if (c.unit === unit) spent++; }, { owner: unit }); + // PRTS 备注: "触发时可额外回复1点攻击回复技力,不额外消耗弹药" — the extra attack is an attack (attack SP, trait + // heal) but consumes no ammo (engine forceAttack `noAmmo`: no bullet, no ammoUsed for any listener — the + // 7-bullet 特质 counter, items, bonds and the prob_add counter above) + battle.on('attack', (c) => { + if (c.attacker !== unit || unit.mem.extraAttack || !on(unit)) return; + const p = num(t0.prob) + (unit.skill?.active ? num(t0.prob_add) * spent : 0); + if (!(p > 0) || !battle.rng.chance(Math.min(1, p))) return; + const dep = unit.deploySeq; + battle.after(0, () => { + if (!on(unit) || unit.deploySeq !== dep || !unit.canAct || unit.s.flags.disarm) return; + unit.mem.extraAttack = true; + let hit = false; + try { hit = battle.forceAttack(unit, null, { noAmmo: true }); } finally { unit.mem.extraAttack = false; } + if (hit) battle.fx('extraAttack', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 铳弹共感 + battle.on('skillStart', (c) => { + if (c.unit !== unit || c.skill.kind !== 'ammo') return; + const n = battle.allies().filter((a) => isOp(a) && isLaterano(a)).length; + const stacks = Math.min(num(t1.add_count_max_stack, 4), n); + if (stacks > 0) c.skill.addAmmo(stacks * num(t1.add_count, 1)); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + crowdAspd(battle, unit, 'excu2:module', num(tm.attack_speed), num(tm['trigger_cnt[equip]'])); + if (sid === 'skchr_excu2_3') { // S3: +attack@atk ATK per bullet spent + battle.on('ammoUsed', (c) => { + if (c.unit !== unit || !unit.skill?.active) return; + battle.addBuff(unit, { key: 'excu2:verdict', refresh: 'stack', maxStacks: Math.max(1, num(bb['attack@max_stack_cnt'], 30)), mods: mods({ atkPct: num(bb['attack@atk']) }) }); + }, { owner: unit }); + } + if (sid && sid !== 'skchr_excu2_2') return; + // S2: melee attacks dodged with `prob`, each dodge refills ammo + battle.on('hit', (c) => { + if (c.target !== unit || !unit.skill?.active || !c.source || c.source.side !== 'enemy' || !c.dmg.isAttack) return; + if (!meleeAttacker(c.source, unit) || !(prob > 0) || !battle.rng.chance(prob)) return; + c.dmg.cancel = true; + battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); + // a dodge like the engine's (listeners of the `dodge` hook — items/bonds — see it too) + if (battle.hasHook('dodge')) battle.emit('dodge', { source: c.source, target: unit, dmg: c.dmg }); + unit.skill.addAmmo(refill); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_02-titi.js b/server/sim/content/kits/ops/chess_char_5_02-titi.js new file mode 100644 index 00000000..058c0009 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_02-titi.js @@ -0,0 +1,177 @@ +// server/sim/content/kits/ops/chess_char_5_02-titi.js — 缇缇 (char_4056_titi) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { + AURA_IV, AURA_DUR, RING1, num, on, talent, mods, dist, inFaction, isOp, selectedId, lazySkills, whileOn, +} from '../shared/tier5.js'; + +const isSargonMinos = (u) => inFaction(u, 'sargonShip', ['sargon', 'minos']); + +/** Enemy not moving along its route (blocked, stunned, rooted, waiting…). */ +const isStill = (e) => !!(e.blockedBy || !e.moving || e.s.flags.stun || e.s.flags.noMove || !(e.s.moveSpeed > 0)); + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 缇缇 — S3 旧日绽放 (25 s): ATK +, 2 targets, sleeps non-sleeping targets; during the skill every enemy waking up (or + // knocked out asleep) takes arts damage growing with the sleep time (linear min_atk_scale → max_atk_scale over `sleep` s, + // PRTS 备注) and sends the highest-aggro enemy within range_radius to sleep; other allies in range fall asleep (小睡) + // instead of dying. T1 凝固的时光: can attack sleeping enemies, +15 % ATK arts vs non-moving + // enemies, sleeping enemies take 30 % ATK arts/s. T2 勇气的报偿: Sargon/Minos ops above 50 % HP get +20 ASPD. + // S1 缓蚀 (duration): ATK +, attack@prob chance per attack to sleep the target attack@sleep s. + // S2 封护 (duration): no attacks, ATK +; she and the lowest-HP-ratio operator in her range sleep until the skill ends + // (沉睡 = invulnerable), enemies around either of them keep falling asleep; T1 ×talent_scale. + chess_char_5_02_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1); + const sid = selectedId(chess, def); + const hitSleep = num(bb['attack@sleep'], num(bb.sleep, 5)); + const minS = num(bb.min_atk_scale, 1), maxS = num(bb.max_atk_scale, minS); + const chainSleep = num(bb.sleep, hitSleep), radius = num(bb.range_radius, 1.5), chainN = Math.max(1, num(bb.max_target, 1)); + // S2 封护: "第一天赋效果提升至N倍" while it runs + const t0Scale = (unit) => (sid === 'skchr_titi_2' && unit.skill?.active ? num(bb.talent_scale, 1) : 1); + const ward = (battle, unit, a) => { + const entered = !a.buffs.some((b) => (b.status ?? b.key) === 'sleep'); + battle.addBuff(a, { key: 'titi:ward', flags: { sleep: true }, visible: true, status: 'sleep', data: { src: unit } }); + battle.releaseBlocked(a); + if (battle.hasHook('statusApplied')) battle.emit('statusApplied', { source: unit, target: a, status: 'sleep', duration: Infinity, entered }); + }; + return { + skills: lazySkills({ + skchr_titi_1: () => ({ + kind: 'duration', mods: mods({ atkPct: num(bb.atk) }), + attack: { + onHit({ battle, unit, target }) { + if (!target || !target.alive || target.s.flags.sleep || !battle.rng.chance(num(bb['attack@prob']))) return; + battle.applyStatus(target, 'sleep', { duration: num(bb['attack@sleep']), source: unit }); + }, + }, + }), + skchr_titi_2: () => ({ + kind: 'duration', mods: mods({ atkPct: num(bb.atk) }), + attack: { noAttack: true }, + onStart({ battle, unit }) { + const pick = battle.alliesInGrid(unit).filter((a) => a !== unit && isOp(a) && a.hp > 0) + .sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0] ?? null; + unit.mem.titiWard = pick ? [unit, pick] : [unit]; + for (const a of unit.mem.titiWard) ward(battle, unit, a); + unit.mem.titiWardAcc = AURA_IV; + battle.fx('sleepGuard', { x: unit.x, y: unit.y, id: unit.id }); + }, + onTick({ battle, unit, dt }) { + unit.mem.titiWardAcc += dt; + if (unit.mem.titiWardAcc < AURA_IV - 1e-9) return; + unit.mem.titiWardAcc = 0; + for (const a of unit.mem.titiWard || []) { + if (!a.alive || !a.deployed || !a.findBuff('titi:ward')) continue; + for (const e of battle.foesInRadius(a.x, a.y, RING1)) if (e.alive) battle.applyStatus(e, 'sleep', { duration: AURA_DUR, source: unit }); + } + }, + onEnd({ battle, unit }) { + for (const a of unit.mem.titiWard || []) { + const b = a.findBuff('titi:ward'); + if (b && b.data.src === unit) battle.removeBuff(a, b); + } + unit.mem.titiWard = null; + }, + }), + }), + trait: { hitSleep: true }, // 凝固的时光 (a): she targets and damages sleeping enemies (沉睡 = 无敌 for everyone else) + skill: { + kind: 'duration', + mods: mods({ atkPct: num(bb.atk) }), + attack: { + maxTargets: Math.max(1, num(bb['attack@max_target'], 2)), + onHit({ battle, unit, target }) { + if (!target || !target.alive || target.s.flags.sleep) return; + battle.applyStatus(target, 'sleep', { duration: hitSleep, source: unit }); + }, + }, + onEnd({ battle, unit }) { + for (const a of battle.allyUnits) { + const b = a.findBuff('titi:allySleep'); + if (b && b.data.src === unit) battle.removeBuff(a, b); + } + }, + }, + talents: [ + { install(battle, unit) { // 凝固的时光 — (a) is the `hitSleep` trait above + // (b) +extra_atk_scale arts vs non-moving enemies + battle.on('damaged', (c) => { + if (c.source !== unit || !c.dmg?.isAttack || c.dmg.isSplash || c.type === 'element') return; + const e = c.target; + if (e.side !== 'enemy' || !e.alive || !isStill(e)) return; + battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.extra_atk_scale) * t0Scale(unit), type: 'arts', tags: ['talent', 'titiStill'] }); + }, { owner: unit }); + // (c) sleeping enemies take damage_atk_scale × ATK arts per second + whileOn(battle, unit, 1, () => { + const amount = unit.s.atk * num(t0.damage_atk_scale) * t0Scale(unit); + for (const e of battle.enemies) { + if (e.alive && !e.hidden && e.s.flags.sleep) battle.dealDamage(unit, e, { amount, type: 'arts', tags: ['talent', 'titiDream'] }); + } + }); + } }, + { install(battle, unit) { // 勇气的报偿: 精力充沛 + whileOn(battle, unit, AURA_IV, () => { + for (const a of battle.allies()) { + if (isOp(a) && isSargonMinos(a) && a.hpRatio > num(t1.hp_ratio, 0.5)) battle.addBuff(a, { key: 'titi:vigor', duration: AURA_DUR, mods: { aspd: num(t1.attack_speed) } }); + } + }); + } }, + ], + install(battle, unit) { + if (sid && sid !== 'skchr_titi_3') return; + // S3: wake-up burst chain (sleep applied by anyone counts) + const asleep = new Map(); + // PRTS 备注: "向1.5半径内除该单位外仇恨值最高的敌方单位施加沉睡" — highest aggro (taunt) first, then the nearest + const sleepOthers = (from) => { + const cands = battle.foesInRadius(from.x, from.y, radius).filter((o) => o !== from && !o.s.flags.sleep && !o.mem?.candleOwner) + .sort((a, b) => (b.s.taunt || 0) - (a.s.taunt || 0) || dist(a, from) - dist(b, from) || a.spawnSeq - b.spawnSeq); + let n = 0; + for (const o of cands) { + if (n >= chainN) break; + if (battle.applyStatus(o, 'sleep', { duration: chainSleep, source: unit })) n++; + } + }; + battle.on('statusApplied', (c) => { + if (c.status === 'sleep' && c.target.side === 'enemy' && !asleep.has(c.target)) asleep.set(c.target, battle.time); + }, { owner: unit }); + battle.on('tick', () => { + if (!asleep.size) return; + for (const [e, t0s] of asleep) { + if (!e.alive) { asleep.delete(e); continue; } + if (e.s.flags.sleep) continue; + asleep.delete(e); + if (!unit.skill?.active || !on(unit)) continue; + const scale = minS + (maxS - minS) * Math.min(1, (battle.time - t0s) / Math.max(0.1, chainSleep)); + battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'titiWake'] }); + battle.fx('wake', { x: e.x, y: e.y, id: e.id, scale: Math.round(scale * 100) / 100 }); + sleepOthers(e); + } + }, { owner: unit }); + battle.on('kill', (c) => { + const v = c.victim; + if (v.side !== 'enemy' || !asleep.has(v)) return; + asleep.delete(v); + if (unit.skill?.active && on(unit)) sleepOthers(v); + }, { owner: unit }); + // S3: other allies in range fall asleep instead of dying, until healed to full or the skill ends + battle.on('fatal', (c) => { + const a = c.unit; + if (c.prevented || a.side !== 'ally' || a === unit) return; + const guard = a.findBuff('titi:allySleep'); + if (guard) { if (guard.data.src === unit) c.prevented = true; return; } + if (!isOp(a) || !unit.skill?.active || !on(unit) || !unit.rangeKeySet?.has(a.tileR * COLS + a.tileC)) return; + c.prevented = true; + const entered = !a.buffs.some((b) => (b.status ?? b.key) === 'sleep'); + battle.addBuff(a, { + key: 'titi:allySleep', flags: { sleep: true }, visible: true, status: 'sleep', data: { src: unit }, interval: 0.2, + onTick: ({ unit: x, buff }) => { if (x.hp >= x.s.maxHp - 1e-6) battle.removeBuff(x, buff); }, + }); + // other content (特质 "干员进入沉睡时") observes it like any sleep status + if (battle.hasHook('statusApplied')) battle.emit('statusApplied', { source: unit, target: a, status: 'sleep', duration: Infinity, entered }); + battle.fx('sleepGuard', { x: a.x, y: a.y, id: a.id }); + }, { owner: unit, priority: 5 }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_03-blaze2.js b/server/sim/content/kits/ops/chess_char_5_03-blaze2.js new file mode 100644 index 00000000..4b463678 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_03-blaze2.js @@ -0,0 +1,166 @@ +// server/sim/content/kits/ops/chess_char_5_03-blaze2.js — 烛煌 (char_1040_blaze2) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { bodyInKeys } from '../../../body.js'; +import { + AURA_IV, AURA_DUR, num, on, talent, traitBb, skillGrid, batPct, mods, isOp, selectedId, lazySkills, instantKind, + skillRange, burstSpUp, elementHit, whileOn, burstDamageUp, +} from '../shared/tier5.js'; + +/** 烛煌 绝处重燃 "使附近的敌人晕眩" radius (tiles). PRTS 备注: 1.7. */ +const NEARBY_RADIUS = 1.7; +/** 烛煌 S3 "攻击变为群体攻击": splash radius around the target (tiles). PRTS 技能3 备注 "攻击溅射半径1.7". */ +const BLAZE_S3_SPLASH = 1.7; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 烛煌 — S3 众恶的焚场 (ammo): skill range 4-11 (the targets; the DEFAULT cast still needs an enemy in 3-1), ATK +, + // BAT −1.3 s, "攻击变为群体攻击" = one target + a BLAZE_S3_SPLASH (1.7) splash around it (PRTS 备注 "攻击溅射半径1.7", + // 中点判定 — so the fire reaches past the diamond; it used to hit every enemy inside it instead, community report E1 + // after 0.1.0), +attack@atk_scale ATK elemental damage to every enemy of the attack in a burn burst (main and splash), + // dealt BEFORE the attack's own damage ("于攻击造成伤害前判定元素爆发并造成元素伤害": a 'hit' hook — so a hit that kills + // or starts the burst keeps / does not get it); loses 3 % max HP/s; any burn burst on the field refills ammo_recover + // bullets, never above the skill's ammo ("补充后的弹药数量无法超过上限"). Each landed bolt shows its splash (fx 'splash' r 1.7). + // T1 熔点引爆: burn burst anywhere → 350 % ATK elemental damage to it + heal 12 % max HP. T2 绝处重燃: downed instead of + // dying (6000 shield, no attack, no heal, 3 %/s regen) → revives at full HP and stuns nearby enemies. + // Module (elite): ×damage_scale vs enemies in an element burst. + // S1 炙手之援 (instant): the operator with the highest max HP in her range carries a fire aura for max_duration s — + // every `interval` s the enemies within range_radius of it take atk_scale × her ATK arts + element_multiplier × that + // damage as 灼燃损伤. S2 沸血燎原 (duration): skill range, BAT +0.9 s, ATK +, 3 targets; each attack costs + // attack@hp_ratio of her max HP and leaves a burning tile under every target: ground enemies on it move −50 % and take + // atk_scale × ATK arts + element_damage_scale × that as 灼燃损伤 per second (the tiles burn until the skill ends + // [ASSUMED: no duration in the data]). Module PRI-Y (elite): SP +0.2/s while an enemy in range is in an element burst. + chess_char_5_03_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1); + const sid = selectedId(chess, def); + const lose = num(bb.lose_hp_scale); + const burnTiles = (battle, unit) => { + const tiles = unit.mem.blazeTiles; + if (!tiles || !tiles.size) return; + unit.mem.blazeAcc2 = (unit.mem.blazeAcc2 ?? 0) + AURA_IV; + const dmgTick = unit.mem.blazeAcc2 >= 1 - 1e-9; + if (dmgTick) unit.mem.blazeAcc2 -= 1; + for (const e of battle.enemies) { + if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, tiles)) continue; + battle.addBuff(e, { key: `blaze2:ground:${unit.id}`, duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + num(bb.move_speed)) } }); + if (!dmgTick) continue; + const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeGround'] }); + if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_damage_scale), tags: ['skill', 'blazeGround'] }); + } + }; + return { + skills: lazySkills({ + skchr_blaze2_1: () => ({ + kind: instantKind(chess, def), + onStart({ battle, unit }) { + const t = battle.alliesInGrid(unit).filter((a) => isOp(a)).sort((a, b) => b.s.maxHp - a.s.maxHp || a.id - b.id)[0]; + if (!t) return; + const r = num(bb.range_radius, 1.5); + battle.addBuff(t, { + key: `blaze2:aid:${unit.id}`, duration: num(bb.max_duration, 20), interval: Math.max(0.1, num(bb.interval, 1)), visible: true, data: { src: unit }, + onTick: ({ unit: x }) => { + for (const e of battle.foesInRadius(x.x, x.y, r)) { + const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeAid'] }); + if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_multiplier), tags: ['skill', 'blazeAid'] }); + } + }, + }); + battle.fx('aoe', { x: t.x, y: t.y, id: unit.id, r, skill: 'blaze2Aid' }); + }, + }), + skchr_blaze2_2: () => ({ + kind: 'duration', + mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), + targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 3)) }), + attack: { + onEachHit({ unit, target }) { + if (target && target.side === 'enemy') unit.mem.blazeTiles?.add(Math.round(target.y) * COLS + Math.round(target.x)); + }, + }, + onStart({ unit }) { unit.mem.blazeTiles = new Set(); unit.mem.blazeAcc2 = 0; }, + onAttack({ battle, unit }) { battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio']), { source: unit }); }, + onEnd({ unit }) { unit.mem.blazeTiles = null; }, + }), + }), + skill: { + kind: 'ammo', ammo: num(bb['attack@trigger_time'], 18), + mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + attack: { + splashRadius: BLAZE_S3_SPLASH, + // the fire of each landed bolt (the screen rings splash attacks by sub-profession; 本源术师 is not one): once + // per attack at the main target / its spot — from the profile it was fired with, so the last bolt too + onHit({ battle, unit, target, x, y }) { + battle.fx('splash', { x, y, ...(target && target.alive ? { id: target.id } : {}), src: unit.id, r: BLAZE_S3_SPLASH, element: 'burn' }); + }, + }, + onStart({ unit }) { unit.mem.blazeAcc = 0; }, + onTick({ battle, unit, dt }) { + if (!(lose > 0)) return; + unit.mem.blazeAcc = (unit.mem.blazeAcc ?? 0) + dt; + while (unit.mem.blazeAcc >= 1 && unit.alive) { + unit.mem.blazeAcc -= 1; + battle.loseHp(unit, unit.s.maxHp * lose, { source: unit }); + } + }, + }, + talents: [ + { install(battle, unit) { // 熔点引爆 + battle.on('elementBurst', (c) => { + if (c.element !== 'burn' || !on(unit) || c.target.side !== 'enemy') return; + elementHit(battle, unit, c.target, unit.s.atk * num(t0.ep_damage_scale), 'blazeMelt', 'burn'); + // 绝处重燃: "倒地期间…无法被治疗" — the meltdown heal does not reach her while downed + if (!unit.mem.downed) battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio), { self: true }); + battle.fx('meltdown', { x: c.target.x, y: c.target.y, id: c.target.id }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 绝处重燃 + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.downed = false; }, { owner: unit }); + battle.on('fatal', (c) => { + if (c.unit !== unit || c.prevented || unit.mem.downed) return; + c.prevented = true; + unit.mem.downed = true; + if (unit.skill?.active) unit.skill.end('downed'); + battle.releaseBlocked(unit); + const dep = unit.deploySeq; + battle.addBuff(unit, { + key: 'blaze2:downed', visible: true, shield: num(t1.dynamic), interval: 0.2, + flags: { disarm: true, noHeal: true }, + mods: mods({ hpRegenRatio: num(t1.hp_recovery_per_sec_by_max_hp_ratio), blockCnt: -99 }), + onTick: ({ unit: u, buff }) => { + if (u.deploySeq !== dep || u.hp < u.s.maxHp - 1e-6) return; + battle.removeBuff(u, buff); + u.mem.downed = false; + for (const e of battle.foesInRadius(u.x, u.y, NEARBY_RADIUS)) if (e.alive) battle.applyStatus(e, 'stun', { duration: num(t1.stun), source: u }); + battle.fx('revive', { x: u.x, y: u.y, id: u.id, r: NEARBY_RADIUS }); + }, + }); + battle.fx('downed', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit, priority: -50 }); + } }, + ], + install(battle, unit) { + burstDamageUp(battle, unit, num(tb.damage_scale)); + burstSpUp(battle, unit, 'blaze2:module', num(tm.sp_recovery_per_sec)); + if (sid === 'skchr_blaze2_2') whileOn(battle, unit, AURA_IV, () => burnTiles(battle, unit)); + if (sid && sid !== 'skchr_blaze2_3') return; + // S3: each hit of her skill attack (main and splash) on an enemy in a burn burst deals the bonus first (PRTS 备注). + // Gated on the attack's own isSkill (captured when the bolt was fired), not on the live skill: her bolts land after + // the last bullet's end('ammo') / an early end (downed), and those still carry it. Only S3 runs here (sid gate). + battle.on('hit', (c) => { + const d = c.dmg, t = c.target; + if (c.source !== unit || !d.isAttack || !d.isSkill || d.cancel) return; + if (t.side === 'enemy' && t.alive && t.findBuff('burnBurst')) elementHit(battle, unit, t, unit.s.atk * num(bb['attack@atk_scale']), 'blazeBurn', 'burn'); + }, { owner: unit }); + const maxAmmo = num(bb['attack@trigger_time'], 18); + battle.on('elementBurst', (c) => { // S3: burn bursts refill ammo, up to the skill's ammo + const sk = unit.skill; + if (c.element !== 'burn' || !sk?.active || sk.kind !== 'ammo' || !on(unit)) return; + const n = Math.min(num(bb.ammo_recover), maxAmmo - sk.ammoLeft); + if (n > 0) sk.addAmmo(n); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_04-bldsk.js b/server/sim/content/kits/ops/chess_char_5_04-bldsk.js new file mode 100644 index 00000000..dc95924e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_04-bldsk.js @@ -0,0 +1,59 @@ +// server/sim/content/kits/ops/chess_char_5_04-bldsk.js — 华法琳 (char_171_bldsk) kit, tier 5 (hidden). +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { HALF_HP, NEVER, num, on, inRange, talent, traitBb, lowHpHealUp } from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 华法琳 — S1 紧急包扎 (charges, attack SP): the next heal on a target below half HP adds hp_ratio × its max HP. + // T1 血液样本回收: an enemy dying in range → +2 SP to herself and to a random ally in range. + // Module (elite): heals on allies below 50 % ×1.15. + chess_char_5_04_a: (bb, chess) => { + const t0 = talent(chess, 0), tb = traitBb(chess); + const ratio = num(bb.hp_ratio); + return { + skill: { + kind: 'charges', heal: true, trigger: NEVER, + onStart({ unit }) { unit.mem.bldskBonus = unit.mem.bldskTarget ?? null; }, + }, + talents: [{ install(battle, unit) { // 血液样本回收 + battle.on('kill', (c) => { + const v = c.victim; + if (v.side !== 'enemy' || !on(unit) || !inRange(unit, v)) return; + unit.skill?.gainSp(num(t0['bldsk_t_1[self].sp']), 'talent'); + const others = battle.alliesInGrid(unit).filter((a) => a !== unit && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive'); + const pick = battle.rng.pick(others); + if (pick) pick.skill.gainSp(num(t0['bldsk_t_1[rand].sp']), 'talent'); + }, { owner: unit }); + } }], + install(battle, unit) { + // the skill only fires for a heal target below half HP: checked right before the heal + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit) return; + unit.mem.bldskNoSp = false; + const sk = unit.skill; + if (!sk || !sk.ready || unit.s.flags.silence) return; + const t = c.targets[0]; + if (!t || t.side !== 'ally' || !(t.hpRatio < HALF_HP)) return; + unit.mem.bldskTarget = t; + // the heal made by the skill recovers no attack SP (engine rule for skill attacks: cost N ⇒ every N+1 heals) + if (sk.activate('DEFAULT')) unit.mem.bldskNoSp = true; + unit.mem.bldskTarget = null; + }, { owner: unit }); + battle.on('spGain', (c) => { + if (c.unit !== unit || c.reason !== 'attack' || !unit.mem.bldskNoSp) return; + unit.mem.bldskNoSp = false; + c.amount = 0; + }, { owner: unit, priority: 100 }); + battle.on('heal', (c) => { // merged into the same heal + if (c.source !== unit || !unit.mem.bldskBonus || c.target !== unit.mem.bldskBonus) return; + unit.mem.bldskBonus = null; + // the bonus is part of the same heal: the healer's and the target's healing multipliers apply to it too + c.amount += c.target.s.maxHp * ratio * num(unit.s.healingDealtMul, 1) * num(c.target.s.healingTakenMul, 1); + battle.fx('healAoe', { x: c.target.x, y: c.target.y, id: c.target.id, r: 0.5 }); + }, { owner: unit, priority: 10 }); + lowHpHealUp(battle, unit, tb); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_05-ulpia.js b/server/sim/content/kits/ops/chess_char_5_05-ulpia.js new file mode 100644 index 00000000..1d7a8a11 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_05-ulpia.js @@ -0,0 +1,146 @@ +// server/sim/content/kits/ops/chess_char_5_05-ulpia.js — 乌尔比安 (char_4145_ulpia) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { frontOf } from '../../../dir.js'; +import { isHpLoss } from '../../../damage.js'; +import { + RING1, num, on, inRange, talent, traitBb, skillGrid, mods, dist, isAbyssal, isOp, selectedId, lazySkills, instantKind, + permBuff, enemiesInGrid, +} from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 乌尔比安 — S3 必须开辟的通路 (25 s, CUSTOM_RANGE row ahead): max HP/ATK +, throws an anchor forward that stops on the + // first enemy or at max distance — on his own tile while he blocks: 135 % ATK phys + 6 s stun around it + // (projectile_range); moves onto the anchor tile when deployable and not his own (a 从不混淆的方向 marker keeps his tile) + // and returns at skill end — both 【移动】, i.e. free redeploys (Battle.moveRedeploy; the return empties his SP). + // T1 本性的坚守: heal 100 (160 below 50 %) on every hit taken. T2 血脉的哺养: per kill +120 max HP / +30 ATK (×9), + // other Abyssal Hunters +50 %. Module (elite): healing received ×1.2. + // S1 必须促成的接触 (instant): the anchor lands on the best enemy of the skill range (beyond his own range, unblocked + // first) and drags up to max_target enemies around it (RING1) in front of him (中等力度), atk_scale × ATK phys each; + // a 捕网 — ground enemies only ([ASSUMED] like 雪雉's "不对空" net). S3's anchor blast has no such note: it hits air + // units too [ASSUMED]. + // S2 必须维系的界限 (toggle, 持续时间无限): T1 ×talent_scale, block +1, max HP +, ATK +. + chess_char_5_05_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); + const sid = selectedId(chess, def); + const t0Scale = sid === 'skchr_ulpia_2' ? num(bb.talent_scale, 1) : 1; + const tokenId = chess?.skill?.overrideTokenKey ?? def?.skill?.raw?.overrideTokenKey ?? 'token_10039_ulpia_block'; + const trig = def?.skill?.trigger?.grid ?? chess?.skill?.trigger?.customRangeGrid ?? [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6]]; + const reach = Math.max(1, ...trig.filter((p) => p[0] === 0).map((p) => p[1])); + const radius = num(bb.projectile_range, 1.5); + return { + skills: lazySkills({ + skchr_ulpia_1: () => ({ + kind: instantKind(chess, def), + onStart({ battle, unit }) { + // "向前方扔出船锚": the anchor goes beyond his own reach first (pulling what he already hits is moot), then + // unblocked ground enemies, then the usual target order + const list = enemiesInGrid(battle, unit, skillGrid(chess, def) ?? unit.rangeGrid); + const reach = (e) => inRange(unit, e); + // the anchor is a 捕网 (PRTS 备注 "实际效果为捕网而非拖拽"); 雪雉's 捕网 is "不对空" (PRTS 雪雉 备注) — [ASSUMED] the same + // for this one: it never lands on or catches air units (FLY, 近地悬浮, 浮空) + const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying); + if (!main) return; + const near = battle.foesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep) + .sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq) + .slice(0, Math.max(1, num(bb.max_target, 2))); + const force = num(bb.force, 1); + battle.fx('anchor', { x: main.x, y: main.y, id: unit.id, fromX: unit.x, fromY: unit.y, r: RING1 }); + for (const e of near) { + // "中等力度地拖拽至面前": the 捕网's pull uses the 拖拽 rules (PRTS 推与拉 §捕网 "力的大小:同拖拽") — + // Battle.pullToFront, the official 力度 − 重量 pull + battle.pullToFront(e, unit, force); + if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchorPull'] }); + } + }, + }), + skchr_ulpia_2: () => ({ + kind: 'toggle', + mods: mods({ atkPct: num(bb.atk), hpPct: num(bb.max_hp), blockCnt: num(bb.block_cnt) }), + }), + }), + skill: { + kind: 'duration', + mods: mods({ hpPct: num(bb.max_hp), atkPct: num(bb.atk) }), + onStart({ battle, unit }) { + // PRTS 备注 ② — the anchor's target: his own tile while he blocks an enemy (e.g. just after a 突袭 landing), + // else the nearest tile ahead in the skill range (straight along his direction) with an enemy on it, else the + // farthest one. His own tile is a candidate only while he blocks ("自身所在地块(仅阻挡敌人时)"); not taken: the + // reading of the range's own tile (6-1 starts at [0,0]) as distance 0 for the second rule (a flyer over him) + let stop = 0; + if (!unit.blocking.some((e) => e.alive && e.blockedBy === unit)) { + for (let d = 1; d <= reach; d++) { + const [r, c] = frontOf(unit.tileR, unit.tileC, unit.dir, d); + // the anchor stops at the field edge and in front of a ground obstacle (crates, roadblocks) + if (!battle.grid.inRect(r, c) || battle.grid.isObstacle(r, c)) break; + stop = d; + if (battle.enemiesInKeys([r * COLS + c], unit, { canHitFly: true }).length) break; + } + } + const [sr, sc] = frontOf(unit.tileR, unit.tileC, unit.dir, stop); + const fromX = unit.x, fromY = unit.y; + battle.fx('anchor', { x: sc, y: sr, id: unit.id, fromX, fromY, r: radius }); + for (const e of battle.foesInRadius(sc, sr, radius)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchor'] }); + if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit }); + } + // ③ 【移动】 — only a change of tile moves him and leaves the 从不混淆的方向 ("若目标地块不为当前地块,会在原地部署"): an + // anchor on his own tile leaves him where he stands, no marker, nothing to return from [ASSUMED: the "tile one + // beyond the landing" is not tried when the landing is his own tile] + if (stop === 0 || !unit.alive) return; + // PRTS 备注: landing tile > the tile one beyond it > his own tile (a deployable, free, unreserved melee tile) + const ok = ([r, c]) => (r !== unit.tileR || c !== unit.tileC) && battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c); + const dest = [[sr, sc], frontOf(unit.tileR, unit.tileC, unit.dir, stop + 1)].find(ok); + if (dest == null) return; + const home = [unit.tileR, unit.tileC]; + // the 【移动】 is a redeploy on the new tile (Battle.moveRedeploy: a new deployment, deploy effects fire again, + // no exit) that keeps the running skill — "【移动】后仅继承下列效果:技能进度、第二天赋叠加层数"; the rest of + // his buffs are kept too (owner's deviation, DESIGN §22.3). The marker is deployed after the move (备注 ③) + if (!battle.moveRedeploy(unit, dest[0], dest[1]) || !unit.alive || !unit.skill?.active) return; + const marker = battle.spawnToken(unit, tokenId, home[0], home[1], { untargetable: true, kit: { skill: null, trait: { noAttack: true } } }); + unit.mem.anchorHome = { r: home[0], c: home[1], marker }; + battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); + }, + onEnd({ battle, unit }) { + const h = unit.mem.anchorHome; + unit.mem.anchorHome = null; + if (!h) return; + if (h.marker && h.marker.alive) battle.retreat(h.marker, { reason: 'expired', permanent: true }); + if (unit.alive && unit.deployed) { + // ④ 【返回】: a 【移动】 back to his tile "【返回】时将清空技力,但仍可以享受后续由其他效果提供的技力" — the SP is + // emptied before the deploy effects of the return run (迅捷作战粮, 黄沙罗盘 … still give theirs, so do skillEnd + // effects after it: 迅捷). Here in onEnd the skill's mods are still on (skills.js end) [ASSUMED: officially the + // return comes "技能结束后"; none of the deploy effects that can reach him reads his stats] + const fromX = unit.x, fromY = unit.y; + if (battle.moveRedeploy(unit, h.r, h.c, { clearSp: true })) battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); + } + }, + }, + talents: [ + { install(battle, unit) { // 本性的坚守 + battle.on('damaged', (c) => { + if (c.target !== unit || !on(unit) || unit.hp <= 0 || !(c.amount > 0) || c.type === 'element' || isHpLoss(c.dmg)) return; // (not a 流失) + const v = (unit.hpRatio < num(t0.hp_ratio, 0.5) ? num(t0.value2) : num(t0.value1)) * (unit.skill?.active ? t0Scale : 1); + if (v > 0) battle.heal(unit, unit, v, { self: true }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 血脉的哺养 + battle.on('kill', (c) => { + if (c.killer !== unit || c.victim.side !== 'enemy' || !on(unit)) return; + battle.addBuff(unit, { key: 'ulpia:blood', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 9)), mods: mods({ hpFlat: num(t1.max_hp), atkFlat: num(t1.atk) }) }); + const share = mods({ hpFlat: num(t1['ulpia_t_1[abyssal].max_hp']), atkFlat: num(t1['ulpia_t_1[abyssal].atk']) }); + if (!Object.keys(share).length) return; + for (const a of battle.allies()) { + if (a !== unit && isOp(a) && isAbyssal(a)) battle.addBuff(a, { key: 'ulpia:bloodShare', refresh: 'stack', maxStacks: Math.max(1, num(t1['ulpia_t_1[abyssal].max_stack_cnt'], 9)), mods: share }); + } + }, { owner: unit }); + } }, + ], + install(battle, unit) { + if (num(tb.heal_scale) > 0) permBuff(battle, unit, 'ulpia:module', { healingTakenMul: num(tb.heal_scale) }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_06-etlchi.js b/server/sim/content/kits/ops/chess_char_5_06-etlchi.js new file mode 100644 index 00000000..7b7bed42 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_06-etlchi.js @@ -0,0 +1,183 @@ +// server/sim/content/kits/ops/chess_char_5_06-etlchi.js — 隐德来希 (char_4010_etlchi) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { mitigate } from '../../../damage.js'; +import { + RING1, num, on, talent, talentRec, traitBb, skillGrid, mods, dist, lazySkills, instantKind, crowdAspd, +} from '../shared/tier5.js'; + +/** 隐德来希 S3 "每次攻击对心烛至少造成35%攻击力的伤害". */ +const CANDLE_MIN_ATK = 0.35; +/** 隐德来希 S3 心烛 enemy key (hidden talent bbStr take_extra_enemy_key; not in data/enemies.json). */ +const CANDLE_KEY = 'enemy_5601_entlec'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 隐德来希 — S3 灵与欲的惜别 (20 s): skill range, ATK +, ASPD +100; summons a 心烛 for up to 3 highest-HP ground enemies + // in range (60 % of their current HP, same DEF/RES); his hits on a candle deal at least 35 % ATK and the original loses + // the same HP; only he can damage candles (other allies' attacks are redirected to the original). + // T1 萃血: each hit steals 75 max HP (≤ 1350) and applies 200 arts/s for 5 s. T2 重盈: once below 25 % HP, heal 50 % + // max HP and take −10 % phys damage afterwards. Reaper trait (heal per hit, module 60) comes from the profession. + // S1 玫影觅迹 (instant, attack SP): next attack atk_scale × ATK, twice. S2 绯红壁合 (duration): no attacks; blood sickles + // on herself and on one other ground unit (the ally with the most enemies around it) cut every enemy around them (RING1) + // for atk_scale × ATK phys every `interval` s — ground enemies only unless the carrier has taken off (PRTS 备注). + // Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range. + chess_char_5_06_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); + const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1); + const nCandles = Math.max(1, num(bb['attack@max_target'], 3)); + const candleKey = talentRec(chess, 2)?.bbStr?.take_extra_enemy_key ?? CANDLE_KEY; + const aroundN = (battle, a) => battle.foesInRadius(a.x, a.y, RING1).length; + return { + skills: lazySkills({ + skchr_etlchi_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), hits: 2 } }), + skchr_etlchi_2: () => ({ + kind: 'duration', attack: { noAttack: true }, + onStart({ battle, unit }) { + const other = battle.alliesFor(unit).filter((a) => a !== unit && a.ground && a.hp > 0) + .sort((a, b) => aroundN(battle, b) - aroundN(battle, a) || dist(a, unit) - dist(b, unit) || a.id - b.id)[0] ?? null; + unit.mem.sickles = other ? [unit, other] : [unit]; + unit.mem.sickleAcc = 0; + for (const a of unit.mem.sickles) battle.fx('aoe', { x: a.x, y: a.y, id: unit.id, r: RING1, skill: 'etlchiSickle' }); + }, + onTick({ battle, unit, dt }) { + const iv = Math.max(0.1, num(bb.interval, 0.5)); + unit.mem.sickleAcc += dt; + while (unit.mem.sickleAcc >= iv - 1e-9) { + unit.mem.sickleAcc -= iv; + for (const a of unit.mem.sickles || []) { + if (!a.alive || !a.deployed) continue; + // PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空); + // 起飞 = an airborne skywalker (蒂比's skills: flag `liftoff`; still a 地面单位, so she can carry one) + const air = !!a.s.flags.liftoff; + for (const e of battle.foesInRadius(a.x, a.y, RING1)) { + if (e.isFlying && !air) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] }); + } + } + } + }, + onEnd({ unit }) { unit.mem.sickles = null; }, + }), + }), + skill: { + kind: 'duration', + mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + onStart({ battle, unit }) { + unit.mem.candles = []; + const picks = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false }) + .filter((e) => !e.isFlying && !e.mem?.candleOwner && !e.isBoss) + .sort((a, b) => b.hp - a.hp || a.spawnSeq - b.spawnSeq).slice(0, nCandles); + for (const e of picks) { + const r = Math.round(e.y), c = Math.round(e.x); + // 心烛 is not in data/enemies.json: an inline record (neutral, never attacks, never moves, not counted) + const def = { + name: '心烛', rank: 'NORMAL', applyWay: 'NONE', motion: 'WALK', notCountInTotal: true, lifePointReduce: 0, + stats: { maxHp: Math.max(1, e.hp * hpScale), atk: 0, def: e.s.def * defScale, magicResistance: e.s.res * resScale, moveSpeed: 0, massLevel: num(e.base.massLevel, 1) }, + }; + const cd = battle.spawnEnemy(candleKey, { + def, pos: [e.y, e.x], route: { motion: 'WALK', start: [r, c], end: [r, c], checkpoints: [] }, + countInTotal: false, ownerPlayerId: e.ownerId, + }); + if (!cd) continue; + cd.name = '心烛'; + Object.assign(cd.base, { maxHp: Math.max(1, e.hp * hpScale), atk: 0, def: e.s.def * defScale, res: e.s.res * resScale, moveSpeed: 0 }); + cd.profile = { ...(cd.profile || {}), noAttack: true }; + cd.markDirty(); + cd.hp = cd.s.maxHp; + cd.mem.candleOwner = unit; + cd.mem.candleOf = e; + cd.mem.noLeak = true; // never a leak at the time limit (Battle._timeout) + battle.addBuff(cd, { key: 'etlchi:candle', flags: { unblockable: true, noMove: true } }); + unit.mem.candles.push(cd); + battle.fx('candle', { x: cd.x, y: cd.y, id: cd.id, of: e.id }); + } + }, + onEnd({ battle, unit }) { + for (const cd of unit.mem.candles || []) if (cd.alive) battle.kill(cd, null); + unit.mem.candles = []; + }, + }, + talents: [ + { install(battle, unit) { // 萃血 + const steal = num(t0['attack@steal_hp']), cap = num(t0['attack@steal_hp_max']); + const dot = num(t0.magic_value), dotDur = num(t0.dot_duration, 5), iv = num(t0.interval, 1); + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.stolen = 0; }, { owner: unit }); + battle.on('damaged', (c) => { + if (c.source !== unit || !c.dmg?.isAttack || c.type === 'element') return; + const e = c.target; + if (e.side !== 'enemy' || !e.alive || e.mem?.candleOwner || !on(unit)) return; + if (dot > 0) { + battle.addBuff(e, { key: `etlchi:dot:${unit.id}`, duration: dotDur, interval: iv, refresh: 'extend', + onTick: ({ unit: x }) => battle.dealDamage(unit, x, { amount: dot, type: 'arts', tags: ['talent', 'dot'] }) }); + } + const stolen = num(unit.mem.stolen); + // (a boss sharing the match HP pool keeps its max HP: the pool is owned by the match) + const n = Math.min(steal, cap - stolen, Math.floor(e.s.maxHp - 1)); // never below 1 max HP + if (n > 0 && !e.bossPool) { + unit.mem.stolen = stolen + n; + // the victim keeps an explicit running total (a 'stack' refresh would rescale every stack by the last n) + const key = `etlchi:steal:${unit.id}`; + const lost = num(e.findBuff(key)?.data?.total) + n; + const hp0 = e.hp; + battle.addBuff(e, { key, mods: { hpFlat: -lost }, data: { total: lost } }); + // 偷取生命上限: the victim's current HP only drops where it exceeds the new maximum (the engine keeps ratios) + if (e.alive) e.hp = Math.min(hp0, e.s.maxHp); + battle.addBuff(unit, { key: 'etlchi:gain', mods: { hpFlat: unit.mem.stolen } }); + } + }, { owner: unit }); + } }, + { install(battle, unit) { // 重盈 + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.reborn = false; }, { owner: unit }); + battle.on('damaged', (c) => { + if (c.target !== unit || unit.mem.reborn || !on(unit) || unit.hp <= 0 || !(unit.hpRatio < num(t1.hp_ratio))) return; + unit.mem.reborn = true; + battle.heal(unit, unit, unit.s.maxHp * num(t1['etlchi_t_2[heal].hp_ratio']), { self: true }); + if (num(t1.damage_resistance) > 0) battle.addBuff(unit, { key: 'etlchi:reborn', mods: { physTakenMul: 1 - num(t1.damage_resistance) } }); + battle.fx('reborn', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + crowdAspd(battle, unit, 'etlchi:module', num(tb.attack_speed), num(tb.cnt)); + const mine = (e) => e && e.mem && e.mem.candleOwner === unit; + // "心烛只受隐德来希攻击的影响": other sources' element gauges never fill a candle either + battle.on('elementHit', (c) => { if (mine(c.target) && c.source !== unit) c.dmg.cancel = true; }, { owner: unit }); + battle.on('beforeStatus', (c) => { if (mine(c.target) && c.source !== unit) c.cancel = true; }, { owner: unit }); + battle.on('hit', (c) => { + if (!mine(c.target)) return; + if (c.source !== unit) { c.dmg.cancel = true; return; } + if (c.dmg.type !== 'phys' && c.dmg.type !== 'arts') return; + const ss = unit.s; + const mit = mitigate(c.dmg.amount, c.dmg.type, c.target.s, { + defIgnorePct: c.dmg.defIgnorePct + ss.defIgnorePct, defIgnoreFlat: c.dmg.defIgnoreFlat + ss.defIgnoreFlat, + resIgnorePct: c.dmg.resIgnorePct + ss.resIgnorePct, resIgnoreFlat: c.dmg.resIgnoreFlat + ss.resIgnoreFlat, + }); + const floor = ss.atk * CANDLE_MIN_ATK; + if (mit < floor) { c.dmg.type = 'true'; c.dmg.amount = floor; c.dmg.canDodge = false; } + }, { owner: unit, priority: -20 }); + battle.on('damaged', (c) => { + if (!mine(c.target) || !(c.amount > 0) || c.type === 'element') return; + const o = c.target.mem.candleOf; + if (o && o.alive) battle.loseHp(o, c.amount, { source: unit }); + }, { owner: unit }); + battle.on('death', (c) => { // the original fell or leaked: its candle goes out + if (c.unit.side !== 'enemy' || c.unit.mem?.candleOwner) return; + for (const cd of unit.mem.candles || []) if (cd.alive && cd.mem.candleOf === c.unit) battle.kill(cd, null); + }, { owner: unit }); + battle.on('beforeAttack', (c) => { // other allies hit the original instead of the candle + if (c.attacker === unit || c.attacker.side !== 'ally' || !(unit.mem.candles && unit.mem.candles.length)) return; + if (!c.targets.some(mine)) return; + const out = []; + for (const t of c.targets) { + if (!mine(t)) { if (!out.includes(t)) out.push(t); continue; } + const o = t.mem.candleOf; + if (o && o.alive && !out.includes(o) && !c.targets.includes(o)) out.push(o); + } + c.targets = out; + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_07-surtr.js b/server/sim/content/kits/ops/chess_char_5_07-surtr.js new file mode 100644 index 00000000..adf8f0f7 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_07-surtr.js @@ -0,0 +1,108 @@ +// server/sim/content/kits/ops/chess_char_5_07-surtr.js — 史尔特尔 (char_350_surtr) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { num, talent, traitBb, mods, selectedId, lazySkills, instantKind, whileOn, permBuff } from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 史尔特尔 — S3 黄昏 (toggle, 持续时间无限): full heal, ATK +, range +2, 3 targets, max HP +5000 (flat), HP loss ramping + // to 20 % max HP/s over 60 s. T1 熔火: ignores 20 RES. T2 余烬: lethal damage keeps HP ≥ 1 for 8 s (不死 + 禁疗), then she + // withdraws. + // Module (elite): ASPD +8 while not blocking. + // S1 烈焰魔剑 (instant, attack SP): next attack atk_scale × ATK; a kill refills all SP at once. + // S2 熔核巨影 (duration): ATK +, range +1, 2 targets; an attack that hits a single enemy is ×critical atk_scale. + // Module AFT-Y (elite): the enemies she blocks are 法术脆弱 +10 %. + chess_char_5_07_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); + const sid = selectedId(chess, def); + const iv = Math.max(0.05, num(bb.interval, 0.2)), peak = num(bb.hp_ratio), ramp = Math.max(0.1, num(bb.duration, 60)); + const maxHp = num(bb.max_hp); + return { + skills: lazySkills({ + skchr_surtr_1: () => ({ + kind: instantKind(chess, def), + attack: { + atkScale: num(bb.atk_scale, 1), + onHit({ unit, target }) { + // 将目标击倒则立即恢复所有技力 (the skill is still pending here: the refill is the next charge) + if (target && target.side === 'enemy' && !target.alive && unit.skill) unit.skill.gainSp(unit.skill.spCost, 'skill'); + }, + }, + }), + skchr_surtr_2: () => ({ + kind: 'duration', mods: mods({ atkPct: num(bb.atk) }), + targeting: mods({ rangeExtend: Math.round(num(bb.ability_range_forward_extend)), maxTargets: num(bb['attack@max_target']) }), + }), + }), + skill: { + kind: 'toggle', + mods: mods({ atkPct: num(bb.atk), ...(Math.abs(maxHp) > 5 ? { hpFlat: maxHp } : { hpPct: maxHp }) }), + targeting: mods({ rangeExtend: Math.round(num(bb.ability_range_forward_extend)), maxTargets: num(bb['attack@max_target']) }), + onStart({ battle, unit }) { + unit.mem.twilightT = 0; + unit.mem.twilightAcc = 0; + // "立即恢复所有生命" — PRTS 技能3 备注 "(无视禁疗)": it reaches her during 余烬 too + battle.heal(unit, unit, unit.s.maxHp, { self: true, ignoreHealFree: true }); + battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'surtr' }); + }, + onTick({ battle, unit, dt }) { + if (!(peak > 0)) return; + unit.mem.twilightT += dt; + unit.mem.twilightAcc += dt; + while (unit.mem.twilightAcc >= iv && unit.alive) { + unit.mem.twilightAcc -= iv; + const rate = peak * Math.min(1, unit.mem.twilightT / ramp); + if (rate > 0) battle.loseHp(unit, unit.s.maxHp * rate * iv, { source: unit, silent: true }); + } + }, + }, + talents: [ + { install(battle, unit) { permBuff(battle, unit, 'surtr:magma', { resIgnoreFlat: num(t0.magic_resist_penetrate_fixed) }); } }, + { install(battle, unit) { // 余烬 + // PRTS 天赋备注: "持有不死的情况下不会触发此天赋" (a 不死 that prevented the blow first: `c.prevented` — 坚固维式重锤's + // lock, PRIO_REVIVE −100, runs after this −60 on the same first lethal blow [ASSUMED order], so it never starts + // while 余烬 is unused). "触发本天赋后,获得禁疗与不死": 不死 = every later lethal blow is prevented (`mem.ember`); + // 禁疗 (异常效果 HEAL_FREE "无法成为治疗类能力的目标,且受到的治疗量变为0", an HP-regen attribute excepted) = flags + // noHeal (no heal pick, no heal from others) + healFree (her own heals too; S3's start heal "无视禁疗"), shown as + // the status 'healFree' until she leaves. "强制退出战场视为撤回干员": a retreat (Battle.retreat drops the buff) — she + // lies down where she stood and redeploys there, like every operator that leaves the field (PRTS 卫戍协议/帮助 + // "干员退场后…原地留下一个“倒地干员”…自动部署至该位置"; Battle.isDown, GitHub #60). + const wait = num(t1['surtr_t_2[withdraw].interval'], 8); + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.ember = false; }, { owner: unit }); + battle.on('fatal', (c) => { + if (c.unit !== unit || c.prevented) return; + c.prevented = true; + if (unit.mem.ember) return; + unit.mem.ember = true; + const dep = unit.deploySeq; + battle.addBuff(unit, { key: 'surtr:ember', status: 'healFree', flags: { noHeal: true, healFree: true } }); + battle.fx('ember', { x: unit.x, y: unit.y, id: unit.id }); + battle.after(wait, () => { if (unit.alive && unit.deploySeq === dep) battle.retreat(unit, { reason: 'retreat' }); }, { owner: unit }); + }, { owner: unit, priority: -60 }); + } }, + ], + install(battle, unit) { + const aspd = num(tb.attack_speed); + if (aspd) { + whileOn(battle, unit, 0.2, () => { + if (!unit.blocking.length) battle.addBuff(unit, { key: 'surtr:module', duration: 0.3, mods: { aspd } }); + else battle.removeBuff(unit, 'surtr:module'); + }); + } + const fragile = num(tb.damage_scale, 1) - 1; // AFT-Y: 自身阻挡的敌人受到10%的法术脆弱效果 + if (fragile > 0) { + whileOn(battle, unit, 0.2, () => { + for (const e of unit.blocking) if (e.alive) battle.applyStatus(e, 'artsFragile', { duration: 0.3, value: fragile, source: unit }); + }); + } + if (sid === 'skchr_surtr_2') { // S2: 仅攻击到一个敌人时对其攻击力提升至140% + const solo = num(bb['attack@surtr_s_2[critical].atk_scale'], 1); + battle.on('beforeAttack', (c) => { if (c.attacker === unit) unit.mem.surtrSolo = !!unit.skill?.active && c.targets.filter((t) => t && t.alive).length === 1; }, { owner: unit, priority: -100 }); + battle.on('hit', (c) => { + if (c.source === unit && unit.mem.surtrSolo && unit.skill?.active && c.dmg.isAttack && !c.dmg.isSplash) c.dmg.amount *= solo; + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_08-horn.js b/server/sim/content/kits/ops/chess_char_5_08-horn.js new file mode 100644 index 00000000..7722915d --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_08-horn.js @@ -0,0 +1,143 @@ +// server/sim/content/kits/ops/chess_char_5_08-horn.js — 号角 (char_4039_horn) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { bodyInRadius } from '../../../body.js'; +import { + AURA_IV, AURA_DUR, num, talent, traitBb, batPct, mods, isOp, selectedId, lazySkills, instantKind, whileOn, +} from '../shared/tier5.js'; + +/** 号角 S2 过载: the second half of the ammo (PRTS 备注 of S3 "技能进行到一半时"; S2 counts shots). [ASSUMED] */ +const HORN_S2_OVERLOAD_AT = 0.5; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 号角 — S3 终极防线 (24 s in total, a two-segment gauge — PRTS 备注: "技能进行到一半时触发额外效果"): ATK +, BAT −1.2 s + // for the first 12 s, then 过载 for the remaining damage_duration (12) s: ATK changes to +50 %, HP loss ramping to 12 % + // max HP/s at the end. T1 军事要塞: all Defenders ATK +20 % while she is on the field. + // T2 血战: once per deployment, lethal damage → full heal, max HP −50 %, ASPD +18, DEF +18 %. + // Module (elite): ×1.1 vs blocked enemies. + // S1 照明榴弹 (instant / 2 charges elite, 自动触发 ⇒ DEFAULT): next attack atk_scale × ATK; a ranged (unblocked) one also + // splashes projectile_range and lights the impact for projectile_delay_time s (enemies within projectile_range lose + // 隐匿). S2 暴风号令 (ammo 10): every attack attack@s2.atk_scale × ATK phys splash; 过载 for the second half of the + // ammo [ASSUMED like S3]: + attack@s2.magic_atk_scale × ATK arts to every enemy hit (manual close never happens in the + // auto battle). Module FOR-Y (elite): ASPD +10 while not blocking. + // S2 and S3 cast with an enemy in range (data DEFAULT, rawRule TAKE_DAMAGE): the owner's deliberate deviation from the + // official 重装 TAKE_DAMAGE row (DESIGN §21.29, tools/build-data.mjs TRIGGER_DEVIATIONS). + chess_char_5_08_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1); + const sid = selectedId(chess, def); + const s2Ammo = Math.max(1, num(bb['attack@s2.trigger_time'], 10)); + const total = Math.max(0.1, num(def?.skill?.duration, num(chess?.skill?.duration, 24))); + const ovAtk = num(bb['horn_s_3[overload_start].atk'], num(bb.atk)); + const ovIv = Math.max(0.05, num(bb['horn_s_3[overload_start].interval'], 0.2)); + const ovPeak = num(bb['horn_s_3[overload_start].hp_ratio']); + const ovRamp = Math.max(0.1, Math.min(total, num(bb['horn_s_3[overload_start].damage_duration'], total / 2))); + const main = total - ovRamp; + const blockedMul = num(tb.atk_scale); + return { + skills: lazySkills({ + skchr_horn_1: () => ({ + kind: instantKind(chess, def), // 自动触发 "下次攻击": the data rule DEFAULT (an AUTO skill takes no 技能策略) + attack: { + atkScale: num(bb.atk_scale, 1), + onHit({ battle, unit, x, y }) { + if (!unit.mem.hornFlare) return; + unit.mem.hornFlare = false; + (unit.mem.flares ??= []).push({ x, y, until: battle.time + num(bb.projectile_delay_time, 6) }); + battle.fx('zone', { x, y, id: unit.id, r: num(bb.projectile_range, 1.7), duration: num(bb.projectile_delay_time, 6) }); + }, + }, + }), + skchr_horn_2: () => ({ + kind: 'ammo', ammo: s2Ammo, + attack: { atkScale: num(bb['attack@s2.atk_scale'], 1) }, + onStart({ unit }) { unit.mem.hornS2Over = false; }, + onEnd({ unit }) { unit.mem.hornS2Over = false; }, + }), + }), + skill: { + kind: 'duration', duration: total, + mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), + onStart({ unit }) { unit.mem.hornT = 0; unit.mem.hornAcc = 0; unit.mem.overload = false; }, + onTick({ battle, unit, dt }) { + unit.mem.hornT += dt; + if (!unit.mem.overload && unit.mem.hornT >= main - 1e-9) { + unit.mem.overload = true; + const delta = ovAtk - num(bb.atk); + battle.addBuff(unit, { key: 'horn:overload', visible: true, mods: mods({ atkPct: delta }) }); + battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); + } + if (!unit.mem.overload || !(ovPeak > 0)) return; + unit.mem.hornAcc += dt; + while (unit.mem.hornAcc >= ovIv && unit.alive) { + unit.mem.hornAcc -= ovIv; + const rate = ovPeak * Math.min(1, (unit.mem.hornT - main) / ovRamp); + if (rate > 0) battle.loseHp(unit, unit.s.maxHp * rate * ovIv, { source: unit, silent: true }); + } + }, + onEnd({ battle, unit }) { unit.mem.overload = false; battle.removeBuff(unit, 'horn:overload'); }, + }, + trait: blockedMul > 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedMul : 1) } : undefined, + talents: [ + { install(battle, unit) { // 军事要塞 + const atk = num(t0.atk); + if (!atk) return; + whileOn(battle, unit, AURA_IV, () => { + for (const a of battle.allies()) if (isOp(a) && a.def?.profession === 'TANK') battle.addBuff(a, { key: 'horn:fortress', duration: AURA_DUR, mods: { atkPct: atk } }); + }); + } }, + { install(battle, unit) { // 血战 + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.bloodBattle = false; }, { owner: unit }); + battle.on('fatal', (c) => { + if (c.unit !== unit || c.prevented || unit.mem.bloodBattle) return; + c.prevented = true; + unit.mem.bloodBattle = true; + battle.addBuff(unit, { key: 'horn:bloodBattle', visible: true, mods: mods({ hpMul: 1 - num(t1.max_hp), aspd: num(t1.attack_speed), defPct: num(t1.def) }) }); + unit.hp = Math.max(1, unit.hp); + battle.heal(unit, unit, unit.s.maxHp * num(t1.hp_ratio, 1), { self: true }); + battle.fx('bloodBattle', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit, priority: -40 }); + } }, + ], + install(battle, unit) { + const aspd = num(tm.attack_speed); // FOR-Y: 不阻挡敌人时…攻击速度+10 + if (aspd) whileOn(battle, unit, 0.2, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'horn:moduleY', duration: 0.3, mods: { aspd } }); }); + if (sid === 'skchr_horn_1') { + // the pending shot: a ranged (unblocked) attack widens its splash and lights the impact + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !unit.skill?.pending || !c.profile) return; + unit.mem.hornFlare = !c.profile._fortressMelee; + if (unit.mem.hornFlare) c.profile.splashRadius = Math.max(num(c.profile.splashRadius), num(bb.projectile_range, 1.7)); + }, { owner: unit, priority: -100 }); + battle.every(AURA_IV, () => { + const fl = unit.mem.flares; + if (!fl || !fl.length) return; + unit.mem.flares = fl.filter((f) => f.until > battle.time + 1e-9); + const r = num(bb.projectile_range, 1.7); + for (const f of unit.mem.flares) for (const e of battle.enemies) { + if (e.alive && !e.hidden && bodyInRadius(e, f.x, f.y, r)) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit }); + } + }, { owner: unit }); + } + if (sid === 'skchr_horn_2') { + const magic = num(bb['attack@s2.magic_atk_scale']); + battle.on('beforeAttack', (c) => { // 过载: the second half of the ammo + if (c.attacker !== unit || !unit.skill?.active || !c.profile) return; + const shot = s2Ammo - num(unit.skill.ammoLeft) + 1; + const over = shot > s2Ammo * HORN_S2_OVERLOAD_AT + 1e-9; + if (over && !unit.mem.hornS2Over) battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); + unit.mem.hornS2Over = over; + if (!over || !(magic > 0)) return; + // decided at the shot (a shell still in flight when the skill ends keeps it): the effective profile of this + // attack (a per-attack copy while the skill runs) carries the extra arts splash on every enemy it hits + const prev = c.profile.onEachHit; + c.profile.onEachHit = (b, u, victim, hc) => { + if (prev) prev(b, u, victim, hc); + if (victim && victim.alive && victim.side === 'enemy') b.dealDamage(unit, victim, { amount: unit.s.atk * magic, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'hornOverload'] }); + }; + }, { owner: unit, priority: -100 }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_09-cetsyr.js b/server/sim/content/kits/ops/chess_char_5_09-cetsyr.js new file mode 100644 index 00000000..3a90b0b3 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_09-cetsyr.js @@ -0,0 +1,125 @@ +// server/sim/content/kits/ops/chess_char_5_09-cetsyr.js — 魔王 (char_4134_cetsyr) kit, tier 5 (hidden). +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { rotateOffset } from '../../../dir.js'; +import { AURA_IV, AURA_DUR, num, talent, traitBb, skillGrid, isOp, leaderOf, whileOn } from '../shared/tier5.js'; + +/** 魔王 T1 微尘: collision radius of one mote (PRTS 备注 0.4); orbit radius / speed come from the talent bb. */ +const MOTE_HIT_RADIUS = 0.4; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 魔王 — bard (aura heal 10 % ATK/s). S3 编织重构现世 (30 s): skill range, aura 65 %, motes never vanish, other allies in + // range get 鼓舞 = +65 % of her max HP, HP of everyone in range is equalised every 2 s. + // T1 过往尘埃: 3 motes orbit her; one colliding with an operator vanishes and gives it ×1.5 aura healing for 6 s, then + // respawns after 6 s. + // T2 魔王残响: allies take −10 % damage from Sarkaz enemies. Module (elite): ≥2 other ops in range → ATK +8 %. + chess_char_5_09_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1); + const baseRatio = num(tb['attack@atk_to_hp_recovery_ratio'], 0.1); + const skillRatio = num(bb['attack@atk_to_hp_recovery_ratio'], baseRatio); + const inspire = num(bb.max_hp); + const shareIv = Math.max(0.1, num(bb['attack@cetsyr_s_3[cal_hp_ratio].interval'], 2)); + const inspireAll = (battle, unit) => { + if (!(inspire > 0)) return; + const hp = unit.s.maxHp * inspire; + for (const a of battle.alliesInGrid(unit)) { + if (a !== unit) battle.addBuff(a, { key: 'cetsyr:inspire', duration: AURA_DUR, visible: true, status: 'inspire', mods: { hpFlat: hp }, data: { src: unit } }); + } + }; + return { + skill: { + kind: 'duration', + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + onStart({ battle, unit }) { + if (unit.profile) unit.profile.auraRatio = skillRatio; + unit.mem.shareAcc = 0; + unit.mem.inspireAcc = 0; + inspireAll(battle, unit); + battle.fx('healAoe', { x: unit.x, y: unit.y, id: unit.id, r: 2.5 }); + }, + onTick({ battle, unit, dt }) { + unit.mem.inspireAcc += dt; + if (unit.mem.inspireAcc >= AURA_IV) { unit.mem.inspireAcc = 0; inspireAll(battle, unit); } + unit.mem.shareAcc += dt; + if (unit.mem.shareAcc < shareIv - 1e-9) return; + unit.mem.shareAcc -= shareIv; + const group = battle.alliesInGrid(unit).filter((a) => a.alive && a.kind !== 'device'); + let hp = 0, max = 0; + for (const a of group) { hp += a.hp; max += a.s.maxHp; } + if (!(max > 0) || group.length < 2) return; + const ratio = hp / max; + for (const a of group) a.hp = Math.max(1, Math.min(a.s.maxHp, a.s.maxHp * ratio)); + battle.fx('hpShare', { x: unit.x, y: unit.y, id: unit.id, ratio: Math.round(ratio * 1000) / 1000 }); + }, + onEnd({ battle, unit }) { + if (unit.profile) unit.profile.auraRatio = baseRatio; + for (const a of battle.allyUnits) { + const b = a.findBuff('cetsyr:inspire'); + if (b && b.data.src === unit) battle.removeBuff(a, b); + } + }, + }, + talents: [ + { install(battle, unit) { // 过往尘埃: cnt motes orbit her (radius range_radius, dynamic_spd °/s, PRTS hit radius 0.4) + const cnt = Math.max(0, Math.floor(num(t0.cnt, 3))), orbit = num(t0.range_radius, 1.15); + const spd = (num(t0.dynamic_spd, 30) * Math.PI) / 180; + const cd = num(t0.cooldown, 6), dur = num(t0.talent_duration, 6), mul = num(t0['attack@trait_mul'], 1.5); + const motes = new Array(cnt).fill(0); // per orbit slot: the time its mote is back (vanished → respawn after cd) + const reach = orbit + MOTE_HIT_RADIUS + 0.01; + battle.on('deploy', (c) => { if (c.unit === unit) motes.fill(0); }, { owner: unit }); + whileOn(battle, unit, AURA_IV, () => { + if (!cnt) return; + const keep = !!unit.skill?.active; // S3: "微尘"不再消失 + // "微尘"碰撞友方干员: operators only; a mote never collides with an operator already under a mote effect + const cands = battle.alliesInRadius(unit.x, unit.y, reach, null).filter((a) => a !== unit && isOp(a) && !a.findBuff('cetsyr:mote')); + if (!cands.length) return; + const t = battle.time - num(unit.deployedAt); + for (let k = 0; k < cnt; k++) { + if (motes[k] > battle.time + 1e-9) continue; + const ang = spd * t + (2 * Math.PI * k) / cnt; + // the orbit turns with his direction: angle 0 = straight ahead, π/2 = his left hand + const [fr, fc] = unit.fwd, [lr, lc] = rotateOffset(1, 0, unit.dir); + const mx = unit.x + orbit * (fc * Math.cos(ang) + lc * Math.sin(ang)), my = unit.y + orbit * (fr * Math.cos(ang) + lr * Math.sin(ang)); + let hit = null, hd = Infinity; + for (const a of cands) { + if (a.findBuff('cetsyr:mote')) continue; + const d = Math.hypot(a.x - mx, a.y - my); + if (d <= MOTE_HIT_RADIUS + 1e-9 && d < hd) { hd = d; hit = a; } + } + if (!hit) continue; + battle.addBuff(hit, { key: 'cetsyr:mote', duration: dur, visible: true, data: { src: unit, mul } }); + if (!keep) motes[k] = battle.time + cd; + battle.fx('mote', { x: hit.x, y: hit.y, id: hit.id }); + } + }); + battle.on('heal', (c) => { + if (c.source !== unit || !c.opts?.aura) return; + const b = c.target.findBuff('cetsyr:mote'); + if (b) c.amount *= num(b.data.mul, 1); + }, { owner: unit }); + } }, + { install(battle, unit) { // 魔王残响 + const dr = num(t1.damage_resistance); + if (!(dr > 0)) return; + battle.on('hit', (c) => { + if (!c.target || c.target.side !== 'ally' || !c.source || c.source.side !== 'enemy') return; + if (!(c.source.def?.tags || []).includes('sarkaz') || leaderOf(battle, unit) !== unit) return; + c.dmg.mul *= 1 - dr; + }, { owner: unit }); + } }, + ], + install(battle, unit) { // module: ≥ cnt other ops in the (base) range → ATK +8 % + const atk = num(tm.atk), need = num(tm.cnt, 2); + if (!chess?.isGolden || !atk) return; + whileOn(battle, unit, AURA_IV, () => { + const keys = new Set(unit.baseRangeKeys || []); + let n = 0; + for (const a of battle.allies()) if (a !== unit && isOp(a) && keys.has(a.tileR * COLS + a.tileC)) n++; + if (n >= need) battle.addBuff(unit, { key: 'cetsyr:module', duration: AURA_DUR, mods: { atkPct: atk } }); + }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_10-lisa.js b/server/sim/content/kits/ops/chess_char_5_10-lisa.js new file mode 100644 index 00000000..61856e04 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_10-lisa.js @@ -0,0 +1,64 @@ +// server/sim/content/kits/ops/chess_char_5_10-lisa.js — 铃兰 (char_358_lisa) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { AURA_IV, AURA_DUR, num, talent, skillGrid, mods, isOp, lazySkills, whileOn, spAura } from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 铃兰 — S3 狐火渺然 (27/29 s): no attacks, skill range, every enemy in range is sluggish, allies in range heal 9/11 % + // ATK per second, T2 ×scale_delta_to_one. T1 技力光环·辅助: Supporters +0.4 SP/s (highest wins). + // T2 画地为牢: sluggish enemies in range also take +20 % damage for the same time. Module (elite): +0.2 SP/s with an + // enemy in range. + chess_char_5_10_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); + const boost = num(bb.scale_delta_to_one, 1); + const healRatio = num(bb['attack@atk_to_hp_recovery_ratio']); + const fragile = num(t1.damage_scale, 1) - 1; + return { + // S1 全力以赴 (duration): ATK +, ASPD +. S2 儿时的舞乐 (toggle, 持续时间无限): ATK +, 2 targets. + skills: lazySkills({ + skchr_lisa_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }), + skchr_lisa_2: () => ({ kind: 'toggle', mods: mods({ atkPct: num(bb.atk) }), targeting: { maxTargets: Math.max(1, num(bb['attack@max_target'], 2)) } }), + }), + skill: { + kind: 'duration', + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + attack: { noAttack: true }, + onStart({ unit }) { unit.mem.foxSlow = AURA_IV; unit.mem.foxHeal = 0; }, + onTick({ battle, unit, dt }) { + unit.mem.foxSlow += dt; + if (unit.mem.foxSlow >= AURA_IV) { + unit.mem.foxSlow = 0; + for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) battle.applyStatus(e, 'sluggish', { duration: AURA_DUR, source: unit }); + } + unit.mem.foxHeal += dt; + if (unit.mem.foxHeal >= 1) { + unit.mem.foxHeal -= 1; + if (!(healRatio > 0)) return; + for (const a of battle.alliesInGrid(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, unit.s.atk * healRatio, { aura: true }); + } + }, + }, + talents: [ + { install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec), (a) => isOp(a) && a.def?.profession === 'SUPPORT'); } }, + { install(battle, unit) { // 画地为牢 — PRTS 备注: an aura; every enemy in her range that is 停顿 (whoever caused it) + // carries the talent's 脆弱 while it stays 停顿 and in range. It is the standard 脆弱 status (same-name: highest wins). + if (!(fragile > 0)) return; + whileOn(battle, unit, AURA_IV, () => { + const v = fragile * (unit.skill?.active ? boost : 1); + for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) { + if (e.alive && e.findBuff('sluggish')) battle.applyStatus(e, 'fragile', { duration: AURA_DUR, value: v, source: unit }); + } + }); + } }, + ], + install(battle, unit) { // module + const sp = num(tm.sp_recovery_per_sec); + if (!chess?.isGolden || !(sp > 0)) return; + whileOn(battle, unit, AURA_IV, () => { + if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) battle.addBuff(unit, { key: 'lisa:module', duration: AURA_DUR, mods: { spRecoveryFlat: sp } }); + }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_11-demkni.js b/server/sim/content/kits/ops/chess_char_5_11-demkni.js new file mode 100644 index 00000000..9565e9e9 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_11-demkni.js @@ -0,0 +1,111 @@ +// server/sim/content/kits/ops/chess_char_5_11-demkni.js — 塞雷娅 (char_202_demkni) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { + HALF_HP, NEVER, AURA_IV, AURA_DUR, num, on, talent, traitBb, skillGrid, mods, selectedId, lazySkills, instantKind, + whileOn, lowHpHealUp, permBuff, +} from '../shared/tier5.js'; + +/** + * Activate the unit's skill whenever it is ready and `cond()` holds (NEVER-trigger skills). Consecutive casts of a + * multi-charge skill are spaced by at least one attack interval (like the attack-driven DEFAULT rule). + */ +function autoCast(battle, unit, cond) { + battle.on('tick', () => { + const sk = unit.skill; + if (!sk || sk.noSkill || !on(unit) || !unit.canAct || !sk.ready || unit.s.flags.silence) return; + if (sk.active && sk.isTimed) return; + if (battle.time - sk.lastStart < unit.s.interval - 1e-9) return; + if (cond()) sk.activate('DEFAULT'); + }, { owner: unit }); +} + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 塞雷娅 — S2 药物配置 (instant, time SP): heals every ally in the skill range for heal_scale × ATK (cast when one is + // injured). T1 莱茵充能护服: every 20 s on the field ATK +5 % / DEF +4 % (×5). T2 精神回复: +1 SP to every ally she heals. + // Module (elite): heals on allies below 50 % ×1.15. + // S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack when an ally of the skill area 周围 is at ≤ half HP): + // that attack is instead a heal of the lowest such ally for heal_scale × ATK. + // S3 钙质化 (duration; the 重装 strategy TAKE_DAMAGE): allies in the skill area heal + // attack@heal_scale × ATK per second; enemies there take arts ×demkni_s_3.damage_scale and move −60 %. + // Module GUA-Y (elite): damage taken −15 %. + chess_char_5_11_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); + const sid = selectedId(chess, def); + const grid = skillGrid(chess, def) ?? [[0, 0]]; + const zone = (battle, unit) => { + const at = unit.tileR * COLS + unit.tileC; + if (unit.mem.sariaAt !== at) { unit.mem.sariaAt = at; unit.mem.sariaKeys = new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)); } + const keys = unit.mem.sariaKeys; + return battle.allies().filter((a) => keys.has(a.tileR * COLS + a.tileC) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal))); + }; + const healable = (a, unit) => a.kind !== 'device' && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal)); + return { + skills: lazySkills({ + skchr_demkni_1: () => ({ + // 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次 + // 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic + // rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮, + // no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack) + kind: instantKind(chess, def), + trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP }, + targeting: { rangeGrid: grid }, + attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(bb.heal_scale, 1), projectile: 'none' }, + onHit({ battle, target }) { if (target) battle.fx('healAoe', { x: target.x, y: target.y, id: target.id, r: 0.5 }); }, + }), + skchr_demkni_3: () => ({ + kind: 'duration', + onStart({ unit }) { unit.mem.calcAcc = 0; unit.mem.calcAura = AURA_IV; }, + onTick({ battle, unit, dt }) { + unit.mem.calcAura += dt; + if (unit.mem.calcAura >= AURA_IV - 1e-9) { + unit.mem.calcAura = 0; + const m = { artsTakenMul: num(bb['demkni_s_3.damage_scale'], 1), moveMul: Math.max(0, 1 + num(bb['demkni_s_3.move_speed'])) }; + for (const e of battle.unitsInGrid(unit, grid, { side: 'enemy' })) battle.addBuff(e, { key: 'saria:calcify', duration: AURA_DUR, visible: true, mods: m }); + } + unit.mem.calcAcc += dt; + if (unit.mem.calcAcc < 1 - 1e-9) return; + unit.mem.calcAcc -= 1; + const amt = unit.s.atk * num(bb['attack@heal_scale']); + if (amt > 0) for (const a of battle.unitsInGrid(unit, grid, { side: 'ally' })) if (healable(a, unit) && a.hp < a.s.maxHp - 1e-6) battle.heal(unit, a, amt); + }, + }), + }), + skill: { + kind: 'instant', heal: true, trigger: NEVER, + onStart({ battle, unit }) { + const amt = unit.s.atk * num(bb.heal_scale, 1); + for (const a of zone(battle, unit)) if (a.hp < a.s.maxHp - 1e-6) battle.heal(unit, a, amt); + battle.fx('healAoe', { x: unit.x, y: unit.y, id: unit.id, r: 2.5 }); + }, + }, + talents: [ + { install(battle, unit) { // 莱茵充能护服 + const iv = Math.max(1, num(t0.interval, 20)), max = Math.max(1, num(t0.max_stack_cnt, 5)); + whileOn(battle, unit, 1, () => { + const n = Math.min(max, Math.floor((battle.time - unit.deployedAt + 1e-6) / iv)); + const cur = unit.findBuff('saria:suit'); + if (n > 0 && (!cur || cur.stacks !== n)) battle.addBuff(unit, { key: 'saria:suit', stacks: n, maxStacks: max, mods: mods({ atkPct: num(t0.atk), defPct: num(t0.def) }) }); + }); + } }, + { install(battle, unit) { // 精神回复 + const sp = num(t1.sp); + if (!(sp > 0)) return; + battle.on('heal', (c) => { + if (c.source !== unit || c.opts?.regen || !(c.amount > 0) || !c.target.skill) return; + c.target.skill.gainSp(sp, 'talent'); + }, { owner: unit, priority: -10 }); + } }, + ], + install(battle, unit) { + // S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured + if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6)); + lowHpHealUp(battle, unit, tb); + if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_12-dusk.js b/server/sim/content/kits/ops/chess_char_5_12-dusk.js new file mode 100644 index 00000000..45832929 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_12-dusk.js @@ -0,0 +1,118 @@ +// server/sim/content/kits/ops/chess_char_5_12-dusk.js — 夕 (char_2015_dusk) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets } from '../../../targeting.js'; +import { + HALF_HP, num, on, talent, talentRec, batPct, mods, selectedId, lazySkills, skillRange, moduleRangeUp, +} from '../shared/tier5.js'; + +/** 夕 S1 "下一次攻击溅射范围扩大" — expanded splash radius (tiles; the splash-caster default is 1.1). PRTS: "溅射半径扩大至1.7". */ +const DUSK_SPLASH_RADIUS = 1.7; +/** 夕 S3 "攻击范围与溅射范围扩大" — the expanded splash radius, the same 1.7 as S1 (PRTS). [ASSUMED for S3] */ +const DUSK_S3_SPLASH = DUSK_SPLASH_RADIUS; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 夕 — S1 工笔入化 (2 charges): next attack 180 % ATK with an expanded splash. T1 化境: a kill by 夕 or 小自在 → ATK +2 % + // (×15). T2 点睛: first attack after deploying summons 小自在 (25 s) on the target's tile (deployable ground). + // Module (elite): 攻击范围扩大. + // S2 泼墨淋漓 (duration): skill range, ATK +, ASPD +, hits every enemy in range (one hit each: no splash on top); + // arts damage ×damage_scale on enemies below hp_ratio. S3 写意胜形 (duration): skill range, BAT +0.4 s, prefers + // unblocked enemies, splash 1.7, ATK +; every attack summons 小自在 on the target's tile (deployable ground) or moves / + // refreshes the one already out (deployLimit 1: "召唤/刷新一个"), 25 s (the 点睛 token duration). + chess_char_5_12_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1); + const sid = selectedId(chess, def); + const tokenId = talentRec(chess, 1)?.tokenKey ?? (chess?.tokens || [])[0] ?? 'token_10015_dusk_drgn'; + const tokenDur = num(t1['attack@tokenduration'], 25); + const freeGround = (battle, r, c) => battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c); + // 小自在 expiry follows tk.mem.duskUntil (a refresh pushes it back) + const expire = (battle, tk) => { + if (!tk.alive) return; + const left = num(tk.mem.duskUntil) - battle.time; + if (left > 1e-6) battle.after(left, () => expire(battle, tk), { owner: tk }); + else battle.retreat(tk, { reason: 'expired', permanent: true }); + }; + /** Summon 小自在 on (r, c), or move / refresh hers when one is out. */ + const summon = (battle, unit, r, c) => { + const mine = battle.allyUnits.find((x) => x.kind === 'token' && x.ownerUnit === unit && x.defId === tokenId && x.alive && x.deployed); + if (mine) { + if (mine.tileR !== r || mine.tileC !== c) { + const fromX = mine.x, fromY = mine.y; + if (!freeGround(battle, r, c) || !battle.relocate(mine, r, c)) return null; + battle.fx('teleport', { x: mine.x, y: mine.y, id: mine.id, fromX, fromY }); + } + mine.mem.duskUntil = battle.time + tokenDur; + return mine; + } + if (!freeGround(battle, r, c)) return null; + const tk = battle.spawnToken(unit, tokenId, r, c); + if (!tk) return null; + tk.mem.duskUntil = battle.time + tokenDur; + battle.after(tokenDur, () => expire(battle, tk), { owner: tk }); + battle.fx('summon', { x: tk.x, y: tk.y, id: tk.id, token: tokenId }); + return tk; + }; + return { + skills: lazySkills({ + skchr_dusk_2: () => ({ + kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), + targeting: skillRange(chess, def, { allInRange: true }), + attack: { splashRadius: 0 }, + }), + skchr_dusk_3: () => ({ + kind: 'duration', mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), + targeting: skillRange(chess, def), + attack: { splashRadius: DUSK_S3_SPLASH }, + }), + }), + skill: { kind: 'charges', attack: { atkScale: num(bb.atk_scale, 1), splashRadius: DUSK_SPLASH_RADIUS } }, + talents: [ + { install(battle, unit) { // 化境 + battle.on('kill', (c) => { + const k = c.killer; + if (!k || c.victim.side !== 'enemy' || !(k === unit || (k.kind === 'token' && k.ownerUnit === unit)) || !on(unit)) return; + battle.addBuff(unit, { key: 'dusk:realm', refresh: 'stack', maxStacks: Math.max(1, num(t0.max_stack_cnt, 15)), mods: { atkPct: num(t0.atk) } }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 点睛 + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.duskSummoned = false; }, { owner: unit }); + battle.on('attack', (c) => { + if (c.attacker !== unit || unit.mem.duskSummoned) return; + const t = c.targets.find((x) => x.side === 'enemy'); + if (!t) return; + unit.mem.duskSummoned = true; + // "在目标位置(可部署地面)召唤": only on the target's own tile when it is free deployable ground — PRTS 备注: + // otherwise nothing is summoned, and the talent is spent for this deployment either way + summon(battle, unit, Math.round(t.y), Math.round(t.x)); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + moduleRangeUp(battle, unit, chess); + if (sid === 'skchr_dusk_2') { + const cut = num(bb.hp_ratio, HALF_HP), mul = num(bb.damage_scale, 1); + battle.on('hit', (c) => { + if (c.source !== unit || !unit.skill?.active || c.dmg.type !== 'arts' || !c.target || c.target.side !== 'enemy') return; + if (c.target.hpRatio < cut) c.dmg.mul *= mul; + }, { owner: unit }); + } + if (sid === 'skchr_dusk_3') { + // 优先攻击未阻挡的敌人 + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !unit.skill?.active || !c.targets.length || !c.targets[0].blockedBy) return; + const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); + sortEnemyTargets(battle, unit, list, null); + const alt = list.find((e) => !e.blockedBy); + if (alt) c.targets = [alt, ...c.targets.slice(1).filter((x) => x !== alt)]; + }, { owner: unit, priority: 10 }); + battle.on('attack', (c) => { + if (c.attacker !== unit || !unit.skill?.active) return; + const t = c.targets.find((x) => x.side === 'enemy'); + if (t) summon(battle, unit, Math.round(t.y), Math.round(t.x)); + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_13-ghost2.js b/server/sim/content/kits/ops/chess_char_5_13-ghost2.js new file mode 100644 index 00000000..95e5c626 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_13-ghost2.js @@ -0,0 +1,104 @@ +// server/sim/content/kits/ops/chess_char_5_13-ghost2.js — 归溟幽灵鲨 (char_1023_ghost2) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { + AURA_IV, AURA_DUR, num, talent, traitBb, skillGrid, batPct, mods, isAbyssal, isOp, selectedId, lazySkills, whileOn, +} from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 归溟幽灵鲨 — dollkeeper (professions.js installDollkeeper: the 替身 form, its switch animations, 阻回). Her <替身> makes + // no normal attack (PRTS 特性备注 "<替身>不进行普通攻击") and so casts no skill (kit trait `dollNoAttack`). + // S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1 (PRTS 备注: 不死 — a lethal hit does not switch her meanwhile); + // when it ends she switches to the 替身 at once (PRTS 修正 "技能结束后立刻切换为<替身>", the text's 视为被击倒: no lethal + // HP loss, so no 不死 / 复活 effect takes it). T1 拥抱自我: the 替身 slows nearby enemies −40 % and deals 40 % ATK + // arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only) — once it fights (not during its switch + // animation). T2 阿戈尔的深邃: Abyssal Hunters in the team max HP +20 %. Module (elite): substitute ATK +15 %. + // S1 生存的技巧 (duration): swaps HP ratios with the other operator of the skill area (周围) with the lowest HP ratio, + // ATK +. S3 生存的重压 (duration): BAT +1 s, hits every blocked enemy, ATK +, max HP +; an attacked enemy whose HP ratio + // is ≥ hers takes attack@atk_scale_ex × ATK phys more, otherwise she loses attack@hp_ratio of her max HP. + // Module PUM-Y (elite): substitute max HP +20 %. + chess_char_5_13_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); + const sid = selectedId(chess, def); + const aroundGrid = chess?.trait?.rangeGrid ?? [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]; + return { + skills: lazySkills({ + skchr_ghost2_1: () => ({ + kind: 'duration', mods: mods({ atkPct: num(bb.atk) }), + onStart({ battle, unit }) { + const t = battle.unitsInGrid(unit, skillGrid(chess, def) ?? aroundGrid, { side: 'ally' }).filter((a) => a !== unit && isOp(a) && a.hp > 0) + .sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0]; + if (!t) return; + const mine = unit.hpRatio, theirs = t.hpRatio; + unit.hp = Math.max(1, Math.min(unit.s.maxHp, unit.s.maxHp * theirs)); + t.hp = Math.max(1, Math.min(t.s.maxHp, t.s.maxHp * mine)); + battle.fx('hpShare', { x: t.x, y: t.y, id: unit.id, to: t.id }); + }, + }), + skchr_ghost2_3: () => ({ + kind: 'duration', + mods: mods({ atkPct: num(bb.atk), hpPct: num(bb.max_hp), batPct: batPct(bb.base_attack_time, chess) }), + attack: { + hitAllBlocked: true, + onEachHit({ battle, unit, target, kind }) { + if (kind !== 'main' || !target || target.side !== 'enemy') return; + if (unit.mem.ghostHeavy?.get(target)) { + if (target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb['attack@atk_scale_ex']), type: 'phys', isSkill: true, tags: ['skill', 'ghost2Weight'] }); + } else if (unit.alive) battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio']), { source: unit }); + }, + }, + }), + }), + skill: { + kind: 'duration', + mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), + // "技能结束后立刻切换为<替身>": nothing when the skill ended with her (death) or by her switch to the 替身, or she + // already is one + onEnd({ battle, unit, reason }) { + if (reason === 'death' || reason === 'substitute' || !unit.alive || !unit.deployed || unit.trait.doll) return; + battle.emit('dollSwitch', { unit, reason: 'skill', done: false }); + }, + }, + trait: { dollNoAttack: true }, + talents: [ + { install(battle, unit) { // 拥抱自我 (the 替身 fighting: not during a switch animation) + const slow = num(t0.move_speed), scale = num(t0.atk_scale); + whileOn(battle, unit, AURA_IV, () => { + if (!unit.trait.doll || unit.trait.dollSwitching || !slow) return; + for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } }); + }); + whileOn(battle, unit, 1, () => { + if (!unit.trait.doll || unit.trait.dollSwitching || !(scale > 0)) return; + for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] }); + }); + } }, + { install(battle, unit) { // 阿戈尔的深邃 + const hp = num(t1.max_hp); + if (!hp) return; + battle.on('battleStart', () => { + for (const a of battle.getPlayer(unit.ownerId)?.units ?? []) if (isOp(a) && isAbyssal(a)) battle.addBuff(a, { key: 'ghost2:abyss', mods: { hpPct: hp }, persist: true, allowDead: true }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // S2: HP never below 1 while it runs + if (!sid || sid === 'skchr_ghost2_2') battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 }); + if (sid === 'skchr_ghost2_3') { // S3: 生命比例高于或等于自身 — judged when the attack starts + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !unit.skill?.active) return; + unit.mem.ghostHeavy = new Map(c.targets.filter(Boolean).map((t) => [t, t.hpRatio >= unit.hpRatio - 1e-9])); + }, { owner: unit, priority: -100 }); + } + const hpUp = num(tb.max_hp); // PUM-Y: 替身…生命值提升 + if (hpUp) whileOn(battle, unit, 0.2, () => { if (unit.trait.doll) battle.addBuff(unit, { key: 'ghost2:moduleY', duration: 0.3, mods: { hpPct: hpUp } }); }); + const atk = num(tb.atk); + if (atk) { + whileOn(battle, unit, 0.2, () => { + if (unit.trait.doll) battle.addBuff(unit, { key: 'ghost2:module', duration: 0.3, mods: { atkPct: atk } }); + }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_14-svash2.js b/server/sim/content/kits/ops/chess_char_5_14-svash2.js new file mode 100644 index 00000000..d5d670b2 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_14-svash2.js @@ -0,0 +1,191 @@ +// server/sim/content/kits/ops/chess_char_5_14-svash2.js — 凛御银灰 (char_1045_svash2) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { bodyKeys } from '../../../body.js'; +import { toLocal } from '../../../dir.js'; +import { + AURA_IV, AURA_DUR, num, on, talent, talentRec, skillGrid, mods, inFaction, isOp, selectedId, lazySkills, instantKind, + skillRange, whileOn, enemiesInGrid, +} from '../shared/tier5.js'; + +const isKjerag = (u) => inFaction(u, 'kjeragShip', ['kjerag']); + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 凛御银灰 — S2 御敌的锋锐 (2 charges; PRTS 备注 "可对空"): the 6 front-most enemies in the skill range take 260 % ATK + // phys, cold + reveal for 4 s; the waiting (knocked-out) op nearest to 风雪之眼 by cost gets −11 redeploy cost (right + // side guard/caster/sniper first); waiting ops left of the eye (cost < eye cost) cast the AoE with their own ATK when + // they redeploy (≤2). + // T1 开放性开局: ops left of the eye and himself: +4 SP at deployment, redeploy time −20 %. + // T2 雪境先驱: Kjerag ops freeze-immune, DEF +60, 1.5 % max HP/s regen; doubled after 15 s on the field. + // The eye follows the selected skill (its overrideTokenKey: S1 eagle1 cost 16, S2 eagle2 14, S3 eagle3 19). + // S1 周旋的谋略 (instant): +cost DP at once; the waiting op nearest to the eye gets −[deck].cost redeploy cost (same pick + // as S2) and a barrier of [deck].shield × his max HP when it redeploys. + // S3 变革已至 (duration 48 s): skill range; enemies in it lose 隐匿; every attack hits every enemy of the range on the + // target's line (his facing) for bird_atk_scale × ATK phys + 脆弱 (damage_scale − 1, weak[limit] s [ASSUMED duration]); + // +[start_cost] DP at once, then +[cost].cost DP every [cost].interval s; the first activation of the battle swaps the + // base costs of the most (guard/caster/sniper first) and least expensive waiting ops. "风雪之眼变为可部署" is not + // modelled (nobody deploys by hand in the auto battle). + chess_char_5_14_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1); + const grid = skillGrid(chess, def) ?? [[0, 0], [0, 1]]; + const eyeId = chess?.skill?.overrideTokenKey ?? talentRec(chess, 0)?.tokenKey ?? 'token_10057_svash2_eagle2'; + const n = Math.max(1, num(bb.max_target, 6)), maxStacks = Math.max(1, num(bb.max_stack_cnt, 2)); + const PREF = ['WARRIOR', 'CASTER', 'SNIPER']; + /** Waiting (knocked-out) ops of his player split around the eye cost, and the one nearest to the eye (right side first). */ + const waitingArea = (battle, unit) => { + const eye = unit.mem.eyeCost ?? 0; + const waiting = battle.allyUnits.filter((a) => isOp(a) && a !== unit && a.ownerId === unit.ownerId && !a.alive && !a.removed); + const right = waiting.filter((a) => a.base.cost >= eye).sort((a, b) => a.base.cost - b.base.cost || a.id - b.id); + const left = waiting.filter((a) => a.base.cost < eye).sort((a, b) => b.base.cost - a.base.cost || a.id - b.id); + const pick = right.find((a) => PREF.includes(a.def?.profession)) ?? right[0] ?? left[0] ?? null; + return { waiting, right, left, pick }; + }; + const cutCost = (a, v) => { + if (!a || !(v > 0)) return; + a.mem.svashCostBase ??= a.base.cost; + a.base.cost = Math.max(0, a.base.cost - v); + }; + const slash = (battle, caster, atk) => { + // PRTS 备注: the slash ignores 隐匿 and hits flying enemies (and its reveal makes them targetable afterwards) + for (const e of enemiesInGrid(battle, caster, grid, n, { ignoreStealth: true })) { + battle.dealDamage(caster, e, { amount: atk * num(bb.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'svash2'] }); + if (!e.alive) continue; + battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: caster }); + battle.applyStatus(e, 'reveal', { duration: num(bb.cold), source: caster }); + } + battle.fx('aoe', { x: caster.x + caster.fwd[1] * 1.5, y: caster.y + caster.fwd[0] * 1.5, id: caster.id, r: 2, skill: 'svash2' }); + }; + return { + skills: lazySkills({ + // (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1) + skchr_svash2_1: () => ({ + kind: instantKind(chess, def), + trigger: 'SP_FULL', + onStart({ battle, unit }) { + battle.addDp(unit.ownerId, num(bb.cost)); + const { pick } = waitingArea(battle, unit); + cutCost(pick, num(bb['svash2_s_1[deck].cost'])); + if (pick) pick.mem.svashShield = unit.s.maxHp * num(bb['svash2_s_1[deck].shield']); + battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'svash2Plan' }); + }, + }), + skchr_svash2_3: () => ({ + kind: 'duration', + targeting: skillRange(chess, def), + attack: { + atkScale: num(bb.bird_atk_scale, 1), + onEachHit({ battle, unit, target }) { + if (!target || !target.alive || target.side !== 'enemy') return; + const v = num(bb.damage_scale, 1) - 1; + if (v > 0) battle.applyStatus(target, 'fragile', { duration: num(bb['weak[limit]'], 2), value: v, source: unit }); + }, + }, + onStart({ battle, unit }) { + battle.addDp(unit.ownerId, num(bb['svash2_s_3[start_cost].cost'])); + unit.mem.svashDpAcc = 0; + unit.mem.svashReveal = AURA_IV; + if (!unit.mem.svashSwapped) { // 首次开启时交换…基础部署费用 + unit.mem.svashSwapped = true; + const { waiting } = waitingArea(battle, unit); + const baseCost = (a) => a.mem.svashCostBase ?? a.base.cost; + const byCost = waiting.slice().sort((a, b) => baseCost(b) - baseCost(a) || a.id - b.id); + const hi = byCost.find((a) => PREF.includes(a.def?.profession)) ?? byCost[0]; + const lo = byCost.filter((a) => a !== hi).pop(); + if (hi && lo && baseCost(hi) !== baseCost(lo)) { + const ch = baseCost(hi), cl = baseCost(lo); + for (const [a, v] of [[hi, cl], [lo, ch]]) { + if (a.mem.svashCostBase != null) { a.base.cost = Math.max(0, v - (a.mem.svashCostBase - a.base.cost)); a.mem.svashCostBase = v; } else a.base.cost = v; + } + } + } + }, + onTick({ battle, unit, dt }) { + const iv = Math.max(0.1, num(bb['svash2_s_3[cost].interval'], 2)); + unit.mem.svashDpAcc += dt; + while (unit.mem.svashDpAcc >= iv - 1e-9) { unit.mem.svashDpAcc -= iv; battle.addDp(unit.ownerId, num(bb['svash2_s_3[cost].cost'], 1)); } + unit.mem.svashReveal += dt; + if (unit.mem.svashReveal < AURA_IV - 1e-9) return; + unit.mem.svashReveal = 0; + for (const e of enemiesInGrid(battle, unit, grid, 0, { ignoreStealth: true })) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit }); + }, + }), + }), + skill: { + kind: 'charges', // DEFAULT (data): cast right before an attack on an enemy in his initial (melee) range + onStart({ battle, unit }) { + slash(battle, unit, unit.s.atk); + // cost cut: nearest to the eye, right side guard/caster/sniper preferred + const { left, pick } = waitingArea(battle, unit); + cutCost(pick, num(bb.cost)); + for (const a of left) a.mem.svashCasts = Math.min(maxStacks, num(a.mem.svashCasts) + 1); + }, + }, + talents: [ + { install(battle, unit) { // 开放性开局 + const sp = num(t0.sp), mul = num(t0.respawn_time, 1); + const left = (a) => isOp(a) && a.ownerId === unit.ownerId && (a === unit || a.base.cost < num(unit.mem.eyeCost)); + battle.on('battleStart', () => { + if (!on(unit) || !(sp > 0)) return; + for (const a of battle.allies(unit.ownerId)) if (left(a) && a.skill) a.skill.gainSp(sp, 'talent'); + }, { owner: unit }); + battle.on('deploy', (c) => { + const a = c.unit; + if (c.initial || !left(a) || !(a === unit || on(unit))) return; + if (sp > 0 && a.skill) a.skill.gainSp(sp, 'talent'); + }, { owner: unit }); + battle.on('death', (c) => { + const a = c.unit; + if (!left(a) || a.removed || !(a === unit || on(unit)) || !Number.isFinite(a.respawnAt) || !(mul > 0 && mul < 1)) return; + a.respawnAt = a.deathAt + (a.respawnAt - a.deathAt) * mul; + }, { owner: unit }); + } }, + { install(battle, unit) { // 雪境先驱 + const defv = num(t1.def), regen = num(t1.hp_recovery_per_sec_by_max_hp_ratio), after = num(t1.interval, 15); + whileOn(battle, unit, AURA_IV, () => { + const m = battle.time - unit.deployedAt >= after - 1e-9 ? 2 : 1; + for (const a of battle.allies()) { + if (isOp(a) && isKjerag(a)) battle.addBuff(a, { key: 'svash2:snow', duration: AURA_DUR, mods: mods({ defFlat: defv * m, hpRegenRatio: regen * m }) }); + } + }); + battle.on('beforeStatus', (c) => { + if (c.status === 'freeze' && c.target.side === 'ally' && c.target.findBuff('svash2:snow')) c.cancel = true; + }, { owner: unit }); + } }, + ], + install(battle, unit) { + const tok = battle.tokenDef(eyeId, unit); // the owner's skill variant (DESIGN §16) + unit.mem.eyeCost = num(tok?.stats?.cost, 14); + if (selectedId(chess, def) === 'skchr_svash2_3') { + // S3: every attack hits the enemies of the skill range on the target's line (his facing frame): the line of + // the target's position; another enemy is on it when its body is (a huge one: any tile — body.js) + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !unit.skill?.active || !c.targets.length) return; + const main = c.targets[0]; + const lat = (r, cc) => toLocal(r - unit.tileR, cc - unit.tileC, unit.dir)[0]; + const l0 = lat(Math.round(main.y), Math.round(main.x)); + const onLine = (e) => bodyKeys(e).some((k) => lat(Math.floor(k / COLS), k % COLS) === l0); + const line = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).filter((e) => e !== main && onLine(e)); + if (line.length) c.targets = [main, ...line]; + }, { owner: unit, priority: 10 }); + } + // waiting-area effects resolve when the op (re)deploys + battle.on('deploy', (c) => { + const a = c.unit; + if (!isOp(a) || a.ownerId !== unit.ownerId) return; + if (a.mem.svashCostBase != null) { a.base.cost = a.mem.svashCostBase; a.mem.svashCostBase = null; } + if (num(a.mem.svashShield) > 0 && !c.initial) { // S1: 部署后获得…屏障 + battle.addBuff(a, { key: 'svash2:barrier', shield: a.mem.svashShield, visible: true }); + a.mem.svashShield = 0; + } + const casts = num(a.mem.svashCasts); + if (casts > 0 && !c.initial) { + a.mem.svashCasts = 0; + for (let i = 0; i < casts; i++) slash(battle, a, a.s.atk); + } + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_15-thorn2.js b/server/sim/content/kits/ops/chess_char_5_15-thorn2.js new file mode 100644 index 00000000..59cd281b --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_15-thorn2.js @@ -0,0 +1,209 @@ +// server/sim/content/kits/ops/chess_char_5_15-thorn2.js — 引星棘刺 (char_1039_thorn2) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { sortEnemyTargets } from '../../../targeting.js'; +import { toLocal } from '../../../dir.js'; +import { + AURA_IV, AURA_DUR, RING1, num, on, talent, skillGrid, maxCharges, mods, dist, isOp, selectedId, lazySkills, + instantKind, whileOn, permBuff, +} from '../shared/tier5.js'; + +/** Straight-road length through each tile (max of the horizontal and vertical runs of ground-passable tiles). */ +const ROAD_CACHE = new WeakMap(); +function straightRun(battle, r, c) { + let m = ROAD_CACHE.get(battle); + if (!m) { + m = new Map(); + const g = battle.grid, R = battle.rect; + const pass = (rr, cc) => g.groundPassable(rr, cc, true); + for (let rr = R.r0; rr <= R.r1; rr++) { + for (let cc = R.c0; cc <= R.c1; cc++) { + if (!pass(rr, cc)) continue; + let h = 1, v = 1; + for (let k = cc - 1; k >= R.c0 && pass(rr, k); k--) h++; + for (let k = cc + 1; k <= R.c1 && pass(rr, k); k++) h++; + for (let k = rr - 1; k >= R.r0 && pass(k, cc); k--) v++; + for (let k = rr + 1; k <= R.r1 && pass(k, cc); k++) v++; + m.set(rr * COLS + cc, Math.max(h, v)); + } + } + ROAD_CACHE.set(battle, m); + } + return m.get(r * COLS + c) ?? 0; +} + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 引星棘刺 — S2 解构涌潮 (instant / 2 charges elite): throws an alchemy unit at the target: for 12/15 s ground enemies + // around it get healing ×0.5, take 120/140 % ATK arts per second, allies heal 12/15 % ATK per second; the unit drifts + // along the throw direction and its radius grows. T1 心相: ATK +10 %, +3 s when another op is in range. + // T2 视界: allies ASPD +5, enemies −5 (doubled on straight roads ≥ 6 tiles). Module (elite): +0.1 SP/s with a unit out. + // S1 度算浪波 (instant): an alchemy unit thrown at the ally in her range with the lowest HP ratio: for + // projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and heal + // hp_recovery_per_sec_ratio × ATK per second. S3 “我的海疆”: passive — her range is the skill range; active + // (instant) — alchemy units on the max_target_token operators with the lowest block count: for projectile_delay_time s + // the enemies around each of them (RING1, following it) get ATK/DEF/RES −, one strongest instance (不叠加), and take + // atk_scale × ATK arts per second, everything ramping +per_interval each `interval` s up to max_stack_cnt steps. + chess_char_5_15_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); + const sid = selectedId(chess, def); + const baseDur = num(bb.projectile_delay_time, num(bb.remaining_time, 12)); + const r0 = num(bb.projectile_range, 1.1), grow = num(bb.value), speed = num(bb.projectile_move_speed); + const healMul = Math.max(0, num(bb.heal_scale, 1)); + const extend = (battle, unit) => (battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0); + // S3 ramp: value after `steps` intervals, clamped towards the max ("效果逐渐提升…15秒后达到最大") + const ramp = (base, per, max, steps) => { + const v = num(base) + num(per) * steps; + return num(max) < num(base) ? Math.max(num(max), v) : Math.min(num(max, v), v); + }; + /** One tick (AURA_IV) of an S1 guard zone / S3 sea zone. */ + const tickSpecial = (battle, unit, z, seaHits) => { + if (z.type === 'guard') { + const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1; + const allies = battle.alliesFor(unit).filter(inZone); + for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) }); + if (z.acc >= 1 - 1e-9) { + z.acc -= 1; + const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio); + if (heal > 0) for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true }); + } + return; + } + // sea: follows its operator while it stands on the field; the first burn lands with the unit, then one per + // `interval` (so the 15th step — the max — is reached 15 s after the throw, for the debuff and the damage alike) + if (z.anchor && z.anchor.alive && z.anchor.deployed) { z.x = z.anchor.x; z.y = z.anchor.y; } + const iv = Math.max(0.1, num(bb.interval, 1)); + const steps = Math.min(Math.max(0, num(bb.max_stack_cnt, 15)), Math.floor((z.t - AURA_IV + 1e-9) / iv)); + const foes = battle.foesInRadius(z.x, z.y, RING1); + for (const e of foes) seaHits.set(e, Math.max(seaHits.get(e) ?? -1, steps)); + if (z.acc >= iv - 1e-9) { + z.acc -= iv; + const scale = ramp(bb.atk_scale, bb.atk_scale_per_interval, bb.max_atk_scale, steps); + z.burn = { foes, scale }; // dealt after the debuffs of this tick are on + } + }; + return { + skills: lazySkills({ + skchr_thorn2_1: () => ({ + kind: instantKind(chess, def), + // an AUTO skill (skill_table 自动触发) that throws at an ally: it fires as soon as its SP is full, with or + // without an enemy — the basic DEFAULT rule waited for an attack, so with no enemy around it sat at 7/7 + // (GitHub #124 「引星棘刺一技能不会在满技力时自动释放」) + trigger: { rule: 'SP_FULL' }, + onStart({ battle, unit }) { + const t = battle.alliesInGrid(unit).filter((a) => a.hp > 0).sort((a, b) => a.hpRatio - b.hpRatio || b.blocking.length - a.blocking.length || dist(a, unit) - dist(b, unit) || a.id - b.id)[0]; + if (!t) return; + const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit) }; + (unit.mem.zones ??= []).push(z); + battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: RING1, duration: z.dur }); + }, + }), + skchr_thorn2_3: () => ({ + kind: instantKind(chess, def), + onStart({ battle, unit }) { + const ops = battle.allies(unit.ownerId).filter((a) => isOp(a) && a.hp > 0) + .sort((a, b) => a.s.blockCnt - b.s.blockCnt || dist(a, unit) - dist(b, unit) || a.id - b.id) + .slice(0, Math.max(1, num(bb.max_target_token, 3))); + const dur = num(bb.projectile_delay_time, 19) + extend(battle, unit); + for (const a of ops) { + (unit.mem.zones ??= []).push({ type: 'sea', anchor: a, x: a.x, y: a.y, t: 0, acc: Math.max(0.1, num(bb.interval, 1)) - AURA_IV, dur }); + battle.fx('zone', { x: a.x, y: a.y, id: unit.id, r: RING1, duration: dur }); + } + }, + }), + }), + skill: { + kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant', + onStart({ battle, unit }) { + const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); + sortEnemyTargets(battle, unit, list, null); + let t = list.find((e) => !e.isFlying) ?? list[0]; + if (!t) { + // PRTS 备注: no enemy in range → thrown at the farthest tile straight ahead inside her range + let best = null; + for (const k of unit.rangeKeys || []) { + const r = Math.floor(k / COLS), c = k % COLS; + if (!battle.grid.inRect(r, c)) continue; + const [lat, d] = toLocal(r - unit.tileR, c - unit.tileC, unit.dir); + if (lat !== 0) continue; + if (d > 0 && (!best || d > best.d)) best = { d, x: c, y: r }; + } + if (!best) return; + t = best; + } + const extra = battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0; + // it drifts away from her deployment tile ("移动方向始终为远离棘刺部署位置中心的方向") + const dx = t.x - unit.x, dy = t.y - unit.y, len = Math.hypot(dx, dy) || 1; + const z = { x: t.x, y: t.y, vx: (dx / len) * speed, vy: (dy / len) * speed, t: 0, acc: 0, dur: baseDur + extra }; + (unit.mem.zones ??= []).push(z); + battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: r0, duration: z.dur }); + }, + }, + talents: [ + { install(battle, unit) { permBuff(battle, unit, 'thorn2:mind', { atkPct: num(t0.atk) }); } }, + { install(battle, unit) { // 视界 + const cnt = num(t1.cnt, 6); + const ally = num(t1.attack_speed_ally), allyX = num(t1.attack_speed_ally_extra), foe = num(t1.attack_speed_enemy), foeX = num(t1.attack_speed_enemy_extra); + whileOn(battle, unit, AURA_IV, () => { + for (const a of battle.alliesFor(unit)) { + const v = ally + (straightRun(battle, a.tileR, a.tileC) >= cnt ? allyX : 0); + if (v) battle.addBuff(a, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } }); + } + for (const e of battle.enemies) { + if (!e.alive || e.hidden) continue; + const v = foe + (straightRun(battle, Math.round(e.y), Math.round(e.x)) >= cnt ? foeX : 0); + if (v) battle.addBuff(e, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } }); + } + }); + } }, + ], + install(battle, unit) { + const sp = num(tm.sp_recovery_per_sec); + if (sid === 'skchr_thorn2_3') { // S3 被动效果:攻击范围扩大 (her own range, so also the DEFAULT trigger range) + const g = skillGrid(chess, def); + if (g) { unit.rangeGrid = g; battle.refreshRange(unit); } + } + // alchemy units keep working after she falls (they are already thrown) + battle.every(AURA_IV, () => { + const zones = unit.mem.zones; + if (!zones || !zones.length) return; + const R = battle.rect; + const seaHits = new Map(); + for (const z of zones) { + z.t += AURA_IV; + z.acc += AURA_IV; + if (z.type) { tickSpecial(battle, unit, z, seaHits); continue; } + z.x = Math.max(R.c0, Math.min(R.c1, z.x + z.vx * AURA_IV)); + z.y = Math.max(R.r0, Math.min(R.r1, z.y + z.vy * AURA_IV)); + const r = r0 + grow * z.t; + const foes = battle.foesInRadius(z.x, z.y, r).filter((e) => !e.isFlying); + for (const e of foes) battle.addBuff(e, { key: 'thorn2:rot', duration: AURA_DUR, mods: { healingTakenMul: healMul } }); + if (z.acc >= 1 - 1e-9) { + z.acc -= 1; + for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] }); + const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr); + if (heal > 0) for (const a of battle.alliesInRadius(z.x, z.y, r, null)) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true }); + battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t) }); + } + } + for (const [e, steps] of seaHits) { // S3 debuff: 不叠加 — the strongest zone wins + if (!e.alive) continue; + battle.addBuff(e, { key: 'thorn2:sea', duration: AURA_DUR, visible: true, mods: mods({ + atkPct: ramp(bb.atk, bb.atk_per_interval, bb.max_atk, steps), + defPct: ramp(bb.def, bb.def_per_interval, bb.max_def, steps), + resMul: 1 + ramp(bb.magic_resistance, bb.magic_resistance_per_interval, bb.max_magic_resistance, steps), + }) }); + } + for (const z of zones) { + if (!z.burn) continue; + for (const e of z.burn.foes) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * z.burn.scale, type: 'arts', isSkill: true, tags: ['skill', 'mySea'] }); + z.burn = null; + } + unit.mem.zones = zones.filter((z) => z.t < z.dur - 1e-9); + if (chess?.isGolden && sp > 0 && on(unit) && unit.mem.zones.length) battle.addBuff(unit, { key: 'thorn2:module', duration: AURA_DUR, mods: { spRecoveryFlat: sp } }); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_16-plosis.js b/server/sim/content/kits/ops/chess_char_5_16-plosis.js new file mode 100644 index 00000000..3fb2ee5a --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_16-plosis.js @@ -0,0 +1,22 @@ +// server/sim/content/kits/ops/chess_char_5_16-plosis.js — 白面鸮 (char_128_plosis) kit, tier 5 (hidden). +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { num, talent, skillGrid, batPct, mods, spAura, moduleRangeUp } from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 白面鸮 — ringhealer. S2 脑啡肽 (33/36 s): skill range, BAT −1.8/−1.9 s. T1 技力光环: all allies +0.3 SP/s (highest + // wins). Module (elite): 攻击范围扩大. + chess_char_5_16_a: (bb, chess, def) => { + const t0 = talent(chess, 0); + return { + skill: { + kind: 'duration', heal: true, + mods: mods({ batPct: batPct(bb.base_attack_time, chess) }), + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + }, + talents: [{ install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec), () => true); } }], + install(battle, unit) { moduleRangeUp(battle, unit, chess); }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_17-f12yin.js b/server/sim/content/kits/ops/chess_char_5_17-f12yin.js new file mode 100644 index 00000000..a70f7d41 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_17-f12yin.js @@ -0,0 +1,73 @@ +// server/sim/content/kits/ops/chess_char_5_17-f12yin.js — 山 (char_264_f12yin) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { + HALF_HP, num, talent, traitBb, skillGrid, batPct, mods, selectedId, lazySkills, instantKind, skillRange, whileOn, + permBuff, +} from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 山 — S2 横扫架势 (stance toggle): DEF −30 %, range = own tile, ATK +35 %, block +1, hits every blocked enemy, 4 % max HP/s + // regen. T1 巨力重拳: 20 % chance ×1.6 ATK and target ATK −15 % for 3 s. T2 强壮肉体: DEF +10 %, 15 % phys dodge. + // Module (elite): ASPD +10 above 50 % HP. + // S1 左勾扫拳 (instant, attack SP): next attack atk_scale × ATK on max_target enemies. S3 震地碎岩击 (duration): skill + // range, BAT +0.7 s, ATK +, double hits on up to attack@max_target enemies, each pushed (中等力度); T1 chance → + // talent@prob. Module FGT-X (elite): 15 % physical dodge. + chess_char_5_17_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); + const sid = selectedId(chess, def); + const critScale = num(t0.atk_scale, 1); + const critProb = (unit) => (sid === 'skchr_f12yin_3' && unit.skill?.active ? num(bb['talent@prob'], num(t0.prob)) : num(t0.prob)); + return { + skills: lazySkills({ + skchr_f12yin_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), maxTargets: Math.max(1, num(bb.max_target, 2)) } }), + skchr_f12yin_3: () => ({ + kind: 'duration', + mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), + targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 3)) }), + attack: { + hits: 2, + onEachHit({ battle, unit, target, kind }) { + if (kind !== 'main' || !target || !target.alive || target.side !== 'enemy') return; + // "中等力度地推动" (PRTS 备注: on the 2nd hit of each attack — onEachHit runs after both): a radial push by + // the official 力度 − 重量 distance (Battle.push) + battle.push(target, num(bb['attack@force'], 1), { from: unit }); + }, + }, + }), + }), + skill: { + kind: 'toggle', + mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }), + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + attack: { hitAllBlocked: true }, + }, + trait: { + dmgMul: (b, u) => (u.mem.crit ? critScale : 1), + afterHit: (b, u, t) => { + if (!u.mem.crit || !t || !t.alive || !num(t0.atk)) return; + b.addBuff(t, { key: 'f12yin:punch', duration: num(t0.duration, 3), mods: { atkPct: num(t0.atk) }, visible: true, status: 'weaken' }); + }, + }, + talents: [ + { install(battle, unit) { // 巨力重拳: one roll per attack + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit) return; + const p = critProb(unit); + unit.mem.crit = p > 0 && battle.rng.chance(p); + if (unit.mem.crit) battle.fx('crit', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + } }, + { install(battle, unit) { permBuff(battle, unit, 'f12yin:body', { defPct: num(t1.def), dodgePhys: num(t1.prob) }); } }, + ], + install(battle, unit) { + // FGT-X: 拥有15%的物理闪避 (an own dodge source: rolls independently of 强壮肉体) + if (num(tb.prob) > 0) permBuff(battle, unit, 'f12yin:moduleX', { dodgePhys: num(tb.prob) }); + const aspd = num(tb.attack_speed); + if (!aspd) return; + whileOn(battle, unit, 0.2, () => { if (unit.hpRatio > HALF_HP) battle.addBuff(unit, { key: 'f12yin:module', duration: 0.3, mods: { aspd } }); }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_18-gvial2.js b/server/sim/content/kits/ops/chess_char_5_18-gvial2.js new file mode 100644 index 00000000..27d9afa1 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_18-gvial2.js @@ -0,0 +1,48 @@ +// server/sim/content/kits/ops/chess_char_5_18-gvial2.js — 百炼嘉维尔 (char_1026_gvial2) kit, tier 5 (hidden). +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { num, talent, traitBb, skillGrid, mods, whileOn } from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 百炼嘉维尔 — S2 链锯强袭 (33/36 s): skill range, ATK/DEF +; hits on unblocked enemies drag them in front of her. + // T1 战地巨斧: ATK/DEF +10 %, +4 % per extra blocked enemy. T2 医学背景: healing received +20 % (+40 % below 50 %). + // Module (elite): ×1.1 vs blocked enemies. + chess_char_5_18_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); + const force = num(bb['attack@force'], 1); + const blockedMul = num(tb.atk_scale); + return { + skill: { + kind: 'duration', + mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt) }), + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + attack: { + onHit({ battle, unit, target }) { + if (!target || !target.alive || target.side !== 'enemy' || target.blockedBy) return; + battle.pullToFront(target, unit, force); // the official 力度 − 重量 pull (Battle.pullToFront) + }, + }, + }, + trait: blockedMul > 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedMul : 1) } : undefined, + talents: [ + { install(battle, unit) { // 战地巨斧 + const apply = () => { + const extra = Math.max(0, unit.blocking.length - 1); + if (unit.mem.axeN === extra && unit.findBuff('gvial2:axe')) return; + unit.mem.axeN = extra; + battle.addBuff(unit, { key: 'gvial2:axe', mods: mods({ atkPct: num(t0.atk) + num(t0.atk_add) * extra, defPct: num(t0.def) + num(t0.def_add) * extra }) }); + }; + battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.axeN = -1; apply(); } }, { owner: unit }); + whileOn(battle, unit, 0.2, apply); + } }, + { install(battle, unit) { // 医学背景 + battle.on('heal', (c) => { + if (c.target !== unit) return; + c.amount *= unit.hpRatio < num(t1.hp_ratio, 0.5) ? num(t1.heal_scale_2, 1) : num(t1.heal_scale_1, 1); + }, { owner: unit }); + } }, + ], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_19-mlynar.js b/server/sim/content/kits/ops/chess_char_5_19-mlynar.js new file mode 100644 index 00000000..9791ac44 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_19-mlynar.js @@ -0,0 +1,128 @@ +// server/sim/content/kits/ops/chess_char_5_19-mlynar.js — 玛恩纳 (char_4064_mlynar) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { + num, on, inRange, talent, skillGrid, batPct, mods, inFaction, isOp, selectedId, lazySkills, skillRange, leaderOf, + whileOn, permBuff, +} from '../shared/tier5.js'; + +/** 玛恩纳 游侠 "周围存在3名及以上敌人" radius (8-neighbourhood). */ +const AROUND_RADIUS = 1.5; +const isKazimierz = (u) => inFaction(u, 'kazimierzShip', ['kazimierz']); + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 玛恩纳 — librator (ramp to +200 % ATK while idle). S3 未照耀的荣光 (26 s, CUSTOM_RANGE): skill range, trait ×2, 5 + // targets at 125/150 % ATK phys, air units too (PRTS 备注 "※可对空"; player report B4 after 0.1.0: on a flying wave the + // cast hit nothing for 26 s); enemies in range take +10/11 % of his ATK true damage from every Kazimierz attack. Per + // PRTS 备注 the trait bonus drops by 10 % (per_kill_reduce, absolute: +400 % → +390 %) for each enemy knocked out by + // his own attack (or the damage it carries — not 无动于衷's reflection, not a mark another operator's attack set off), + // settled after that attack, never below +0 %. T1 游侠: ×1.1 ATK on attacks (×1.15 and −15 % damage taken with ≥3 + // enemies around). T2 无动于衷: taunt +1, Kazimierz ops reflect 15 % of his ATK as true damage when attacked. + // S1 未声张的怒火 (duration, SEARCH): attacks attack@atk_scale × ATK, DEF +. S2 未宽解的悲哀 (duration): skill range, + // BAT +0.3 s, attacks attack@atk_scale × ATK twice; a kill of his own attacks during the skill keeps the trait ramp + // when it ends. S1 / S2 have no 对空 note: ground only, like his trait. + chess_char_5_19_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1); + const sid = selectedId(chess, def); + const up = num(bb.trait_up, 1), perKill = num(bb.per_kill_reduce); + // S3: the trait bonus = ramp × trait_up + per_kill_reduce × kills, ≥ 0 — the trait's own ramp buff stays, the + // difference goes into `mlynar:traitUp` (negative once the kills take the bonus below the ramp) + const applyUp = (battle, unit) => { + const ramp = num(unit.trait.ramp); + const extra = unit.mem.mlyUp ? Math.max(0, ramp * up + perKill * num(unit.mem.mlyKills)) - ramp : 0; + if (extra) battle.addBuff(unit, { key: 'mlynar:traitUp', mods: { atkPct: extra } }); + else battle.removeBuff(unit, 'mlynar:traitUp'); + }; + /** + * "仅自身普通攻击(与该次攻击附带的伤害)击倒非角色类单位" (PRTS S2 / S3 备注): `onKill(victim)` for each enemy he + * knocks out, while his skill runs, that his current attack hit (or the mark it set off, tagged 'mlynarOwn') — so a + * kill by the damage that attack carries (天马之枪, the 卡西米尔 bond's true damage, the mark) counts too, in any + * hook order; `onAttack()` after each of his attacks ("加成降低于当次攻击后统一结算"), which also closes the set. + * 无动于衷's reflection and a mark another operator's attack set off happen outside his attack: never counted. + */ + const ownKills = (battle, unit, onKill, onAttack = null) => { + const hit = new Set(); + battle.on('damaged', (c) => { + if (c.source === unit && (c.dmg?.isAttack || (c.dmg?.tags || []).includes('mlynarOwn'))) hit.add(c.target); + }, { owner: unit, priority: 1000 }); + battle.on('kill', (c) => { if (c.killer === unit && hit.has(c.victim) && c.victim.side === 'enemy' && unit.skill?.active) onKill(c.victim); }, { owner: unit }); + battle.on('attack', (c) => { if (c.attacker !== unit) return; hit.clear(); if (onAttack) onAttack(); }, { owner: unit }); + }; + return { + skills: lazySkills({ + skchr_mlynar_1: () => ({ kind: 'duration', mods: mods({ defPct: num(bb.def) }), attack: { atkScale: num(bb['attack@atk_scale'], 1) } }), + skchr_mlynar_2: () => ({ + kind: 'duration', mods: mods({ batPct: batPct(bb.base_attack_time, chess) }), + targeting: skillRange(chess, def), + attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: 2 }, + }), + }), + skill: { + kind: 'duration', + targeting: { ...(skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : {}), canHitFly: true }, + attack: { atkScale: num(bb['attack@atk_scale'], 1), maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) }, + onStart({ battle, unit }) { + unit.mem.mlyUp = true; unit.mem.mlyKills = 0; unit.mem.mlyPending = 0; + applyUp(battle, unit); + battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 2, skill: 'mlynar' }); + }, + onEnd({ battle, unit }) { unit.mem.mlyUp = false; unit.mem.mlyPending = 0; battle.removeBuff(unit, 'mlynar:traitUp'); }, + }, + trait: { dmgMul: (b, u) => (num(u.mem.mlyNear) >= num(t0.cnt, 3) ? num(t0.atk_scale_up, 1) : num(t0.atk_scale_base, 1)) }, + talents: [ + { install(battle, unit) { // 游侠 + const dr = num(t0.damage_resistance); + whileOn(battle, unit, 0.2, () => { + unit.mem.mlyNear = battle.foesInRadius(unit.x, unit.y, AROUND_RADIUS).length; + if (dr > 0 && unit.mem.mlyNear >= num(t0.cnt, 3)) battle.addBuff(unit, { key: 'mlynar:ranger', duration: 0.3, mods: { dmgTakenMul: 1 - dr } }); + }); + } }, + { install(battle, unit) { // 无动于衷 + permBuff(battle, unit, 'mlynar:unmoved', { taunt: num(t1.taunt_level) }); + const scale = num(t1.atk_scale); + if (!(scale > 0)) return; + battle.on('damaged', (c) => { + const a = c.target, src = c.source; + if (a.side !== 'ally' || !isOp(a) || !isKazimierz(a) || !src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return; + if (!on(unit) || leaderOf(battle, unit) !== unit) return; + battle.dealDamage(unit, src, { amount: unit.s.atk * scale, type: 'true', canDodge: false, tags: ['talent', 'reflect'] }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + if (sid === 'skchr_mlynar_2') { + // 技能期间若击倒敌人,技能结束时特性效果不重置: the librator trait resets the ramp on skillEnd (priority 0) — + // remember it before and put it back after + battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.mlyKeep = false; }, { owner: unit }); + ownKills(battle, unit, () => { unit.mem.mlyKeep = true; }); + battle.on('skillEnd', (c) => { + if (c.unit === unit) unit.mem.mlyRamp = unit.mem.mlyKeep && c.reason !== 'death' ? num(unit.trait.ramp) : null; + }, { owner: unit, priority: 100 }); + battle.on('skillEnd', (c) => { + if (c.unit !== unit || unit.mem.mlyRamp == null) return; + unit.trait.ramp = unit.mem.mlyRamp; + unit.mem.mlyRamp = null; + if (unit.trait.ramp > 0 && unit.alive) battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } }); + }, { owner: unit, priority: -100 }); + } + if (sid && sid !== 'skchr_mlynar_3') return; + ownKills(battle, unit, () => { unit.mem.mlyPending = num(unit.mem.mlyPending) + 1; }, () => { + if (!unit.mem.mlyPending || !unit.mem.mlyUp) return; + unit.mem.mlyKills = num(unit.mem.mlyKills) + unit.mem.mlyPending; + unit.mem.mlyPending = 0; + applyUp(battle, unit); + }); + const extra = num(bb.atk_scale); + if (!(extra > 0)) return; + battle.on('damaged', (c) => { + const src = c.source, e = c.target; + if (!src || src.side !== 'ally' || !isOp(src) || !isKazimierz(src) || !c.dmg?.isAttack || c.type === 'element') return; + if (e.side !== 'enemy' || !e.alive || !unit.skill?.active || !on(unit) || !inRange(unit, e)) return; + const tags = src === unit ? ['skill', 'mlynarMark', 'mlynarOwn'] : ['skill', 'mlynarMark']; + battle.dealDamage(unit, e, { amount: unit.s.atk * extra, type: 'true', canDodge: false, isSkill: true, tags }); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_20-aglina.js b/server/sim/content/kits/ops/chess_char_5_20-aglina.js new file mode 100644 index 00000000..0692a79c --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_20-aglina.js @@ -0,0 +1,72 @@ +// server/sim/content/kits/ops/chess_char_5_20-aglina.js — 安洁莉娜 (char_291_aglina) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { + AURA_IV, AURA_DUR, num, talent, skillGrid, batPct, mods, selectedId, lazySkills, whileOn, +} from '../shared/tier5.js'; + +/** 安洁莉娜 S3 失重: weight (massLevel) reduction while weightless. PRTS: "重量下降一个等级". */ +const WEIGHTLESS_MASS = 1; +/** "攻击范围内存在敌人时,技力自然恢复速度+X/秒" (modules DEC-X). */ +function enemySpUp(battle, unit, key, sp) { + if (!(sp > 0)) return; + whileOn(battle, unit, AURA_IV, () => { + if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } }); + }); +} + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 安洁莉娜 — slower (no normal attack while the skill is off). S3 秘杖·反重力模式 (14/18 s, SEARCH): every enemy on the + // field is weightless, skill range, ATK +, 4/5 targets. T1 加速力场: all allies ASPD +7. T2 兼职工作: while the skill is + // off, all allies regenerate 20 HP/s. Module (elite): longer sluggish (trait bb, profession tunables). + // S1 秘杖·速充模式 (duration, attack SP): ATK +; she attacks normally with S1 ("技能未开启时无法普通攻击" is only in the + // S2/S3 texts). S2 秘杖·微粒模式 (duration, SEARCH): attack interval ×base_attack_time ("极大幅度缩短": a positive value + // described as a shortening is the new interval ratio, tier1 batMod convention), each attack damage_scale × ATK arts. + // Module DEC-X (elite): SP +0.2/s with an enemy in range. + chess_char_5_20_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); + const sid = selectedId(chess, def); + const ratioBat = (v) => (num(v) > 0 && num(v) < 1 ? num(v) - 1 : batPct(v, chess)); + const weightless = (battle, unit) => { + const left = Math.max(0.1, num(unit.skill?.timeLeft, 1)); + for (const e of battle.enemies) { + if (!e.alive || e.findBuff('aglina:weightless')) continue; + // 失重: weight −1 level (engine mod `massFlat`; massLevel never drops below 0) + battle.addBuff(e, { key: 'aglina:weightless', status: 'weightless', visible: true, duration: left, data: { src: unit }, mods: { massFlat: -WEIGHTLESS_MASS } }); + } + }; + return { + skills: lazySkills({ + skchr_aglina_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk) }) }), + skchr_aglina_2: () => ({ kind: 'duration', mods: mods({ batPct: ratioBat(bb.base_attack_time) }), attack: { atkScale: num(bb.damage_scale, 1) } }), + }), + skill: { + kind: 'duration', + mods: mods({ atkPct: num(bb.atk) }), + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + attack: { maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) }, + onStart({ battle, unit }) { unit.mem.aglAcc = 0; weightless(battle, unit); battle.fx('weightless', { x: unit.x, y: unit.y, id: unit.id }); }, + onTick({ battle, unit, dt }) { unit.mem.aglAcc += dt; if (unit.mem.aglAcc >= AURA_IV) { unit.mem.aglAcc = 0; weightless(battle, unit); } }, + onEnd({ battle, unit }) { + for (const e of battle.enemies) { const b = e.findBuff('aglina:weightless'); if (b && b.data.src === unit) battle.removeBuff(e, b); } + }, + }, + trait: sid === 'skchr_aglina_1' ? undefined : { noAttackUnlessSkill: true }, + install(battle, unit) { enemySpUp(battle, unit, 'aglina:module', num(tm.sp_recovery_per_sec)); }, + talents: [ + { install(battle, unit) { // 加速力场 + const aspd = num(t0.attack_speed); + if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); }); + } }, + { install(battle, unit) { // 兼职工作 + const hp = num(t1.hp_recovery_per_sec); + // an HP-regeneration attribute, so 禁疗 does not stop it — PRTS 备注 "生命回复的提供方式为增加目标的'生命回复速度'属性, + // 不受治疗加成和禁疗影响" (异常效果 禁疗: "增减生命回复速度…的效果不会被识别为治疗类能力"); a 孤立 unit (炎佑) is not + // selected (Battle.alliesFor) + if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); }); + } }, + ], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_21-sntlla.js b/server/sim/content/kits/ops/chess_char_5_21-sntlla.js new file mode 100644 index 00000000..7fa73075 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_21-sntlla.js @@ -0,0 +1,78 @@ +// server/sim/content/kits/ops/chess_char_5_21-sntlla.js — 寒檀 (char_341_sntlla) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { toLocal } from '../../../dir.js'; +import { num, talent, skillGrid, batPct, mods, lazySkills, whileOn } from '../shared/tier5.js'; + +/** 寒檀 S2 icicle splash radius (PRTS 备注 "冰凌溅射半径1.5"). */ +const ICICLE_RADIUS = 1.5; +/** 寒檀 S2 icicle fall time (s) between the attack and the impact. [ASSUMED] */ +const ICICLE_DELAY = 0.3; +/** 寒檀 S2 icicle lines, lateral offsets in her facing-RIGHT frame: ① the line on her left, ② on her right, ③ her own, then ①. */ +const ICICLE_ROWS = [1, -1, 0]; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 寒檀 — S2 “女巫之泪” (15 s): skill range, BAT −2.4 s; attacks become icicles on random tiles of the range (cycling left + // row → right row → own row): 65/75 % ATK arts + 1 s cold to every enemy within 1.5 of the impact. T1 生于冰寒: after 20 s on the field ATK +15 % and 抵抗 (negative + // statuses on her last half as long). + chess_char_5_21_a: (bb, chess, def) => { + const t0 = talent(chess, 0); + const scale = num(bb['attack@atk_scale'], 1), cold = num(bb['attack@cold']); + const resist = Math.min(1, Math.max(0, -num(t0.one_minus_status_resistance))); // share of the duration removed + return { + // S1 迅捷打击·γ型 (duration): ATK +, ASPD +. + skills: lazySkills({ + 'skcom_quickattack[3]': () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }), + }), + skill: { + kind: 'duration', + mods: mods({ batPct: batPct(bb.base_attack_time, chess) }), + targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + attack: { noAttack: true }, + onStart({ unit }) { unit.mem.iceCd = 0; unit.mem.iceRow = 0; }, + onTick({ battle, unit, dt }) { + unit.mem.iceCd -= dt; + if (unit.mem.iceCd > 0 || !unit.canAct || unit.s.flags.disarm) return; + if (!battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) return; + unit.mem.iceCd = unit.s.interval; + // PRTS 备注: icicles fall on a random tile of ① the row on her left, ② the row on her right, ③ her own row, … + let k = null; + for (let i = 0; i < ICICLE_ROWS.length && k == null; i++) { + const idx = (num(unit.mem.iceRow) + i) % ICICLE_ROWS.length; + const lat = ICICLE_ROWS[idx]; + const keys = (unit.rangeKeys || []).filter((x) => toLocal(Math.floor(x / COLS) - unit.tileR, x % COLS - unit.tileC, unit.dir)[0] === lat); + if (!keys.length) continue; + k = battle.rng.pick(keys); + unit.mem.iceRow = (idx + 1) % ICICLE_ROWS.length; + } + if (k == null) return; + const r = Math.floor(k / COLS), c = k % COLS; + unit.lastAttackAt = battle.time; + unit.stats.attacks++; // "攻击变为…召唤冰凌": every icicle is one of her attacks + battle.fx('iceSpike', { x: c, y: r, id: unit.id, r: ICICLE_RADIUS }); + battle.after(ICICLE_DELAY, () => { + const hit = []; + for (const e of battle.foesInRadius(c, r, ICICLE_RADIUS)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'iceSpike'] }); + if (e.alive && cold > 0) battle.applyStatus(e, 'cold', { duration: cold, source: unit }); + if (e.alive) hit.push(e); + } + // an icicle is an attack: "攻击时" effects (items, bonds) see it with the enemies it hit + if (hit.length && battle.hasHook('attack')) battle.emit('attack', { attacker: unit, targets: hit, isSkill: true }); + }, { owner: unit }); + }, + }, + talents: [{ install(battle, unit) { // 生于冰寒 + const after = num(t0.interval, 20); + // + 抵抗 (engine `resist` status: new control statuses last half as long, never compounding with other sources) + whileOn(battle, unit, 0.5, () => { + if (battle.time - unit.deployedAt < after - 1e-9 || unit.findBuff('sntlla:born')) return; + battle.addBuff(unit, { key: 'sntlla:born', visible: true, mods: mods({ atkPct: num(t0.atk) }) }); + if (resist > 0) battle.applyStatus(unit, 'resist', { value: resist, source: unit }); + }); + } }], + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_22-nymph.js b/server/sim/content/kits/ops/chess_char_5_22-nymph.js new file mode 100644 index 00000000..3af4c80b --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_22-nymph.js @@ -0,0 +1,103 @@ +// server/sim/content/kits/ops/chess_char_5_22-nymph.js — 妮芙 (char_4146_nymph) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { hasHp } from '../../../damage.js'; +import { + num, on, inRange, talent, traitBb, maxCharges, mods, selectedId, lazySkills, skillRange, burstSpUp, elementHit, + burstDamageUp, +} from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 妮芙 — S2 怵然震爆 (1/2 charges): next attack 270/300 % ATK arts + 4/5 s fear on the target and an equal arts splash + // (projectile_range), every hit adding 18/22 % of its damage as apoptosis; targets in an apoptosis burst raise T1 to 70/85 %. + // T1 失魂: hitting an enemy in an apoptosis burst → 40 % ATK elemental damage per second until the burst ends. + // T2 窥心钥: an apoptosis burst in range → ATK +2 % (×10). Module (elite): ×damage_scale vs enemies in a burst. + // S1 笞心击 (duration): ATK +; attacks add attack@ep_damage_ratio of their damage as 凋亡损伤 and, on a target in an + // apoptosis burst, attack@extra_ep_damage_scale × ATK elemental damage. S3 心防溃决 (duration): skill range, ATK +, + // ASPD +, 2 targets; attacks on a target in an apoptosis burst deal elemental damage (split_atk_scale × ATK, no arts). + // Module PRI-Y (elite): SP +0.2/s while an enemy in range is in an element burst. + chess_char_5_22_a: (bb, chess, def) => { + const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1); + const sid = selectedId(chess, def); + const ep = num(bb.ep_damage_ratio, num(bb['attack@ep_damage_ratio'])), skillSoul = num(bb.element_atk_scale), soulBase = num(t0.element_atk_scale); + const soul = (battle, unit, e, scale) => { + const burst = e.findBuff('apoptosisBurst'); + if (!burst || !(scale > 0)) return; + const key = `nymph:soul:${unit.id}`; + const cur = e.findBuff(key); + if (cur) { cur.data.scale = Math.max(num(cur.data.scale), scale); return; } + battle.addBuff(e, { + key, duration: Math.max(0.1, burst.timeLeft), interval: 1, data: { scale }, + onTick: ({ unit: x, buff }) => { + if (!x.findBuff('apoptosisBurst')) { battle.removeBuff(x, buff); return; } + elementHit(battle, unit, x, unit.s.atk * num(buff.data.scale), 'nymphSoul', 'apoptosis'); + }, + }); + battle.fx('soul', { x: e.x, y: e.y, id: e.id }); + }; + return { + skills: lazySkills({ + skchr_nymph_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk) }) }), + skchr_nymph_3: () => ({ + kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), + targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 2)) }), + attack: { atkScale: num(bb['attack@split_atk_scale'], 1) }, + }), + }), + skill: { + kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant', + attack: { + atkScale: num(bb.atk_scale, 1), splashRadius: num(bb.projectile_range, 1.5), + onHit({ battle, unit, target, x, y }) { + if (target && target.alive) battle.applyStatus(target, 'fear', { duration: num(bb.fear), source: unit }); + battle.fx('aoe', { x, y, id: unit.id, r: num(bb.projectile_range, 1.5), skill: 'nymph' }); + }, + }, + }, + talents: [ + { install(battle, unit) { // 失魂 (+ the skill's apoptosis rider and 70 % upgrade) + battle.on('damaged', (c) => { + if (c.source !== unit || c.type === 'element' || c.target.side !== 'enemy' || !c.target.alive || !c.dmg) return; + // (S1 has no attack override: its attacks are the skill's while it runs) + const skillAtk = c.dmg.isSkill || (sid === 'skchr_nymph_1' && !!unit.skill?.active); + // (not on a killing blow: the target is at 0 HP here — no burst on the corpse) + if (skillAtk && c.dmg.isAttack && ep > 0 && c.amount > 0 && hasHp(c.target)) { + battle.dealDamage(unit, c.target, { type: 'element', element: 'apoptosis', amount: c.amount * ep, tags: ['skill', 'nymph'] }); + } + if (c.dmg.isAttack && c.target.alive) soul(battle, unit, c.target, c.dmg.isSkill ? Math.max(skillSoul, soulBase) : soulBase); + }, { owner: unit }); + } }, + { install(battle, unit) { // 窥心钥 + battle.on('elementBurst', (c) => { + if (c.element !== 'apoptosis' || c.target.side !== 'enemy' || !on(unit) || !inRange(unit, c.target)) return; + battle.addBuff(unit, { key: 'nymph:key', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 10)), mods: { atkPct: num(t1.atk) } }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + burstDamageUp(battle, unit, num(tb.damage_scale)); + burstSpUp(battle, unit, 'nymph:module', num(tm.sp_recovery_per_sec)); + if (sid === 'skchr_nymph_1') { // S1: extra elemental damage on burst targets (the 凋亡 rider is T1's `ep`) + const extra = num(bb['attack@extra_ep_damage_scale']); + battle.on('damaged', (c) => { + if (c.source !== unit || !unit.skill?.active || !c.dmg?.isAttack || c.type === 'element' || c.type === 'elemental') return; + const e = c.target; + if (e.side !== 'enemy' || !e.alive || !e.findBuff('apoptosisBurst') || !(extra > 0)) return; + elementHit(battle, unit, e, unit.s.atk * extra, 'nymphLash', 'apoptosis'); + }, { owner: unit }); + } + if (sid === 'skchr_nymph_3') { // S3: attacks on a burst target become elemental damage + battle.on('hit', (c) => { + if (c.source !== unit || !unit.skill?.active || !c.dmg.isAttack || !c.target || !c.target.findBuff('apoptosisBurst')) return; + if (c.dmg.type !== 'arts' && c.dmg.type !== 'phys') return; + c.dmg.type = 'elemental'; + c.dmg.element = 'apoptosis'; + c.dmg.canDodge = false; + (c.dmg.tags ||= []).push('nymphBreak'); + }, { owner: unit, priority: -10 }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_5_23-reckpr.js b/server/sim/content/kits/ops/chess_char_5_23-reckpr.js new file mode 100644 index 00000000..b265d8c6 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_5_23-reckpr.js @@ -0,0 +1,50 @@ +// server/sim/content/kits/ops/chess_char_5_23-reckpr.js — 录武官 (char_4196_reckpr) kit, tier 5. +// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md. + +import { isHpLoss } from '../../../damage.js'; +import { + num, on, inRange, talent, traitBb, mods, isOp, lazySkills, instantKind, lowHpHealUp, +} from '../shared/tier5.js'; + +export default { + // --------------------------------------------------------------------------------------------------------------- + // 录武官 — S2 一点关窍 (25 s): ATK +; healed allies get a 10 s buff healing 80/120 HP on every hit taken. + // T1 学成于聚: an op in range starting a skill → +1 SP and ASPD +16 for 8 s. Module (elite): heals below 50 % ×1.15. + // S1 触类旁通 (2/3 charges): the next heal restores heal_scale × ATK and heals max_target allies. + chess_char_5_23_a: (bb, chess, def) => { + const t0 = talent(chess, 0), tb = traitBb(chess); + const dur = num(bb['attack@buff_duration'], 10), value = num(bb['attack@fixed_heal_value']); + return { + skills: lazySkills({ + skchr_reckpr_1: () => ({ kind: instantKind(chess, def), heal: true, attack: { healScale: num(bb.heal_scale, 1), maxTargets: Math.max(1, num(bb.max_target, 2)) } }), + }), + skill: { + kind: 'duration', heal: true, + mods: mods({ atkPct: num(bb.atk) }), + onHit({ battle, unit, target }) { + // "治疗干员后为其施加一个增益": operators only (summons are 友方单位, not 干员) + if (!target || !target.alive || !isOp(target) || !(value > 0)) return; + battle.addBuff(target, { key: 'reckpr:guard', duration: dur, visible: true, data: { src: unit, value } }); + }, + }, + talents: [{ install(battle, unit) { // 学成于聚 + battle.on('skillStart', (c) => { + const a = c.unit; + if (a === unit || !isOp(a) || !on(unit) || !inRange(unit, a)) return; + if (num(t0.prob, 1) < 1 && !battle.rng.chance(num(t0.prob, 1))) return; + unit.skill?.gainSp(num(t0.sp), 'talent'); + if (num(t0.attack_speed)) battle.addBuff(unit, { key: 'reckpr:learn', duration: num(t0.duration, 8), mods: { aspd: num(t0.attack_speed) } }); + }, { owner: unit }); + } }], + install(battle, unit) { + battle.on('damaged', (c) => { + const a = c.target; + if (a.side !== 'ally' || !(c.amount > 0) || a.hp <= 0 || c.type === 'element' || isHpLoss(c.dmg)) return; // (not a 流失) + const b = a.findBuff('reckpr:guard'); + if (b && b.data.src === unit) battle.heal(unit, a, num(b.data.value)); + }, { owner: unit }); + lowHpHealUp(battle, unit, tb); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_01-lemuen.js b/server/sim/content/kits/ops/chess_char_6_01-lemuen.js new file mode 100644 index 00000000..14a61aaa --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_01-lemuen.js @@ -0,0 +1,227 @@ +// server/sim/content/kits/ops/chess_char_6_01-lemuen.js — 蕾缪安 (char_4193_lemuen) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js'; +import { rotateOffset } from '../../../dir.js'; +import { bodyDist, bodyInKeys, bodyKeys } from '../../../body.js'; +import { num, tbb, live, isElite, hasBond, ANY, bstate } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 蕾缪安 chess_char_6_01 (神射手) — S3 礼炮·强制追思; 跨境追缉许可; 逃犯引渡手续 + +/** S3 bombardment: one shell every 0.3 s after the skill ends, for at most 10 s (PRTS S3 note, not in the data). */ +const LEMUEN_SHELL_INTERVAL = 0.3; +const LEMUEN_SHELL_WINDOW = 10; +/** + * Shell flight time [ASSUMED]: the first bombardment lands one interval after the skill ends, so every shell of the + * PRTS count (≤ 33 in the 10 s window) lands inside it; the renderer draws the shell over this time (fx bombardShell). + */ +const LEMUEN_SHELL_FLIGHT = 0.3; + +function ensureWanted(battle) { + const S = bstate(battle); + if (S.wanted) return S.wanted; + const W = S.wanted = { lemuens: new Set(), time: new WeakMap() }; + const activeLem = () => [...W.lemuens].filter(live); + battle.every(0.25, () => { + const lems = activeLem(); + if (!lems.length) return; + const need = Math.min(...lems.map((u) => u.mem.wantedInterval)); + const lat = battle.allyUnits.filter((a) => live(a) && a.kind === 'op' && hasBond(a, 'lateranoShip')); + for (const e of battle.enemies) { + if (!e.alive || e.hidden || !isElite(e) || e.findBuff('lemuen:wanted')) continue; + // "停留超过8秒": a continuous stay — leaving every 拉特兰 range restarts the count + if (!lat.some((a) => a.rangeKeySet && bodyInKeys(e, a.rangeKeySet))) { W.time.delete(e); continue; } + const t = (W.time.get(e) ?? 0) + 0.25; + W.time.set(e, t); + if (t >= need - 1e-9) { + battle.addBuff(e, { key: 'lemuen:wanted', visible: true }); + battle.fx('wanted', { x: e.x, y: e.y, id: e.id }); + } + } + }); + battle.on('hit', (ctx) => { + const s = ctx.source, t = ctx.target; + if (!s || !t || s.side !== 'ally' || t.side !== 'enemy' || !hasBond(s, 'lateranoShip')) return; + if (!ctx.dmg.isAttack && !ctx.dmg.isSkill) return; + if (!t.findBuff('lemuen:wanted')) return; + const lems = activeLem(); + if (lems.length) ctx.dmg.mul *= Math.max(...lems.map((u) => u.mem.wantedScale)); + }); + return W; +} + +function lemuen(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const aim = Math.max(0.05, num(bb['attack@aim_interval'], 0.5)); + const d1 = num(bb['attack@dist_1'], 0.8); + const d2 = num(bb['attack@dist_2'], num(bb['attack@projectile_range'], 1.5)); + const s1 = num(bb['attack@proj_atk_scale_1'], 1), s2 = num(bb['attack@proj_atk_scale_2'], 1); + const pickLock = (battle, unit, locks) => { + const cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); + if (!cands.length) return null; + sortEnemyTargets(battle, unit, cands, 'lowDef'); + const cnt = (e) => locks.reduce((n, L) => n + (L.e === e ? 1 : 0), 0); + let best = null; + for (const e of cands) if (!best || cnt(e) < cnt(best)) best = e; + return best; + }; + // S3 bombardment (PRTS 蕾缪安 S3 note — the timing is not in the blackboard): after the skill ends ONE shell every + // LEMUEN_SHELL_INTERVAL s, in lock order, on a random point of the square of side 2 × emit_offset (PRTS "边长0.4", + // emit_offset 0.2) around its lock mark — the locked enemy while it is on the field ("持续追踪锁定目标"), else the spot + // it left ("锁定标记会留在原地") — landing LEMUEN_SHELL_FLIGHT s later; each shell hits every enemy within dist_2 once + // (limited_hit_time 1): proj_atk_scale_1 × ATK within dist_1 of its point, proj_atk_scale_2 × ATK beyond, all with the + // ATK cached when the skill ended ("缓存攻击力"); at most LEMUEN_SHELL_WINDOW s of shells ("最多产生33次轰炸"). + const spread = Math.max(0, num(bb['attack@emit_offset'], 0.2)); + const markOf = (L) => { + const e = L.e; + if (!L.gone && (!e.alive || !e.hidden)) { L.x = e.x; L.y = e.y; if (!e.alive) L.gone = true; } + return L; + }; + const blast = (battle, unit, atk, x, y) => { + battle.fx('bombard', { x, y, id: unit.id, r: d2 }); + for (const e of battle.foesInRadius(x, y, d2)) { + if (e.s.flags.untargetable) continue; + const d = bodyDist(e, x, y); + battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] }); + } + }; + const bombard = (battle, unit, locks) => { + const atk = unit.s.atk; + const seq = unit.deploySeq; + const n = Math.min(locks.length, Math.floor(LEMUEN_SHELL_WINDOW / LEMUEN_SHELL_INTERVAL + 1e-9)); + const fire = (i) => { + // knocked out / withdrawn after the skill ended: the shells not fired yet are dropped (those in the air land) + if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return; + const L = markOf(locks[i]); + // the random offset is drawn in her facing frame (the square turns onto itself): a turned board plays alike + const [oy, ox] = rotateOffset(battle.rng.range(-spread, spread), battle.rng.range(-spread, spread), unit.dir); + const x = L.x + ox, y = L.y + oy; + battle.fx('bombardShell', { x, y, id: unit.id, r: d2, t: LEMUEN_SHELL_FLIGHT, i }); + battle.after(LEMUEN_SHELL_FLIGHT, () => blast(battle, unit, atk, x, y), { owner: unit }); + if (i + 1 < n) battle.after(LEMUEN_SHELL_INTERVAL, () => fire(i + 1), { owner: unit }); + }; + if (n > 0) fire(0); + }; + // S2 归乡邀约: aimed snipe at a wanted enemy — ATK scale ramps main → fin by ex every interval (at most trig_cnt + // steps, aim_duration s), fired early once ATK × scale > the target's HP + DEF; ignores dodge; one bullet per aim + const aimS = { + main: num(bb['attack@main_atk_scale'], 1), ex: num(bb['attack@ex_atk_scale']), fin: num(bb['attack@fin_atk_scale'], 1), + iv: Math.max(0.05, num(bb['attack@interval'], 0.25)), steps: Math.floor(num(bb['attack@trig_cnt'], 10)), dur: num(bb['attack@aim_duration'], 2.5), + }; + const endAim = (battle, unit) => { unit.mem.lemAim = null; battle.removeBuff(unit, 'lemuen:aim'); }; + const skills = { + // S1 重逢问候: ammo 5, attacks at attack@atk_scale × ATK on one extra target + skchr_lemuen_1: { + kind: 'ammo', + ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))), + attack: { atkScale: num(bb['attack@atk_scale'], 1) }, + targeting: { maxTargets: 2 }, + }, + skchr_lemuen_2: { + kind: 'ammo', + ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 6))), + mods: { aspd: num(bb.attack_speed), atkPct: num(bb.atk) }, + onStart({ battle, unit }) { endAim(battle, unit); }, + onTick({ battle, unit, skill, dt }) { + const m = unit.mem; + let A = m.lemAim; + if (A && (!A.e.alive || A.e.hidden || !A.e.findBuff('lemuen:wanted'))) { endAim(battle, unit); A = null; } + if (!unit.canAct) return; + if (!A) { + const cands = battle.enemies.filter((e) => e.alive && !e.hidden && e.findBuff('lemuen:wanted') && canTargetEnemy(unit, e, ANY)); + if (!cands.length || skill.ammoLeft <= 0) return; + sortEnemyTargets(battle, unit, cands, 'lowDef'); + A = m.lemAim = { e: cands[0], t: 0 }; + battle.addBuff(unit, { key: 'lemuen:aim', flags: { disarm: true } }); // aiming: no normal attack meanwhile + skill.ammoLeft--; // "消耗子弹对其瞄准" + battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); + battle.fx('lock', { x: A.e.x, y: A.e.y, id: A.e.id, src: unit.id }); + } + A.t += dt; + const scale = Math.min(aimS.fin, aimS.main + aimS.ex * Math.min(aimS.steps, Math.floor((A.t + 1e-9) / aimS.iv))); + const e = A.e; + if (A.t + 1e-9 < aimS.dur && !(unit.s.atk * scale > e.hp + e.s.def)) return; + endAim(battle, unit); + battle.fx('crit', { x: e.x, y: e.y, id: e.id, src: unit.id }); + battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', canDodge: false, isSkill: true, tags: ['skill', 'snipe'] }); + if (skill.active && skill.ammoLeft <= 0) skill.end('ammo'); + }, + onEnd({ battle, unit }) { endAim(battle, unit); }, + }, + }; + return { + skills, + skill: { + kind: 'ammo', + ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))), + attack: { noAttack: true }, + onStart({ unit }) { unit.mem.lemLocks = []; unit.mem.lemAcc = aim; }, + onTick({ battle, unit, skill, dt }) { + const m = unit.mem; + if (!m.lemLocks) return; + for (const L of m.lemLocks) markOf(L); + if (!unit.canAct) return; + m.lemAcc += dt; + if (m.lemAcc + 1e-9 < aim) return; + m.lemAcc -= aim; + const e = pickLock(battle, unit, m.lemLocks); + if (!e) { if (m.lemLocks.length) skill.end('ammo'); return; } + m.lemLocks.push({ e, x: e.x, y: e.y }); + battle.fx('lock', { x: e.x, y: e.y, id: e.id, src: unit.id }); + skill.ammoLeft--; + battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); + if (skill.active && skill.ammoLeft <= 0) skill.end('ammo'); + }, + onEnd({ battle, unit, reason }) { + // the shells follow one by one after the end (bombard); knocked out / withdrawn mid-lock ('death'): none + const locks = unit.mem.lemLocks || []; + unit.mem.lemLocks = null; + if (reason !== 'death' && unit.alive) bombard(battle, unit, locks); + }, + }, + // elite module: "攻击的敌人未被击倒时自身额外获得1点技力" + trait: num(tb.sp) > 0 ? { + afterHit(battle, unit, target) { if (target && target.alive && unit.skill && !unit.skill.active) unit.skill.gainSp(num(tb.sp), 'trait'); }, + } : null, + talents: [ + { install(battle, unit) { // 跨境追缉许可 + unit.mem.wantedInterval = num(t0.interval, 8); + unit.mem.wantedScale = num(t0.damage_scale, 1); + ensureWanted(battle).lemuens.add(unit); + let sig = null; + battle.on('tick', () => { // wanted targets' tiles join her range (engine extra range keys, kept across rebuilds) + if (!live(unit)) return; + const keys = []; + for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('lemuen:wanted')) keys.push(...bodyKeys(e)); + const s = keys.join(','); + if (s === sig) return; + sig = s; + battle.setExtraRange(unit, keys); + }, { owner: unit }); + } }, + { install(battle, unit) { // 逃犯引渡手续 + const iv = num(t1.interval, 20), atk = num(t1.atk), add = Math.floor(num(t1.add_count)), exAdd = Math.floor(num(t1.ex_add_count)); + const ready = () => live(unit) && battle.time - unit.deployedAt >= iv - 1e-6; + battle.on('deploy', ({ unit: u }) => { + if (u !== unit) return; + const seq = unit.deploySeq; + battle.after(iv, () => { + if (!live(unit) || unit.deploySeq !== seq) return; + if (atk) battle.addBuff(unit, { key: 'lemuen:extradition', mods: { atkPct: atk } }); + battle.fx('talent', { x: unit.x, y: unit.y, id: unit.id, name: 'extradition' }); + }, { owner: unit }); + }, { owner: unit }); + battle.on('skillStart', ({ unit: u, skill }) => { + if (!ready() || !skill || skill.kind !== 'ammo') return; + if (u === unit) { if (add > 0) skill.addAmmo(add); } + else if (exAdd > 0 && u.side === 'ally' && u.kind === 'op' && hasBond(u, 'lateranoShip')) skill.addAmmo(exAdd); + }, { owner: unit }); + } }, + ], + }; +} + +export default { + chess_char_6_01_a: lemuen, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_02-sbell2.js b/server/sim/content/kits/ops/chess_char_6_02-sbell2.js new file mode 100644 index 00000000..2bc0d456 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_02-sbell2.js @@ -0,0 +1,228 @@ +// server/sim/content/kits/ops/chess_char_6_02-sbell2.js — 圣聆初雪 (char_1046_sbell2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { bodyInKeys } from '../../../body.js'; +import { num, tbb, moduleBb, live, keyOf, enemiesIn, isTok, instantKind, AROUND8, aura } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 圣聆初雪 chess_char_6_02 (阵法术师) — S3 群山俯首; 无垠的雪景; 圣山的祝福; module 千分之一的心 + +function sbell2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess); + const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; + const maxL = Math.max(1, Math.floor(num(t0.max_cast_cnt, 5))); + const iceId = (chess?.tokens || []).find((t) => /icetgt/.test(String(t))) || 'token_10058_sbell2_icetgt'; + /** + * S2 霜涛覆岭 while it runs (`unit.mem.sbellS2`): a layer landing on a tile already at max snow spreads one layer to + * a neighbouring ground tile (at most max_cast_tile_count spreads per activation); ground enemies on snow take + * s2_magic_scale × ATK arts per second; an enemy leaving snow gets `cold` s of 寒冷; "积雪在目标点积累至5层时,使目标点变为 + * 冻结状态": a protection point (the blue gate) reaching max snow turns into the frozen 保护目标 (freezeTile). + */ + const addSnow = (battle, unit, k) => { + const snow = unit.mem.snow; + if (!snow) return false; + const r = (k / COLS) | 0, c = k % COLS; + if (!battle.grid.inRect(r, c) || !battle.grid.groundPassable(r, c, true)) return false; + // PRTS 备注 "存在自身的该召唤物的地块不会积雪" + if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive && t.tileR === r && t.tileC === c)) return false; + const S2 = unit.mem.sbellS2; + const cur = snow.get(k) ?? 0; + // a protection point already at max snow (its freeze waited for her other token to go) freezes on its next layer + if (S2 && cur >= maxL && freezeTile(battle, unit, k)) return true; + if (cur >= maxL) { + if (!S2 || S2.spreadLeft <= 0) return false; + // "积雪超过5层时会向周围扩散一层": the thinnest neighbouring ground tile gets the layer + let best = null, bl = Infinity; + for (const [dr, dc] of [[0, 1], [1, 0], [0, -1], [-1, 0]]) { + const nr = r + dr, nc = c + dc; + if (!battle.grid.inRect(nr, nc) || !battle.grid.groundPassable(nr, nc, true)) continue; + const L = snow.get(nr * COLS + nc) ?? 0; + if (L < maxL && L < bl) { bl = L; best = nr * COLS + nc; } + } + if (best == null) return false; + S2.spreadLeft--; + return addSnow(battle, unit, best); + } + snow.set(k, cur + 1); + if (S2 && cur + 1 >= maxL) freezeTile(battle, unit, k); + return true; + }; + /** + * "使目标点变为冻结状态" — PRTS 圣聆初雪 S2 备注: "目标点冻结的实际效果为令圣聆初雪在该地块上召唤一个保护目标(冻结状态)(无视部署 + * 属性),并去除相应地块上的积雪", "可以被'变为冻结状态'的目标点包括常规的保护目标点与促融共竞的保护目标点"; the token's page: + * "技能发动后于保护目标叠加5层积雪". So only a protection point (a goal tile, glyph E) freezes, whatever its deploy attributes + * (nobody may stand on a gate): her token icetgt (blocks 3; one at a time, maxDeployCount 1) appears on it and blocks the + * enemies walking in, and that tile's snow is used up. Until 0.1.3 (community report #32): any free tile a melee + * operator could stand on froze at max snow, and the gate never did. Returns true when the token appeared. + */ + const freezeTile = (battle, unit, k) => { + const r = (k / COLS) | 0, c = k % COLS; + if (battle.grid.tile(r, c).special !== 'end') return false; + if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive)) return false; + if (battle.isReservedTile(r, c)) return false; + const ice = battle.spawnToken(unit, iceId, r, c); + if (!ice) return false; + unit.mem.snow.delete(k); + battle.fx('summon', { x: c, y: r, id: ice.id, src: unit.id }); + return true; + }; + const talents = [ + { install(battle, unit) { // 无垠的雪景 (+ S2 snow rules) + const ivN = num(t0.interval, 5.5), ivS = num(bb.interval, ivN); + const slow = num(t0.move_speed), scale = num(t0.talent_magic_scale); + const snow = new Map(); + unit.mem.snow = snow; + const last = new WeakMap(); + let acc = 0, dotAcc = 0; + const lay = () => { + let n = 0; + for (const k of unit.rangeKeys || []) if (addSnow(battle, unit, k)) n++; + if (n) battle.fx('snow', { x: unit.x, y: unit.y, id: unit.id, tiles: n }); + }; + battle.on('deploy', ({ unit: u }) => { + if (u !== unit) return; + snow.clear(); + acc = 0; + if (num(t0.first_snow) > 0) lay(); + }, { owner: unit }); + battle.on('tick', ({ dt }) => { + if (!live(unit)) return; + acc += dt; + if (acc + 1e-9 >= (unit.skill && unit.skill.active ? ivS : ivN)) { acc = 0; lay(); } + if (!snow.size) return; + const S2 = unit.mem.sbellS2; + for (const e of battle.enemies) { + if (!e.alive || e.hidden) continue; + const k = keyOf(e); + const prev = last.get(e); + if (prev !== k) { + last.set(e, k); + const hadSnow = prev !== undefined && !e.isFlying && snow.has(prev); + if (hadSnow) snow.delete(prev); // first enemy leaving clears it + if (!e.isFlying && snow.has(k) && scale > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'snow'] }); + if (S2 && hadSnow && !snow.has(k) && e.alive && S2.cold > 0) battle.applyStatus(e, 'cold', { duration: S2.cold, source: unit }); + } + const L = snow.get(k); + if (L && e.alive && slow) battle.addBuff(e, { key: 'sbell2:snow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow * L) }, source: unit }); + } + if (!S2 || !(S2.dot > 0)) { dotAcc = 0; return; } + dotAcc += dt; + if (dotAcc + 1e-9 < 1) return; + dotAcc -= 1; + for (const e of battle.enemies) { + if (e.alive && !e.hidden && !e.isFlying && bodyInKeys(e, snow)) battle.dealDamage(unit, e, { amount: unit.s.atk * S2.dot, type: 'arts', isSkill: true, tags: ['skill', 'snow'] }); + } + }, { owner: unit }); + } }, + { install(battle, unit) { // 圣山的祝福 + const cold = num(t1.cold), selfFreeze = num(t1.freeze), eFreeze = num(t1.c2e_freeze), hr = num(t1.hp_ratio, 1); + battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.blessUsed = false; }, { owner: unit }); + battle.on('damaged', (ctx) => { + if (ctx.target !== unit || !(cold > 0) || ctx.type === 'element') return; + const s = ctx.source; + if (s && s.side === 'enemy' && s.alive) battle.applyStatus(s, 'cold', { duration: cold, source: unit }); + }, { owner: unit }); + battle.on('fatal', (ctx) => { + if (ctx.unit !== unit || ctx.prevented || unit.mem.blessUsed) return; + ctx.prevented = true; + unit.mem.blessUsed = true; + unit.hp = Math.max(1, unit.s.maxHp * hr); + if (selfFreeze > 0) battle.applyStatus(unit, 'freeze', { duration: selfFreeze, source: unit, force: true }); + if (eFreeze > 0) for (const e of enemiesIn(battle, unit)) battle.applyStatus(e, 'freeze', { duration: eFreeze, source: unit }); + battle.fx('blessing', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit, priority: -50 }); + } }, + ]; + if (num(mod.damage_scale) > 0) { + talents.push({ install(battle, unit) { // module: +3 % damage per enemy in range (max 5) + const per = num(mod.damage_scale), cap = Math.max(1, Math.floor(num(mod.max_valid_stack_cnt, 5))); + aura(battle, unit, 0.25, () => { + const n = Math.min(cap, enemiesIn(battle, unit).length); + if (n > 0) battle.addBuff(unit, { key: 'sbell2:heart', mods: { dmgDealtMul: 1 + per * n }, duration: 0.4, refresh: 'replace' }); + }); + } }); + } + const skills = { + // S1 铃音吹雪 (SEARCH trigger, 2 charges): atk_scale × ATK arts + `cold` s of 寒冷 on every enemy in range, pushed + // (force 1 = 中力) along her direction — PRTS 备注 "固定方向推动(不会因角度过大或距离过近而变化方向与力度),且仅对地面 + // 单位产生推力" (Battle.push fixed, official 力度 − 重量 distance); then one snow layer spreads forward over the ground + // (≤ trig_cnt tiles) + skchr_sbell2_1: { + kind: instantKind(def), + // data trigger SEARCH (the 阵法术师 row, every MANUAL skill of the class: "在初始攻击范围内存在敌人时释放技能") = + // an enemy inside her initial range, checked every tick — what the engine's DEFAULT does for a phalanx + // (noAttackUnlessSkill); an enemy anywhere on the field would dump both charges on an enemy spawning at the gate + trigger: 'DEFAULT', + onStart({ battle, unit }) { + const [fr, fc] = unit.fwd; + const force = num(bb.force); + for (const e of enemiesIn(battle, unit)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); + if (!e.alive) continue; + if (num(bb.cold) > 0) battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: unit }); + if (!e.isFlying) battle.push(e, force, { from: unit, dir: { x: fc, y: fr }, fixed: true }); + } + const n = Math.max(0, Math.floor(num(bb.trig_cnt, 5))); + let laid = 0; + for (let d = 1; d <= n; d++) { + const r = unit.tileR + fr * d, c = unit.tileC + fc * d; + if (!battle.grid.inRect(r, c)) break; + if (addSnow(battle, unit, r * COLS + c)) laid++; + } + battle.fx('frostNova', { x: unit.x, y: unit.y, id: unit.id, tiles: laid }); + }, + }, + // S2 霜涛覆岭 (toggle, 持续时间无限): group attacks at attack@atk_scale_s2 × ATK + the S2 snow rules (addSnow / talent) + skchr_sbell2_2: { + kind: 'toggle', + attack: { atkScale: num(bb['attack@atk_scale_s2'], 1) }, + onStart({ unit }) { + unit.mem.sbellS2 = { spreadLeft: Math.floor(num(bb['talent@max_cast_tile_count'], 20)), dot: num(bb['talent@s2_magic_scale']), cold: num(bb['talent@cold']) }; + }, + onEnd({ battle, unit }) { + unit.mem.sbellS2 = null; + for (const t of battle.allyUnits) if (isTok(t, iceId, unit) && t.alive) battle.retreat(t, { reason: 'expired', permanent: true }); + }, + }, + }; + return { + skills, + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed), resIgnoreFlat: num(bb.magic_resist_penetrate_fixed) }, + ...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}), + attack: { atkScale: num(bb['attack@atk_scale_s3'], 1) }, + onStart({ battle, unit }) { // 立即诱导攻击范围内的所有敌人至自身周围的可达地面,持续 attract_time 秒 + const dur = num(bb.attract_time); + if (!(dur > 0)) return; + let n = 0; + // 诱导 = the engine `attract` status: unblockable, walked (own speed, grid path) to the point, then its route + // resumes; it runs its full time even if she falls meanwhile + for (const e of enemiesIn(battle, unit)) { + if (e.isFlying || e.isBoss || !e.alive) continue; + const goal = lureGoal(battle, unit, e); + if (goal && battle.applyStatus(e, 'attract', { duration: dur, source: unit, point: goal })) n++; + } + battle.fx('lure', { x: unit.x, y: unit.y, id: unit.id, n }); + }, + }, + talents, + }; +} + +/** The nearest ground tile around `unit` to `e` ("自身周围的可达地面"), or null. */ +function lureGoal(battle, unit, e) { + let goal = null, bd = Infinity; + for (const [dr, dc] of AROUND8) { + const r = unit.tileR + dr, c = unit.tileC + dc; + if (!battle.grid.groundPassable(r, c)) continue; + const d = Math.hypot(e.x - c, e.y - r); + if (d < bd - 1e-9) { bd = d; goal = [r, c]; } + } + return goal; +} + +export default { + chess_char_6_02_a: sbell2, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_03-yu.js b/server/sim/content/kits/ops/chess_char_6_03-yu.js new file mode 100644 index 00000000..6adb3907 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_03-yu.js @@ -0,0 +1,155 @@ +// server/sim/content/kits/ops/chess_char_6_03-yu.js — 余 (char_2026_yu) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { canTargetEnemy } from '../../../targeting.js'; +import { + num, bv, tbb, moduleBb, live, opsOf, ANY, onDefaultSkill, selectedSkill, skillGridOf, AROUND8, onElementHit, + elementDmg, aura, +} from '../shared/tier6.js'; + +/** + * "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its + * route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers stay, and so does a + * 自缚 unit (flag `selfBound`: 守墓石像's 转换模式, the 7 huge leaders, the 胄 parts' anchor): PRTS 余 S2 备注 "处于消失状态的/ + * 持有自缚的单位不视为可达目标" (束缚 alone does not exempt it, so not `noMove`). Being a leader is no exemption: PRTS gives + * 卢西恩 and 假想敌:铳 (both sections) 传送抗性 无 — they are teleported like any ground enemy and walk on from his tile (a + * patrol keeps looping: content/bosses.js patrolLoop). 静态刚体 forbids physical movement, not a teleport (the 备注 names + * only 消失 / 自缚 / 免疫传送); every 静态刚体 of the mode flies or is 自缚 anyway. + */ +function teleportEnemy(battle, unit, e) { + if (!e || !e.alive || e.isFlying || e.s.flags.selfBound) return false; + const r = unit.tileR, c = unit.tileC; + if (!battle.grid.groundPassable(r, c)) return false; + const er = Math.round(e.y), ec = Math.round(e.x); + if ((er !== r || ec !== c) && !battle.grid.findPath(er, ec, r, c)) return false; + e.x = c; e.y = r; + battle._unblock(e); + if (e.route) e.route.pts = null; + battle.fx('teleport', { x: c, y: r, id: e.id, src: unit.id }); + return true; +} + +// ------------------------------------------------------------------------------------------------------------------ +// 余 chess_char_6_03 (本源铁卫) — S3 灶里乾坤; 礼尚往来; 闲云隐市; module 人间百味 + +function yu(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess), tb = def?.traitBb || {}; + const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); + const wallProb = isDef ? num(bb.prob) : 0, wallBurn = isDef ? num(bb.ep_damage_ratio) : 0; + // "将第二天赋效果赋予全场所有干员" belongs to S3 (the default skill) only + const s3On = (unit) => isDef && !!unit.skill?.active; + const skills = { + // S1 今日做东 (TAKE_DAMAGE, hurt SP): passive taunt +taunt_level; active: HP / DEF +, every attack taken ⇒ + // ep_damage_ratio × ATK 灼燃损伤 on the attacker (install below) + skchr_yu_1: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) } }, + // S2 厚礼上宾 (cast with an enemy on its x-1: the data's SKILL_RANGE, a deliberate deviation from the 重装 TAKE_DAMAGE + // row — tools/build-data.mjs TRIGGER_DEVIATIONS, DESIGN §22.10): atk_scale × ATK arts on every enemy of the skill range + // + the ground-reachable ones teleported onto his tile (leaders too, unless 自缚: teleportEnemy); block +block_cnt, HP / + // ATK +, normal attacks deal arts damage. The burst hits air units too [ASSUMED: no 对空 note on PRTS]; the teleport + // takes ground units only ("地面可达目标"). The 'pull' fx only when someone was teleported (each one also gets its own + // 'teleport' fx). PRTS's 0.13 s between the damage and the teleport is not modelled (same tick). + skchr_yu_2: { + kind: 'duration', + mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) }, + attack: { dmgType: 'arts' }, + onStart({ battle, unit }) { + const grid = skillGridOf(def) || AROUND8; + const foes = battle.unitsInGrid(unit, grid, { side: 'enemy' }).filter((e) => canTargetEnemy(unit, e, ANY)); + for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); + let n = 0; + for (const e of foes) if (teleportEnemy(battle, unit, e)) n++; + if (n > 0) battle.fx('pull', { x: unit.x, y: unit.y, id: unit.id, n }); + }, + }, + }; + return { + skills, + install(battle, unit) { + if (sid !== 'skchr_yu_1') return; + // 被动效果:自身更容易受到敌人攻击 (while this skill is carried) + battle.addBuff(unit, { key: 'yu:host', mods: { taunt: num(bb.taunt_level, 1) }, persist: true, allowDead: true }); + battle.on('damaged', (ctx) => { + const s = ctx.source; + if (ctx.target !== unit || !unit.skill?.active || !s || s.side !== 'enemy' || !s.alive || !ctx.dmg?.isAttack) return; + elementDmg(battle, unit, s, 'burn', unit.s.atk * num(bb.ep_damage_ratio), ['skill']); + }, { owner: unit }); + }, + skill: { + kind: 'duration', + mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) }, + // the wall runs through his tile perpendicular to his direction (RIGHT / LEFT: his column; UP / DOWN: his row — + // it turns with the deploy direction like a range) [ASSUMED orientation for UP / DOWN] + onStart({ battle, unit }) { + const vertical = unit.fwd[0] === 0; + unit.mem.yuWall = vertical ? { axis: 'x', v: unit.tileC } : { axis: 'y', v: unit.tileR }; + battle.fx('firewall', { x: unit.tileC, y: unit.tileR, id: unit.id, dir: unit.dir, axis: vertical ? 'col' : 'row' }); + }, + onEnd({ unit }) { unit.mem.yuWall = null; }, + }, + talents: [ + { install(battle, unit) { // 礼尚往来: 庇护 while blocking + DoT on blocked enemies + const dr = num(t0.damage_resistance), sc = bv(t0, 'atk_scale'), er = bv(t0, 'ep_damage_ratio'), iv = Math.max(0.1, bv(t0, 'interval', 1)); + // 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例 — true / element damage is not reduced + aura(battle, unit, 0.1, () => { + if (dr > 0 && unit.blocking.length) battle.addBuff(unit, { key: 'yu:shelter', mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr }, duration: 0.2, refresh: 'replace' }); + }); + aura(battle, unit, iv, () => { + for (const e of unit.blocking.slice()) { + if (!e.alive) continue; + if (sc > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', tags: ['talent'] }); + if (er > 0) elementDmg(battle, unit, e, 'burn', unit.s.atk * er, ['talent']); + } + }); + } }, + { install(battle, unit) { // 闲云隐市 (all operators while S3 runs) + const cnt = num(t1.cnt, 4), hr = num(t1.hp_recovery_per_sec_by_max_hp_ratio), er = num(t1.ep_heal_ratio), iv = Math.max(0.1, num(t1.interval, 1)); + aura(battle, unit, iv, () => { + const ops = opsOf(battle, unit.ownerId); + if (ops.length < cnt) return; + for (const a of s3On(unit) ? ops : [unit]) { + if (hr > 0) battle.heal(unit, a, a.s.maxHp * hr * iv, { self: true, silent: true }); + if (er > 0) battle.reduceElement(a, a.s.maxHp * er * iv); + } + }); + } }, + { install(battle, unit) { // S3 fire wall + const crosses = (a, b) => { + const w = unit.mem.yuWall; + if (w == null) return false; + return w.axis === 'y' ? (a.y - w.v) * (b.y - w.v) < 0 : (a.x - w.v) * (b.x - w.v) < 0; + }; + battle.on('damaged', (ctx) => { + const s = ctx.source, t = ctx.target; + if (!(wallBurn > 0) || unit.mem.yuWall == null || !live(unit) || ctx.type !== 'arts' || !(ctx.amount > 0)) return; + if (!s || s === unit || s.side !== 'ally' || !t || t.side !== 'enemy' || !t.alive || !crosses(s, t)) return; + elementDmg(battle, unit, t, 'burn', unit.s.atk * wallBurn, ['skill', 'firewall']); + }, { owner: unit }); + battle.on('hit', (ctx) => { + const s = ctx.source, t = ctx.target; + if (!(wallProb > 0) || unit.mem.yuWall == null || !live(unit) || !s || s.side !== 'enemy' || !t || t.side !== 'ally') return; + if (!ctx.dmg.isAttack || !(s.base.rangeRadius > 0) || s.blockedBy === t || !crosses(s, t)) return; + if (battle.rng() < wallProb) { + ctx.dmg.cancel = true; + const w = unit.mem.yuWall; + battle.fx('firewallBlock', w.axis === 'y' ? { x: t.x, y: w.v, id: unit.id } : { x: w.v, y: t.y, id: unit.id }); + } + }, { owner: unit }); + } }, + { install(battle, unit) { // elite: module element ×1.15 while blocking; 闲云隐市 arts ×1.14 vs burning targets (≥4 ops) + const epScale = num(tb.ep_damage_scale, 1), cnt = num(mod.cnt, Infinity), ds = num(mod.damage_scale, 1); + if (epScale > 1) onElementHit(battle, unit, (ctx) => (ctx.source === unit && live(unit) && unit.blocking.length ? epScale : 1)); + // "将第二天赋效果赋予全场所有干员": while S3 runs every operator of hers gets the ×1.14 too + if (ds > 1) battle.on('hit', (ctx) => { + const s = ctx.source; + if (!s || ctx.dmg.type !== 'arts' || !ctx.target || ctx.target.side !== 'enemy' || !ctx.target.findBuff('burnBurst')) return; + if (s !== unit && !(s.kind === 'op' && s.ownerId === unit.ownerId && live(unit) && s3On(unit))) return; + if (opsOf(battle, unit.ownerId).length >= cnt) ctx.dmg.mul *= ds; + }, { owner: unit }); + } }, + ], + }; +} + +export default { + chess_char_6_03_a: yu, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_04-skadi2.js b/server/sim/content/kits/ops/chess_char_6_04-skadi2.js new file mode 100644 index 00000000..25e3ab49 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_04-skadi2.js @@ -0,0 +1,190 @@ +// server/sim/content/kits/ops/chess_char_6_04-skadi2.js — 浊心斯卡蒂 (char_1012_skadi2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { aggregateMods } from '../../../buffs.js'; +import { COLS } from '../../../constants.js'; +import { + num, bv, tbb, tdesc, moduleBb, parseN, live, enemiesIn, isTok, onDefaultSkill, selectedSkill, aura, +} from '../shared/tier6.js'; + +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); + +/** 深海猎人 (character_table groupId "abyssal" — checked against the official table; data/chess.json has no group id). */ +const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']); + +/** + * 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK after the target's own multipliers (flat + * compensated), strongest source wins. Units flagged `mem.noInspire` (浊心斯卡蒂 "自身不受鼓舞影响") never receive it. + */ +function inspire(battle, target, val, src, stat = 'atk') { + if (!(val > 0) || !live(target) || target.mem.noInspire) return; + const key = stat === 'def' ? 'inspire:def' : 'inspire'; + const cur = target.findBuff(key); + if (cur && cur.data && cur.data.src !== src.id && cur.data.val > val && cur.timeLeft > 0.1) return; + const { add, mul } = aggregateMods(target.buffs.filter((b) => b.key !== key)); + const f = stat === 'def' + ? Math.max(0, 1 + (add.defPct ?? 0)) * (mul.defMul ?? 1) + : Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1); + const flat = f > 1e-6 ? val / f : val; + battle.addBuff(target, { key, mods: stat === 'def' ? { defFlat: flat } : { atkFlat: flat }, duration: 0.75, refresh: 'replace', source: src, visible: true, data: { src: src.id, val } }); +} + +// ------------------------------------------------------------------------------------------------------------------ +// 浊心斯卡蒂 chess_char_6_04 (吟游者) — S3 "潮涌,潮枯"; 远古血亲 (海嗣); 捕食习性; module 蜕化的残迹 + +function skadi2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess), tb = def?.traitBb || {}; + const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); + const tokId = def?.talents?.[0]?.tokenKey || (chess?.tokens || [])[0] || 'token_10017_skadi2_dedant'; + const auraRatio = num(tb['attack@atk_to_hp_recovery_ratio'], 0.1); + // S1 / S2 raise the trait heal ("特性效果提高至N%") while they run; S3 (default) turns it into the tide + const skillRatio = num(bb['attack@atk_to_hp_recovery_ratio'], auraRatio); + const seaborns = (battle, unit) => battle.allyUnits.filter((t) => isTok(t, tokId, unit) && live(t)); + const covered = (battle, unit, toks) => { // allies inside her range ∪ the seaborns' ranges + const set = new Set(battle.alliesInGrid(unit)); + for (const t of toks) for (const a of battle.alliesInGrid(t)) set.add(a); + for (const t of toks) set.delete(t); + return [...set]; + }; + const inCover = (battle, unit, a) => { + const k = a.tileR * COLS + a.tileC; + if (unit.rangeKeySet?.has(k)) return true; + return seaborns(battle, unit).some((t) => t.rangeKeySet?.has(k)); + }; + const skills = { + // S1 同归殊途之吟 (SP_FULL): full self heal, max HP +max_hp, trait heal attack@atk_to_hp_recovery_ratio, and + // damage_resistance of the damage taken by every ally of her (+ 海嗣) range is transferred to her (install) + skchr_skadi2_1: { + kind: 'duration', + mods: { hpPct: num(bb.max_hp) }, + onStart({ battle, unit }) { unit.hp = unit.s.maxHp; battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id }); }, + }, + // S2 同葬无光之愿 (toggle): 鼓舞 ATK / DEF = atk / def × her ATK / DEF on every other ally of her (+ 海嗣) range, + // trait heal attack@atk_to_hp_recovery_ratio (trait pulse) + skchr_skadi2_2: { kind: 'toggle' }, + }; + return { + skills, + skill: { + kind: 'duration', // effects run in the trait pulse below ("特性变为…") + onStart({ battle, unit }) { battle.fx('tide', { x: unit.x, y: unit.y, id: unit.id }); }, + }, + trait: { + install(battle, unit) { // replaces the bard aura: heal normally, tide (true damage + 鼓舞 + self drain) during S3 + unit.mem.noInspire = true; // 自身不受鼓舞影响 + const isInspire = (b) => b.key === 'inspire' || b.status === 'inspire' || b.key.startsWith('inspire:') || b.key.endsWith(':inspire'); + // "海嗣的攻击范围视为自身攻击范围的延伸": an enemy in a 海嗣's range also satisfies her DEFAULT trigger + if (unit.skill) unit.skill.addTriggerRange(() => seaborns(battle, unit)); + let n = 0; + battle.every(0.5, () => { + if (!unit.canAct) return; + for (let i = unit.buffs.length - 1; i >= 0; i--) if (isInspire(unit.buffs[i])) battle.removeBuff(unit, unit.buffs[i]); + n++; + const toks = seaborns(battle, unit); + const allies = covered(battle, unit, toks); + const sk = unit.skill; + const on = !!(sk && sk.active); + if (on && isDef) { + const val = unit.s.atk * num(bb.atk); + for (const a of allies) if (a !== unit) inspire(battle, a, val, unit); + if (n % 2 === 0) { + const dmg = unit.s.atk * num(bb.atk_scale); + if (dmg > 0) for (const src of [unit, ...toks]) for (const e of enemiesIn(battle, src)) { + battle.dealDamage(unit, e, { amount: dmg, type: 'true', isSkill: true, tags: ['skill', 'tide'] }); + } + const loss = unit.s.maxHp * num(bb.hp_ratio); + if (loss > 0) { if (unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit, silent: true }); else unit.hp = Math.min(unit.hp, 1); } + } + return; + } + if (on && sid === 'skchr_skadi2_2') { + const va = unit.s.atk * num(bb.atk), vd = unit.s.def * num(bb.def); + for (const a of allies) if (a !== unit) { inspire(battle, a, va, unit); inspire(battle, a, vd, unit, 'def'); } + } + if (n % 2 === 0) { + const amount = unit.s.atk * (on ? skillRatio : num(unit.profile?.auraRatio, auraRatio)); + for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, amount, { aura: true }); + } + }, { owner: unit }); + }, + }, + install(battle, unit) { + // S1: "攻击范围内我方所有单位受到伤害的50%直接转移给浊心斯卡蒂承担(同类效果取最高)" — the ally takes (1 − share), + // she takes the transferred part as true damage from the same attacker + if (sid === 'skchr_skadi2_1') { + const share = clamp(num(bb.damage_resistance), 0, 1); + const TAG = `skadi2:transfer:${unit.id}`; + battle.on('hit', (ctx) => { + const t = ctx.target, d = ctx.dmg; + if (!(share > 0) || !t || t === unit || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active) return; + if (d.type === 'element' || d.type === 'elemental' || d.skadiShare || d.tags?.includes('transfer') || !inCover(battle, unit, t)) return; + d.skadiShare = { share, tag: TAG }; // 同类效果取最高: one transfer per damage instance + d.mul *= 1 - share; + }, { owner: unit, priority: -20 }); + battle.on('damaged', (ctx) => { + const x = ctx.dmg?.skadiShare; + if (!x || x.tag !== TAG || !(ctx.amount > 0) || !live(unit)) return; + battle.dealDamage(ctx.source, unit, { amount: ctx.amount * x.share / Math.max(1e-6, 1 - x.share), type: 'true', canDodge: false, tags: ['transfer'] }); + }, { owner: unit }); + } + // module 新生代: "自身技能期间,攻击范围内的友军获得30点物理与法术伤害减免" (flat reduction per hit) + const flat = -num(mod.damage_resistance); + if (flat > 0) { + battle.on('hit', (ctx) => { + const t = ctx.target, d = ctx.dmg; + if (!t || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active || !inCover(battle, unit, t)) return; + if (d.type === 'phys') d.defIgnoreFlat -= flat; // +flat effective DEF ⇒ −flat damage (above the 5 % floor) + else if (d.type === 'arts') d.amount = Math.max(0, d.amount - flat / Math.max(0.05, 1 - (t.s.res ?? 0) / 100)); + }, { owner: unit, priority: -10 }); + } + }, + talents: [ + { install(battle, unit) { // 远古血亲: the seaborn is an inert range extension with a limited life + battle.on('deploy', ({ unit: t }) => { + if (!isTok(t, tokId, unit)) return; + if (t.profile) t.profile.noAttack = true; + const dur = num(t.def?.talents?.[0]?.bb?.duration, parseN(tdesc(def, 0), /持续(\d+(?:\.\d+)?)秒/, 25)); + const seq = t.deploySeq, r = t.tileR, c = t.tileC; + battle.after(dur, () => { + if (!t.alive || t.deploySeq !== seq) return; + battle.retreat(t, { reason: 'expired', permanent: true }); + const wait = num(t.base.respawnTime, 30), cost = num(t.base.cost, 0); + let tries = 0; + const again = () => { + if (battle.finished || tries++ > 300) return; + const ps = battle.getPlayer(unit.ownerId); + if (!live(unit) || !ps || ps.dp + 1e-9 < cost || battle.unitAt(r, c)) { battle.after(1, again, { owner: unit }); return; } + battle.addDp(unit.ownerId, -cost); + if (!battle.spawnToken(unit, tokId, r, c)) { battle.addDp(unit.ownerId, cost); battle.after(1, again, { owner: unit }); } + }; + battle.after(wait, again, { owner: unit }); + }, { owner: t }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 捕食习性 (module 新生代: ATK 9 / 20 %, DEF +8 %, +3 SP per op deployed in range) + module (≥2 other ops in range: ATK +8 %) + const a1 = num(t1['skadi2_e_003_t_2[atk][1].atk'], bv(t1, 'skadi2_t_2[atk][1].atk', 0)); + const a2 = num(t1['skadi2_e_003_t_2[atk][2].atk'], bv(t1, 'skadi2_t_2[atk][2].atk', a1)); + const d1 = num(t1['skadi2_e_003_t_2[def].def']), sp = num(t1.sp); + const mCnt = num(mod.cnt, Infinity), mAtk = num(mod.atk); + aura(battle, unit, 0.5, () => { + const toks = seaborns(battle, unit); + const ops = covered(battle, unit, toks).filter((a) => a !== unit && a.kind === 'op'); + const val = ops.some((a) => ABYSSAL.has(a.def?.charId)) ? a2 : ops.length ? a1 : 0; + const own = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op').length; + const total = val + (own >= mCnt ? mAtk : 0); + const dPct = ops.length ? d1 : 0; + if (total > 0 || dPct > 0) battle.addBuff(unit, { key: 'skadi2:predator', mods: { atkPct: total, defPct: dPct }, duration: 0.75, refresh: 'replace' }); + }); + // "我方干员部署于自身或海嗣范围内后,自身立刻获得3点技力" + if (sp > 0) battle.on('deploy', ({ unit: u }) => { + if (!u || u === unit || u.kind !== 'op' || u.side !== 'ally' || !live(unit) || !unit.skill) return; + if (inCover(battle, unit, u)) unit.skill.gainSp(sp, 'talent'); + }, { owner: unit }); + } }, + ], + }; +} + +export default { + chess_char_6_04_a: skadi2, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_05-pasngr.js b/server/sim/content/kits/ops/chess_char_6_05-pasngr.js new file mode 100644 index 00000000..5b3aa393 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_05-pasngr.js @@ -0,0 +1,111 @@ +// server/sim/content/kits/ops/chess_char_6_05-pasngr.js — 异客 (char_472_pasngr) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js'; +import { CHAIN_RADIUS } from '../../../constants.js'; +import { bodyDist, bodyInKeys } from '../../../body.js'; +import { num, bv, tbb, live, ANY, enemiesIn, batOf, N4, aura } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 异客 chess_char_6_05 (链术师) — S3 辉煌裂片; 机理分析; 孤卒 + +const STORM_RADIUS = 1.5; // [ASSUMED] storm zone radius (not in data) + +function pasngr(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; + const strike = (battle, unit, first, scale) => { + const ch0 = unit.profile?.chain || { count: 4, falloff: 0.15, radius: CHAIN_RADIUS }; + // elite module (电磁调节器) upgrades the skill's chain too: skill@chain.atk_scale / skill@sluggish + const ch = { ...ch0, falloff: tb['skill@chain.atk_scale'] != null ? 1 - num(tb['skill@chain.atk_scale']) : num(ch0.falloff, 0.15) }; + const count = Math.max(1, Math.floor(num(bb['chain.max_target'], ch.count || 4))); + const slug = num(tb['skill@sluggish'], num(bb.sluggish, ch.sluggish ?? 0)); + const hit = new Set(); + let prev = first; + for (let i = 0; i < count && prev; i++) { + hit.add(prev.id); + battle.fx('lightning', { x: prev.x, y: prev.y, id: prev.id, src: unit.id }); + battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * Math.pow(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] }); + if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit }); + let best = null, bd = Infinity; + for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) { + if (hit.has(x.id) || !canTargetEnemy(unit, x, ANY)) continue; + const d = bodyDist(x, prev.x, prev.y); + if (d < bd - 1e-9) { bd = d; best = x; } + } + prev = best; + } + }; + // the chain of her profile (trait / module: bounce count, falloff, 停顿) with skill overrides + const chainOf = (unit, o) => ({ ...(unit.profile?.chain || { count: 4, falloff: 0.15, radius: CHAIN_RADIUS, sluggish: 0.5 }), ...o }); + const skills = { + // S1 电能之触: next attack at atk_scale × ATK, bouncing over max_target enemies with a sluggish-s 停顿 (the module's + // skill@pasngr_s_1.chain.atk_scale sets its falloff) + skchr_pasngr_1: { + kind: 'instant', + attack: { atkScale: bv(bb, 'atk_scale', 1) }, + onStart({ unit, skill }) { + const o = { count: Math.max(1, Math.floor(bv(bb, 'max_target', 4))), sluggish: bv(bb, 'sluggish', 0.5) }; + if (tb['skill@pasngr_s_1.chain.atk_scale'] != null) o.falloff = 1 - num(tb['skill@pasngr_s_1.chain.atk_scale']); + skill.spec.attack.chain = chainOf(unit, o); + }, + }, + // S2 聚焦指令: range +1, ATK +atk, base attack time ×0.7/×0.6 (akdata: 乘算负数), bounces raised to attack@max_target + skchr_pasngr_2: { + kind: 'duration', + mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def, true) }, + targeting: { rangeExtend: num(bb.ability_range_forward_extend) }, + attack: {}, + onStart({ unit, skill }) { skill.spec.attack.chain = chainOf(unit, { count: Math.max(1, Math.floor(num(bb['attack@max_target'], 5))) }); }, + }, + }; + return { + skills, + skill: { + kind: 'charges', + onStart({ battle, unit }) { + const keys = skillGrid ? absoluteRangeKeys(skillGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend) : unit.rangeKeys; + const cands = enemiesIn(battle, unit, keys); + if (!cands.length) return; + const tgt = cands.reduce((a, b) => (b.hp > a.hp ? b : a)); + const cx = tgt.x, cy = tgt.y; + const dur = num(bb.duration, 4), iv = Math.max(0.1, num(bb.interval, 0.5)), scale = num(bb.atk_scale, 1); + const n = Math.max(1, Math.round(dur / iv)); + battle.fx('storm', { x: cx, y: cy, id: unit.id, duration: dur, r: STORM_RADIUS }); + let k = 0; + const seq = unit.deploySeq; + const h = battle.every(iv, () => { + if (!live(unit) || unit.deploySeq !== seq) { h.cancel(); return; } // the storm ends when she leaves the field + if (++k >= n) h.cancel(); + const zone = battle.foesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY)); + const e = battle.rng.pick(zone); + if (e) strike(battle, unit, e, scale); + }, { owner: unit }); + }, + }, + talents: [ + { install(battle, unit) { // 机理分析 + const thr = num(t0.hp_ratio, 0.8), ds = bv(t0, 'damage_scale', 1), dur = bv(t0, 'duration', 3); + const key = `pasngr:enhance:${unit.id}`; + battle.on('hit', (ctx) => { + const t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || ctx.dmg.type === 'element') return; + if (ctx.dmg.isAttack && t.hpRatio >= thr - 1e-9) battle.addBuff(t, { key, duration: dur, refresh: 'replace', source: unit }); + if (t.findBuff(key)) ctx.dmg.mul *= ds; + }, { owner: unit }); + } }, + { install(battle, unit) { // 孤卒: no enemy on the 4 surrounding tiles + const atk = num(t1.atk), spr = num(t1.sp_recovery_per_sec); + aura(battle, unit, 0.25, () => { + const near = new Set(absoluteRangeKeys(N4, unit.tileR, unit.tileC, 1, 0)); + if (battle.enemies.some((e) => e.alive && !e.hidden && bodyInKeys(e, near))) return; + battle.addBuff(unit, { key: 'pasngr:lone', mods: { atkPct: atk, spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' }); + }); + } }, + ], + }; +} + +export default { + chess_char_6_05_a: pasngr, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_06-pepe.js b/server/sim/content/kits/ops/chess_char_6_06-pepe.js new file mode 100644 index 00000000..68d9092d --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_06-pepe.js @@ -0,0 +1,121 @@ +// server/sim/content/kits/ops/chess_char_6_06-pepe.js — 佩佩 (char_4058_pepe) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { + num, tbb, live, selectedSkill, batOf, skillGridOf, hasAbnormal, cleanseAbnormal, aura, +} from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 佩佩 chess_char_6_06 (撼地者) — S3 时光震荡; 往昔传承; 弥漫莲香; module 晴雨 + +function pepe(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const sid = selectedSkill(chess, def); + const stackAtk = num(bb['attack@atk']), maxStack = Math.max(0, Math.floor(num(bb['attack@max_stack_cnt']))); + const ext = num(bb['attack@ability_range_forward_extend']); + const stun = num(bb['attack@stun']), stunMain = num(bb['attack@stun_main'], stun); + // 往昔传承: "技能期间每击倒1名敌人,技能结束时获得N点技力,至多回复M点" (any timed skill of hers) + const pastSp = (unit, skill) => { + const gain = Math.min(num(t0.max_sp, Infinity), (unit.mem.pepeKills || 0) * num(t0.sp)); + unit.mem.pepeKills = 0; + if (gain > 0 && unit.alive) skill.gainSp(gain, 'talent'); + }; + const skills = { + // S1 盖戳!: next attack at atk_scale × ATK (splash included); castable while under a 异常状态, which it cleanses + // (install below) + skchr_pepe_1: { kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) } }, + // S2 阻遏混乱锤: skill range, ATK / ASPD +, random target in range; every use gives later casts ASPD + // +attack_speed_extra (≤ max_stack_cnt stacks) + skchr_pepe_2: { + kind: 'duration', + mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, + ...(skillGridOf(def) ? { targeting: { rangeGrid: skillGridOf(def) } } : {}), + onStart({ battle, unit }) { + unit.mem.pepeKills = 0; + const n = unit.mem.pepeRage || 0; + if (n > 0) battle.addBuff(unit, { key: 'pepe:rage', mods: { aspd: num(bb.attack_speed_extra) * n } }); + unit.mem.pepeRage = Math.min(Math.floor(num(bb.max_stack_cnt, 2)), n + 1); + }, + onEnd({ battle, unit, skill }) { battle.removeBuff(unit, 'pepe:rage'); pastSp(unit, skill); }, + }, + }; + return { + skills, + install(battle, unit) { + if (sid === 'skchr_pepe_1') { + battle.on('tick', () => { // "处于异常状态时可以释放技能并清除异常状态" + const sk = unit.skill; + if (!live(unit) || !sk || !sk.ready || sk.active || !hasAbnormal(unit)) return; + cleanseAbnormal(battle, unit); + sk.activate('abnormal'); + }, { owner: unit }); + } + if (sid === 'skchr_pepe_2') { + battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标 (and the enemies she blocks — Battle.blockedTargets) + if (ctx.attacker !== unit || !unit.skill?.active) return; + const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile); + for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e); + if (c.length) ctx.targets = [battle.rng.pick(c)]; + }, { owner: unit }); + } + }, + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) }, + attack: { + splashRadius: 1, + onHit({ battle, unit, target }) { if (target && target.alive && stunMain > 0) battle.applyStatus(target, 'stun', { duration: stunMain, source: unit }); }, + }, + onStart({ unit, skill }) { + unit.mem.pepeStack = 0; + unit.mem.pepeKills = 0; + unit.mem.pepeR0 = num(unit.profile?.splashRadius, 1); + skill.spec.attack.splashRadius = unit.mem.pepeR0; + }, + onAttack({ battle, unit, skill }) { + if (unit.mem.pepeStack >= maxStack) return; + unit.mem.pepeStack++; + battle.addBuff(unit, { key: 'pepe:tremor', mods: { atkPct: stackAtk * unit.mem.pepeStack } }); + skill.spec.attack.splashRadius = unit.mem.pepeR0 + ext * unit.mem.pepeStack; + }, + onEnd({ battle, unit, skill }) { + battle.removeBuff(unit, 'pepe:tremor'); + skill.spec.attack.splashRadius = unit.mem.pepeR0 ?? 1; + pastSp(unit, skill); + }, + }, + talents: [ + { install(battle, unit) { // S3 splash stun + 往昔传承 kill counter + battle.on('damaged', (ctx) => { + if (ctx.source !== unit || !unit.skill?.active || !ctx.dmg?.isSplash || !ctx.target.alive || stun <= 0) return; + battle.applyStatus(ctx.target, 'stun', { duration: stun, source: unit }); + }, { owner: unit }); + battle.on('kill', ({ killer, victim }) => { + if (killer === unit && victim.side === 'enemy' && unit.skill?.active) unit.mem.pepeKills = (unit.mem.pepeKills || 0) + 1; + }, { owner: unit }); + } }, + { install(battle, unit) { // 弥漫莲香: every 近卫 op ATK +16 % + const atk = num(t1.atk); + if (atk) aura(battle, unit, 0.5, () => { + for (const a of battle.allyUnits) if (live(a) && a.kind === 'op' && a.def?.profession === 'WARRIOR') battle.addBuff(a, { key: 'pepe:lotus', mods: { atkPct: atk }, duration: 0.75, refresh: 'replace' }); + }); + } }, + { install(battle, unit) { // elite module: ≥ cnt enemies in the splash area ⇒ this attack ×1.15 + const sc = num(tb.atk_scale_e, 1), cnt = num(tb.cnt, Infinity); + if (!(sc > 1)) return; + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit) return; + const t = ctx.targets[0]; + const r = num(ctx.profile?.splashRadius, 1); + unit.mem.pepeBoost = !!t && battle.foesInRadius(t.x, t.y, r).length >= cnt; + }, { owner: unit }); + battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.dmg.isAttack && unit.mem.pepeBoost) ctx.dmg.mul *= sc; }, { owner: unit }); + battle.on('attack', (ctx) => { if (ctx.attacker === unit) unit.mem.pepeBoost = false; }, { owner: unit, priority: -100 }); + } }, + ], + }; +} + +export default { + chess_char_6_06_a: pepe, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_07-siege2.js b/server/sim/content/kits/ops/chess_char_6_07-siege2.js new file mode 100644 index 00000000..7e4692c8 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_07-siege2.js @@ -0,0 +1,133 @@ +// server/sim/content/kits/ops/chess_char_6_07-siege2.js — 维娜·维多利亚 (char_1019_siege2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { + num, tbb, moduleBb, live, isElite, keyOf, onDefaultSkill, selectedSkill, batOf, skillGridOf, N4, freeTiles, aura, +} from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 维娜·维多利亚 chess_char_6_07 (术战者) — S3 俱以我之名; 诸王的叹息; 无拘的锋芒; module 城主的冒险 + +function siege2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess); + const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); + const tGrid = def?.talents?.[0]?.rangeGrid?.length ? def.talents[0].rangeGrid : [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]; + const tokId = (chess?.tokens || []).find((t) => /vlion/.test(String(t))) || 'token_10040_siege2_vlion'; + const mine = (u, unit) => u === unit || (!!u && u.kind === 'token' && u.ownerUnit === unit); + const skills = { + // S1 重铸晖光: the next attack also deals atk_scale × ATK true damage to every ground enemy on the skill's 4 + // surrounding tiles (+ her own) + skchr_siege2_1: { + kind: 'instant', + attack: { + onHit({ battle, unit }) { + for (const e of battle.unitsInGrid(unit, skillGridOf(def) || N4, { side: 'enemy' })) { + if (!e.isFlying && e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'true', isSkill: true, tags: ['skill'] }); + } + battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id }); + }, + }, + }, + // S2 进赴故土 (toggle): range +1 (skill grid), ATK +atk, one extra target; passive: ≥buff_stack_cnt other allies in + // the talent-1 area ⇒ SP +sp_recovery_per_sec (install) + skchr_siege2_2: { + kind: 'toggle', + mods: { atkPct: num(bb.atk) }, + targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))), ...(skillGridOf(def) ? { rangeGrid: skillGridOf(def) } : {}) }, + }, + }; + return { + skills, + install(battle, unit) { + if (sid === 'skchr_siege2_2') { + const need = Math.max(1, Math.floor(num(bb.buff_stack_cnt, 2))), spr = num(bb.sp_recovery_per_sec); + if (spr > 0) aura(battle, unit, 0.25, () => { + const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device' && battle.allySelectable(a, unit)).length; + if (n >= need) battle.addBuff(unit, { key: 'siege2:homeland', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' }); + }); + } + // module 秩序圣“球”: enemies blocked by her or her summons take 10 % 脆弱 (trait); she and her summons deal ×1.15 to + // 战栗 targets (talent) + const frag = num(tb.damage_scale, 1) - 1, tremMul = num(mod.damage_scale, 1); + if (frag > 0) aura(battle, unit, 0.25, () => { + for (const e of battle.enemies) if (e.alive && e.blockedBy && mine(e.blockedBy, unit)) battle.applyStatus(e, 'fragile', { duration: 0.4, value: frag, source: unit }); + }); + if (tremMul > 1) battle.on('hit', (ctx) => { + const t = ctx.target; + if (!mine(ctx.source, unit) || !t || t.side !== 'enemy' || !(t.findBuff('tremble') || t.s.flags.tremble)) return; + ctx.dmg.mul *= tremMul; + }, { owner: unit }); + }, + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) }, + targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) }, + attack: { dmgType: 'true' }, + // "立即在天赋一生效范围内可部署地面召唤“黄金盟誓”" (EN client "Summons Golden Vows on deployable tiles within + // Talent 1's range"): one on EVERY free tile of the talent-1 area a melee piece could be deployed on — fences + // (low, deployable, not walkable) included [ASSUMED: the EN wiki's "open low ground tiles"]; player report B3 + onStart({ battle, unit, skill }) { + const lions = []; + for (const tile of freeTiles(battle, unit, tGrid, { ground: false })) { + const lion = battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: skill.timeLeft }); + if (!lion) continue; + lions.push(lion); + if (!lion.kit?.fromTokens && lion.profile) lion.profile.dmgType = 'true'; // "攻击造成真实伤害" without a token kit + battle.fx('summon', { x: lion.x, y: lion.y, id: lion.id, src: unit.id }); + } + unit.mem.vlions = lions; + }, + onEnd({ battle, unit }) { + const lions = unit.mem.vlions || []; + unit.mem.vlions = null; + for (const lion of lions) if (lion.alive) battle.retreat(lion, { reason: 'expired', permanent: true }); + battle.setExtraRange(unit, null); + }, + }, + talents: [ + { install(battle, unit) { // S3: enemies blocked by allies inside the talent area become targetable + let sig = ''; + if (!isDef) return; + battle.on('tick', () => { // (engine extra range keys: kept across range rebuilds, cleared at the skill end) + if (!live(unit) || !unit.skill?.active) { sig = ''; return; } + const keys = []; + for (const a of battle.unitsInGrid(unit, tGrid, { side: 'ally' })) for (const e of a.blocking) if (e.alive) keys.push(keyOf(e)); + const s = keys.join(','); + if (s === sig) return; + sig = s; + battle.setExtraRange(unit, keys); + }, { owner: unit }); + } }, + { install(battle, unit) { // 诸王的叹息 + const dr = num(t0.damage_resistance), atk = num(t0.atk); + aura(battle, unit, 0.25, () => { + const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device' && battle.allySelectable(a, unit)); + if (!allies.includes(unit)) allies.push(unit); + if (dr > 0) for (const a of allies) battle.addBuff(a, { key: 'siege2:sigh', mods: { physTakenMul: 1 - dr }, duration: 0.4, refresh: 'replace' }); + const n = allies.filter((a) => a !== unit).length; + if (n > 0 && atk) battle.addBuff(unit, { key: 'siege2:kings', mods: { atkPct: atk * n }, duration: 0.4, refresh: 'replace' }); + }); + } }, + { install(battle, unit) { // 无拘的锋芒: first damage on each enemy ⇒ 战栗 (module 秩序圣“球”: 6 s, elite / leader 12 s) + const durN = num(t1.not_combat_normal, num(t1.not_combat)), durE = num(t1.not_combat_elite, durN); + const seen = new WeakSet(); + battle.on('damaged', (ctx) => { + const t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || seen.has(t)) return; + const dur = isElite(t) ? durE : durN; + if (!(dur > 0)) return; + seen.add(t); + if (t.alive) battle.applyStatus(t, 'tremble', { duration: dur, source: unit }); + }, { owner: unit }); + } }, + { install(battle, unit) { // elite module: ASPD +8 while not blocking + const as = num(tb.attack_speed); + if (as) aura(battle, unit, 0.25, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'siege2:free', mods: { aspd: as }, duration: 0.4, refresh: 'replace' }); }); + } }, + ], + }; +} + +export default { + chess_char_6_07_a: siege2, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_08-reed2.js b/server/sim/content/kits/ops/chess_char_6_08-reed2.js new file mode 100644 index 00000000..6b287b5e --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_08-reed2.js @@ -0,0 +1,132 @@ +// server/sim/content/kits/ops/chess_char_6_08-reed2.js — 焰影苇草 (char_1020_reed2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets } from '../../../targeting.js'; +import { num, tbb, moduleBb, live, enemiesIn, onDefaultSkill, aura } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 焰影苇草 chess_char_6_08 (咒愈师) — S3 生命火种; 灼痕; 映耀 + +function reed2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess); + const isDef = onDefaultSkill(chess); + const selfAtk = (() => { for (const k of Object.keys(bb)) if (!k.startsWith('talent@') && (k === 'atk' || k.endsWith('.atk'))) return num(bb[k]); return 0; })(); + const sProb = num(bb['talent@prob'], num(t0.prob)), dot = num(bb['talent@s3_atk_scale']); + const aoe = num(bb['talent@aoe_scale']), aoeR = num(bb['talent@range_radius'], 1.7); + const scorchAtk = num(t0.atk), scorchFragile = num(t0.damage_scale, 1) - 1; + // 灼痕: ATK −20 % (marker buff, 不可叠加) + 30 % 法术脆弱 (the catalogue status: 同名效果取最高 with other sources) + const scorch = (battle, unit, e) => { + if (!e || !e.alive) return; + const sk = unit.skill; + // "灼痕效果持续至技能结束" — S3 (the default skill) only + const dur = isDef && sk && sk.active ? Math.max(0.1, sk.timeLeft) : num(t0.duration, 6); + battle.addBuff(e, { key: 'reed2:scorch', duration: dur, refresh: 'extend', mods: scorchAtk ? { atkPct: scorchAtk } : null, visible: true, source: unit }); + if (scorchFragile > 0 && e.alive) battle.applyStatus(e, 'artsFragile', { duration: dur, value: scorchFragile, source: unit }); + }; + const skills = { + // S1 迅捷打击·γ型 + 'skcom_quickattack[3]': { kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } }, + // S2 枯荣共息: up to max_target operators of her range (ground ones first) carry three fireballs for + // projectile_life_time s: every `cooldown` s one enemy in the carrier's range (else hers) takes atk_scale × her ATK + // arts, and her trait heals only that carrier (trait scale × the damage) + skchr_reed2_2: { + kind: 'duration', + duration: num(bb.projectile_life_time) > 0 ? num(bb.projectile_life_time) : undefined, + onStart({ battle, unit, skill }) { + const n = Math.max(1, Math.floor(num(bb.max_target, 1))); + const cands = battle.alliesInGrid(unit).filter((a) => a.kind === 'op' && live(a)); + cands.sort((a, b) => (a === unit) - (b === unit) || (b.ground ? 1 : 0) - (a.ground ? 1 : 0) || a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq); + unit.mem.reedFire = cands.slice(0, n).map((a) => ({ a, acc: 0 })); + for (const F of unit.mem.reedFire) { + battle.addBuff(F.a, { key: `reed2:fireball:${unit.id}`, duration: skill.timeLeft, visible: true, source: unit }); + battle.fx('ember', { x: F.a.x, y: F.a.y, id: F.a.id, src: unit.id, n: 3 }); + } + }, + onTick({ battle, unit, dt }) { + const cd = Math.max(0.1, num(bb.cooldown, 1.5)); + for (const F of unit.mem.reedFire || []) { + if (!live(F.a)) continue; + F.acc += dt; + if (F.acc + 1e-9 < cd) continue; + F.acc -= cd; + let c = enemiesIn(battle, F.a); + if (!c.length) c = enemiesIn(battle, unit); + if (!c.length) continue; + sortEnemyTargets(battle, F.a, c, null); + // "每1.5秒对一名敌人造成…法术伤害并仅对该干员触发焰影苇草特性": the trait heal (professions.js + // installIncantation) runs for this damage and names the carrier instead of the lowest-HP ally in range + battle.dealDamage(unit, c[0], { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'fireball'], traitAlly: F.a }); + battle.fx('strike', { x: c[0].x, y: c[0].y, id: c[0].id, src: F.a.id }); + } + }, + onEnd({ battle, unit }) { + for (const F of unit.mem.reedFire || []) battle.removeBuff(F.a, `reed2:fireball:${unit.id}`); + unit.mem.reedFire = null; + }, + }, + }; + return { + skills, + install(battle, unit) { + // module “独属自己的一隅”: "攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至110%" + const ds = num(mod.damage_scale, 1); + if (ds > 1) aura(battle, unit, 0.25, () => { + if (battle.injuredAlliesInKeys(unit.rangeKeys, unit).some((a) => a.kind === 'op' && a !== unit)) { + battle.addBuff(unit, { key: 'reed2:corner', mods: { dmgDealtMul: ds }, duration: 0.4, refresh: 'replace' }); + } + }); + }, + skill: { + kind: 'duration', + mods: { atkPct: selfAtk }, + targeting: { maxTargets: Math.max(1, Math.floor(num(bb.max_target, 1))) }, + onStart({ unit }) { unit.mem.reedAcc = 0; }, + onTick({ battle, unit, dt }) { + unit.mem.reedAcc += dt; + if (unit.mem.reedAcc + 1e-9 < 1) return; + unit.mem.reedAcc -= 1; + if (!(dot > 0)) return; + for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('reed2:scorch')) { + battle.dealDamage(unit, e, { amount: unit.s.atk * dot, type: 'arts', isSkill: true, tags: ['skill', 'scorch'] }); + } + }, + }, + talents: [ + { install(battle, unit) { // 灼痕 (+ S3 kill explosions) + battle.on('damaged', (ctx) => { + const t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || !t.alive || ctx.type === 'element') return; + if (ctx.dmg?.tags?.includes('scorch')) return; + const p = unit.skill?.active ? sProb : num(t0.prob); + if (p >= 1 || (p > 0 && battle.rng() < p)) scorch(battle, unit, t); + }, { owner: unit }); + battle.on('kill', ({ victim }) => { + if (!unit.skill?.active || !live(unit) || victim.side !== 'enemy' || !victim.findBuff('reed2:scorch') || !(aoe > 0)) return; + const x = victim.x, y = victim.y; + // deferred to the next scheduler pass: chained explosions must not nest kill → damage → kill hooks + battle.after(0, () => { + if (!live(unit)) return; + battle.fx('scorchBurst', { x, y, id: unit.id, r: aoeR }); + for (const e of battle.foesInRadius(x, y, aoeR, true)) { // splash around the victim: 中点判定 + if (!e.alive) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * aoe, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'scorch'] }); + scorch(battle, unit, e); + } + }, { owner: unit }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 映耀 + const share = num(t1.scale), boost = num(t1.heal_scale, 1); + battle.on('heal', (ctx) => { + if (ctx.source !== unit || ctx.target === unit || ctx.opts?.reflect) return; + if (boost !== 1) ctx.amount *= boost; + if (share > 0 && ctx.amount > 0) battle.heal(unit, unit, ctx.amount * share, { self: true, reflect: true }); + }, { owner: unit }); + } }, + ], + }; +} + +export default { + chess_char_6_08_a: reed2, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_09-cello.js b/server/sim/content/kits/ops/chess_char_6_09-cello.js new file mode 100644 index 00000000..89a2b4cb --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_09-cello.js @@ -0,0 +1,146 @@ +// server/sim/content/kits/ops/chess_char_6_09-cello.js — 塑心 (char_245_cello) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { bodyInKeys } from '../../../body.js'; +import { hasHp } from '../../../damage.js'; +import { + num, tbb, moduleBb, live, isElite, enemiesIn, selectedSkill, skillGridOf, bstate, onElementHit, elementDmg, aura, +} from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 塑心 chess_char_6_09 (巫役) — S1 “黄金的狂喜”; 无词哀歌; 精神逆构; module 强弱法 + +function cello(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess); + const sid = selectedSkill(chess, def); + const isS1 = sid === 'skchr_cello_1' || !(chess?.skills?.length); // S1 = the default (no loadout info ⇒ default) + // "第二天赋效果提升至N倍" (S3): every 精神逆构 number scaled around 1 (delta × N) while it runs + const boost = (c) => (c.mem.celloBoost > 0 ? c.mem.celloBoost : 1); + const scaled = (v, c) => 1 + (v - 1) * boost(c); + /** S2 partner: the other operator of her range with the highest ATK (re-picked every 0.5 s). */ + const pickPartner = (battle, unit) => { + unit.mem.celloPick = 0.5; + const ops = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op'); + unit.mem.celloPartner = ops.reduce((best, a) => (!best || a.s.atk > best.s.atk + 1e-9 ? a : best), null); + }; + const skills = { + // S2 “安魂的弥撒”: ASPD +, one extra target; while it runs every damage she or the highest-ATK other operator of her + // range deals to an enemy also adds ep_damage_ratio × her ATK 凋亡损伤 (install) + skchr_cello_2: { + kind: 'duration', + mods: { aspd: num(bb.attack_speed) }, + targeting: { maxTargets: 2 }, + onStart({ battle, unit }) { pickPartner(battle, unit); }, + onTick({ battle, unit, dt }) { + unit.mem.celloPick -= dt; + if (unit.mem.celloPick > 0 && live(unit.mem.celloPartner)) return; + pickPartner(battle, unit); + }, + onEnd({ unit }) { unit.mem.celloPartner = null; }, + }, + // S3 “自由的探戈”: stops attacking, skill range, ATK +atk, 精神逆构 ×scale_delta_to_one; the other operators of her + // range with the highest max HP / ATK / DEF get +20 % of that stat + skchr_cello_3: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + ...(skillGridOf(def) ? { targeting: { rangeGrid: skillGridOf(def) } } : {}), + attack: { noAttack: true }, + onStart({ unit }) { unit.mem.celloBoost = num(bb.scale_delta_to_one, 1); unit.mem.celloAcc = Infinity; }, + onTick({ battle, unit, dt }) { + unit.mem.celloAcc += dt; + if (unit.mem.celloAcc < 0.5) return; + unit.mem.celloAcc = 0; + const ops = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op'); + const top = (f) => ops.reduce((best, a) => (!best || f(a) > f(best) + 1e-9 ? a : best), null); + const give = (a, key, mods) => { if (a) battle.addBuff(a, { key, mods, duration: 0.75, refresh: 'replace', source: unit, visible: true }); }; + give(top((a) => a.base.maxHp), 'cello:tango:hp', { hpPct: num(bb['cello_s_3[max_hp].max_hp']) }); + give(top((a) => a.base.atk), 'cello:tango:atk', { atkPct: num(bb['cello_s_3[atk].atk']) }); + give(top((a) => a.base.def), 'cello:tango:def', { defPct: num(bb['cello_s_3[def].def']) }); + }, + onEnd({ unit }) { unit.mem.celloBoost = 1; }, + }, + }; + return { + skills, + // S1 "技能未开启时无法普通攻击" and its 未处于损伤爆发期间 target pick belong to S1 only + trait: isS1 ? { noAttackUnlessSkill: true, priority: 'notBurst' } : null, + install(battle, unit) { + if (sid !== 'skchr_cello_2') return; + const ratio = num(bb.ep_damage_ratio); + // PRTS 塑心 S2 备注: "受影响的干员即将造成伤害时,因该效果造成的凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源 + // 始终为塑心" — a late `hit` handler (after every handler that may cancel the damage), so the 凋亡 lands while the + // target is alive and may burst before the damage; 无来源 bursts (source null) never trigger it. It rides the + // damage about to be dealt, so a hit dodged or absorbed afterwards still carried it [ASSUMED]. + battle.on('hit', (ctx) => { + const s = ctx.source, t = ctx.target, d = ctx.dmg; + if (!(ratio > 0) || !unit.skill?.active || !live(unit) || !s || !t || t.side !== 'enemy' || !hasHp(t) || !d || d.cancel) return; + if (d.type === 'element' || d.type === 'elemental' || !(d.amount > 0) || (s !== unit && s !== unit.mem.celloPartner)) return; + elementDmg(battle, unit, t, 'apoptosis', unit.s.atk * ratio); + }, { owner: unit, priority: -1000 }); + }, + skill: { + kind: 'charges', + targeting: { priority: 'notBurst' }, + attack: { + atkScale: num(bb.atk_scale, 1), + onHit({ battle, unit, target }) { if (target && target.alive) elementDmg(battle, unit, target, 'apoptosis', unit.s.atk * num(bb.ep_damage_ratio)); }, + }, + }, + talents: [ + { install(battle, unit) { // 无词哀歌 + const er = num(t0.ep_damage_ratio), slug = num(t0.sluggish); + aura(battle, unit, 1, () => { + for (const e of enemiesIn(battle, unit)) { + if (er > 0) elementDmg(battle, unit, e, 'apoptosis', unit.s.atk * er, ['talent']); + if (slug > 0 && e.alive) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit }); + } + }); + } }, + { install(battle, unit) { // 精神逆构 (+ module element fragile / field-wide + DoT) and trait module (×1.18 vs elite/leader) + const amp = num(t1.ep_damage_scale, 1), frag = num(t1.damage_scale, 1) - 1, elite = num(tb.ep_damage_scale, 1); + // module 音乐家的旅程: "在场时,全场敌人…" (field-wide) + damage_value 元素伤害 per second during the burst + const dot = num(t1.damage_value), fieldWide = t1.damage_value != null, dotIv = Math.max(0.1, num(t1.interval, 1)); + unit.mem.celloAmp = amp; + unit.mem.celloWide = fieldWide; + // one battle-wide handler: every apoptosis fill on an enemy inside some 塑心's range (or anywhere for a field-wide + // one) × the strongest 精神逆构 (the same effect from two 塑心 does not stack) + const S = bstate(battle); + if (!S.cellos) { + S.cellos = new Set(); + battle.on('elementHit', (ctx) => { + const t = ctx.target, d = ctx.dmg; + if (!d || d.type !== 'element' || d.element !== 'apoptosis' || !t || t.side !== 'enemy') return; + let best = 1; + for (const c of S.cellos) { + if (!live(c) || !(c.mem.celloWide || bodyInKeys(t, c.rangeKeySet))) continue; + const v = scaled(c.mem.celloAmp, c); + if (v > best) best = v; + } + if (best > 1) d.mul *= best; + }); + } + if (amp > 1) S.cellos.add(unit); + // 强弱法 (elite trait): her element damage vs elite / leader enemies ×1.18 + if (elite > 1) onElementHit(battle, unit, (ctx) => (ctx.source === unit && ctx.target.side === 'enemy' && isElite(ctx.target) ? elite : 1)); + // "凋亡损伤爆发期间受到5%的元素脆弱" — the 元素脆弱 status (同名效果取最高) + if (frag > 0) aura(battle, unit, 0.25, () => { + for (const e of enemiesIn(battle, unit)) if (e.findBuff('apoptosisBurst')) battle.applyStatus(e, 'elemFragile', { duration: 0.4, value: frag * boost(unit), source: unit }); + }); + if (dot > 0) aura(battle, unit, dotIv, () => { + for (const e of battle.enemies) { + if (e.alive && !e.hidden && e.findBuff('apoptosisBurst')) battle.dealDamage(unit, e, { amount: dot * boost(unit), type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'elementDmg'] }); + } + }); + // module 音乐家的旅程 trait: "攻击范围内敌人受到10%的元素脆弱" + const mFrag = num(mod.damage_scale, 1) - 1; + if (mFrag > 0) aura(battle, unit, 0.25, () => { + for (const e of enemiesIn(battle, unit)) battle.applyStatus(e, 'elemFragile', { duration: 0.4, value: mFrag, source: unit }); + }); + } }, + ], + }; +} + +export default { + chess_char_6_09_a: cello, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_10-nymph.js b/server/sim/content/kits/ops/chess_char_6_10-nymph.js new file mode 100644 index 00000000..1d993f47 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_10-nymph.js @@ -0,0 +1,69 @@ +// server/sim/content/kits/ops/chess_char_6_10-nymph.js — 妮芙 (char_4146_nymph) kit, tier 6 (hidden). +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { bodyInKeys } from '../../../body.js'; +import { num, tbb, live, elementDmg } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 妮芙 chess_char_6_10 (本源术师, hidden tier-6 entry) — S2 怵然震爆; 失魂; 窥心钥; module 心声 + +function nymph(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const soul = (battle, unit, t, scale) => { + const burst = t.findBuff('apoptosisBurst'); + if (!burst || !(scale > 0)) return; + const key = `nymph:soul:${unit.id}`; + const cur = t.findBuff(key); + if (cur) { cur.data.scale = Math.max(cur.data.scale, scale); cur.timeLeft = Math.max(cur.timeLeft, burst.timeLeft); return; } + battle.addBuff(t, { + key, duration: Math.max(0.1, burst.timeLeft), interval: 1, source: unit, data: { scale }, + // 元素伤害 = the 'elemental' damage type (no DEF/RES, × elementalTakenMul) + onTick: ({ unit: e, buff }) => { if (e.findBuff('apoptosisBurst')) battle.dealDamage(unit, e, { amount: unit.s.atk * buff.data.scale, type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'elementDmg'] }); }, + }); + }; + return { + skill: { + kind: 'charges', + attack: { + atkScale: num(bb.atk_scale, 1), + splashRadius: num(bb.projectile_range, 1.5), + splashScale: 1, + onHit({ battle, unit, target }) { + if (target && target.alive && num(bb.fear) > 0) battle.applyStatus(target, 'fear', { duration: num(bb.fear), source: unit }); + battle.fx('shockBlast', { x: target ? target.x : unit.x, y: target ? target.y : unit.y, id: unit.id }); + }, + }, + }, + talents: [ + { install(battle, unit) { // skill apoptosis (per damaged target) + 失魂 + battle.on('damaged', (ctx) => { + const t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || ctx.type === 'element' || !ctx.dmg?.isAttack) return; + const skillHit = !!ctx.dmg.isSkill; + if (t.alive) soul(battle, unit, t, skillHit ? num(bb.element_atk_scale, num(t0.element_atk_scale)) : num(t0.element_atk_scale)); + if (skillHit && t.alive) elementDmg(battle, unit, t, 'apoptosis', ctx.amount * num(bb.ep_damage_ratio)); + }, { owner: unit }); + } }, + { install(battle, unit) { // 窥心钥 (elite module: field-wide, +ASPD at max stacks) + const atk = num(t1.atk), max = Math.max(1, Math.floor(num(t1.max_stack_cnt, 10))); + const fieldWide = t1.stack_cnt_check != null, as = num(t1.attack_speed); + battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.nymphKey = 0; }, { owner: unit }); + battle.on('elementBurst', ({ target, element }) => { + if (element !== 'apoptosis' || !live(unit) || !target || target.side !== 'enemy') return; + if (!fieldWide && !bodyInKeys(target, unit.rangeKeySet)) return; + unit.mem.nymphKey = Math.min(max, (unit.mem.nymphKey || 0) + 1); + const n = unit.mem.nymphKey; + battle.addBuff(unit, { key: 'nymph:key', mods: { atkPct: atk * n, aspd: fieldWide && n >= max ? as : 0 } }); + }, { owner: unit }); + } }, + { install(battle, unit) { // elite module: ×1.1 vs targets in an element burst + const ds = num(tb.damage_scale, 1); + if (ds > 1) battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.target.s.flags.burstLock) ctx.dmg.mul *= ds; }, { owner: unit }); + } }, + ], + }; +} + +export default { + chess_char_6_10_a: nymph, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_11-mlyss.js b/server/sim/content/kits/ops/chess_char_6_11-mlyss.js new file mode 100644 index 00000000..e6cf1b34 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_11-mlyss.js @@ -0,0 +1,316 @@ +// server/sim/content/kits/ops/chess_char_6_11-mlyss.js — 缪尔赛思 (char_249_mlyss) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { + num, bv, tbb, tdesc, moduleBb, parseN, live, isTok, onDefaultSkill, selectedSkill, AROUND8, N4, bstate, bestTile, + pullToward, +} from '../shared/tier6.js'; + +/** 莱茵生命 (character_table groupId "rhine") members of this season's pool (checked against the official table). */ +const RHINE = new Set(['char_108_silent', 'char_128_plosis', 'char_202_demkni', 'char_249_mlyss', 'char_1047_halo2']); + +// ------------------------------------------------------------------------------------------------------------------ +// 缪尔赛思 chess_char_6_11 (战术家) — S3 浅层非熵适应; 净水即生命 (流形); 开源节流; module 梳妆流形 + +/** + * The operator a 流形 copies: the nearest (Chebyshev tiles) living operator of its player on the field, not the summoner; + * ties → higher base ATK → lower id. Same pick as content/tokens.js (unmanaged 流形), so both paths agree. + */ +function pickCopyTarget(battle, owner, t) { + let best = null, bs = null; + for (const a of battle.allyUnits) { + if (a.kind !== 'op' || !live(a) || a === owner || a.ownerId !== owner.ownerId) continue; + const s = [Math.max(Math.abs(a.tileR - t.tileR), Math.abs(a.tileC - t.tileC)), -a.base.atk, a.id]; + let less = !bs; + if (!less) for (let i = 0; i < 3; i++) { if (s[i] < bs[i]) { less = true; break; } if (s[i] > bs[i]) break; } + if (less) { best = a; bs = s; } + } + return best; +} + +/** + * 流形 copy (token talent "复制目标90%的生命值、攻击力、防御力、法抗,以及阻挡数、攻击间隔、攻击范围、初始伤害类型(不攻击、 + * 治疗类型则不继承)"): scale × HP/ATK/DEF/RES, block count, BAT/ASPD, range and damage type. The attack itself stays the + * token's single-target attack (melee or ranged after the copied position) — splash/chain/multi-hit shapes are not + * listed and not copied. `ranged` (近/远程位) decides the special trait (steal / split). + */ +function copyInto(battle, t, src, scale, ranged) { + const sb = src.base, b = t.base; + b.maxHp = Math.max(1, num(sb.maxHp, b.maxHp) * scale); + for (const k of ['atk', 'def', 'res']) if (Number.isFinite(sb[k])) b[k] = sb[k] * scale; + for (const k of ['blockCnt', 'bat', 'aspd']) if (Number.isFinite(sb[k])) b[k] = sb[k]; + if (Array.isArray(src.rangeGrid) && src.rangeGrid.length) t.rangeGrid = src.rangeGrid; + const sp = src.profile || {}; + const p = t.profile; + if (p) { + p.attack = ranged ? 'ranged' : 'melee'; + // (a 阵法术师 / 轰击术师's instant 'beam' is their every-enemy-in-range shape: the copy fires a plain bolt) + p.projectile = ranged ? (sp.projectile && sp.projectile !== 'none' && sp.projectile !== 'orb' && sp.projectile !== 'beam' ? sp.projectile : 'bolt') : 'none'; + p.canHitFly = ranged ? true : !!sp.canHitFly; + if (!(sp.dmgType === 'heal' || sp.dmgType === 'none' || sp.noAttack || sp.noAttackUnlessSkill)) p.dmgType = sp.dmgType; + p.heal = null; + p.noAttack = false; + p.noAttackUnlessSkill = false; + } + battle.removeBuff(t, 'mlyss:stolen'); + t.mem.mlyss = { ranged, from: src.id, stolenAtk: 0, stolenDef: 0, attacks: 0 }; // "复制后重置" + t.markDirty(); + t.hp = t.s.maxHp; + battle.refreshRange(t); // the copied grid: current + initial range, trigger keys +} + +function mlyss(bb, chess, def) { + const tokId = def?.talents?.[0]?.tokenKey || (chess?.tokens || [])[0] || 'token_10030_mlyss_wtrman'; + const t1 = tbb(def, 1), mod = moduleBb(chess); + const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); + const reinfCut = parseN(chess?.trait?.moduleDesc, /伤害降低(\d+(?:\.\d+)?)%/, 0) / 100; + // S3 (default) only: melee pulse interval, ranged-copy bind + const pulseIv = Math.max(0.2, bv(bb, 'interval', 2)), bindDur = isDef ? bv(bb, 'duration', 1.5) : 0; + // "自身与流形攻击力+N%(攻击速度+N)" — every skill of hers buffs the 流形 too while it runs + const manifoldMods = { atkPct: num(bb.atk), ...(num(bb.attack_speed) ? { aspd: num(bb.attack_speed) } : {}) }; + const buffManifold = (battle, t, dur) => battle.addBuff(t, { key: 'mlyss:s3', mods: manifoldMods, duration: dur }); + // module 落叶四季 (开源节流): copying a 莱茵生命 operator ⇒ she gains `sp`; deploying the copied one ⇒ it gains `sp_other` + const rhineSp = num(mod.sp), rhineSpOther = num(mod.sp_other); + const tokTal = (t, key) => { for (const x of t.def?.talents || []) if (x?.bb && typeof x.bb[key] === 'number') return x.bb; return {}; }; + // "其被击败后会在25秒后自动刷新" (token talent interval; the talent text as a fallback) + const respawnOf = (t) => num(tokTal(t, 'scale').interval, parseN(tdesc(def, 0), /(\d+(?:\.\d+)?)秒后自动刷新/, 25)); + const isRanged = (src) => (src.def?.position ? src.def.position === 'RANGED' : src.profile?.attack === 'ranged'); + + const copyNearest = (battle, unit, t) => { + const src = pickCopyTarget(battle, unit, t); + if (!src) { // nobody to copy: the copy skill is not spent — it ends and stays ready (no attack without a copy) + if (t.skill?.active) { t.skill.end('noTarget'); t.skill.gainSp(t.skill.spCost, 'refund'); } + return; + } + copyInto(battle, t, src, num(tokTal(t, 'scale').scale, 1), isRanged(src)); + battle.fx('copy', { x: t.x, y: t.y, id: t.id, src: src.id }); + if (rhineSp > 0 && RHINE.has(src.def?.charId) && unit.skill) unit.skill.gainSp(rhineSp, 'talent'); + }; + const mineLive = (battle, unit) => battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x)); + + const skills = { + // S1 渐进性润化: fake_cost DP over the skill (1 every mlyss_s_1[cost].interval s, the rest at its end), she and her + // 流形 ATK / ASPD + + skchr_mlyss_1: { + kind: 'duration', + mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, + onStart({ battle, unit, skill }) { + unit.mem.mlyDp = { acc: 0, got: 0 }; + for (const t of mineLive(battle, unit)) buffManifold(battle, t, skill.timeLeft); + }, + onTick({ battle, unit, dt }) { + const m = unit.mem.mlyDp; + if (!m) return; + const iv = Math.max(0.1, bv(bb, 'interval', 1.364)), total = Math.floor(num(bb.fake_cost, 11)), per = bv(bb, 'cost', 1); + m.acc += dt; + while (m.acc + 1e-9 >= iv && m.got < total) { m.acc -= iv; m.got++; battle.addDp(unit.ownerId, per); } + }, + onEnd({ battle, unit, reason }) { + const m = unit.mem.mlyDp; + unit.mem.mlyDp = null; + const total = Math.floor(num(bb.fake_cost, 11)); + if (m && reason === 'duration' && m.got < total) battle.addDp(unit.ownerId, (total - m.got) * bv(bb, 'cost', 1)); + }, + }, + // S2 生态耦合: +cost DP, she and her 流形 ATK +; melee copies regenerate hp ratio / s and take 庇护; ranged copies + // attack twice at random targets of their range (install) + skchr_mlyss_2: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + onStart({ battle, unit, skill }) { + battle.addDp(unit.ownerId, num(bb.cost)); + battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost) }); + for (const t of mineLive(battle, unit)) buffManifold(battle, t, skill.timeLeft); + }, + }, + }; + + return { + skills, + install(battle, unit) { + if (sid === 'skchr_mlyss_2') { + const regen = num(bb.hp_recovery_per_sec_by_max_hp_ratio), dr = num(bb.damage_resistance); + battle.every(0.25, () => { + const on = !!unit.skill?.active && live(unit); + for (const t of battle.allyUnits) { + if (!isTok(t, tokId, unit) || !t.profile) continue; + const m = t.mem.mlyss; + if (t.mem.mlyssHits == null) t.mem.mlyssHits = Math.max(1, Math.floor(num(t.profile.hits, 1))); + const ranged = !!(m && m.ranged); + t.profile.hits = on && ranged && live(t) ? t.mem.mlyssHits * 2 : t.mem.mlyssHits; // 二连击 + if (on && m && !ranged && live(t)) { + battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen, physTakenMul: 1 - dr, artsTakenMul: 1 - dr } }); + } + } + }, { owner: unit }); + battle.on('beforeAttack', (ctx) => { // ranged copies: 随机攻击范围内的目标 + const t = ctx.attacker; + if (!isTok(t, tokId, unit) || !t.mem.mlyss?.ranged || !unit.skill?.active) return; + const c = battle.enemiesInKeys(t.rangeKeys, t, ctx.profile); + if (c.length) ctx.targets = [battle.rng.pick(c)]; + }, { owner: unit }); + } + if (rhineSpOther > 0) battle.on('deploy', ({ unit: u, initial }) => { // the copied 莱茵生命 operator is deployed + if (initial || !u || u.kind !== 'op' || u.ownerId !== unit.ownerId || !RHINE.has(u.def?.charId) || !u.skill) return; + if (battle.allyUnits.some((t) => isTok(t, tokId, unit) && live(t) && t.mem.mlyss?.from === u.id)) u.skill.gainSp(rhineSpOther, 'talent'); + }, { owner: unit }); + }, + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + onStart({ battle, unit, skill }) { + battle.addDp(unit.ownerId, num(bb.cost)); + battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost) }); + const S = unit.mem; + let ranged = false; + for (const t of battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x))) { + buffManifold(battle, t, skill.timeLeft); + if (t.mem.mlyss?.ranged) ranged = true; + } + if (ranged) { // 刷新所有流形: heal the living ones, respawn the destroyed ones now + for (const t of battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x))) t.hp = t.s.maxHp; + for (const p of (S.mlyssPending || []).splice(0)) { p.timer?.cancel?.(); p.spawn(); } + } + S.mlyssPulse = battle.every(pulseIv, () => { // melee copies: pull the 8 surrounding tiles + stun blocked units + if (!unit.skill?.active) return; + for (const t of battle.allyUnits) { + if (!isTok(t, tokId, unit) || !live(t) || !t.mem.mlyss || t.mem.mlyss.ranged) continue; + for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force)); + for (const e of t.blocking) if (e.alive) battle.applyStatus(e, 'stun', { duration: pulseIv + 0.1, source: unit }); + battle.fx('pulse', { x: t.x, y: t.y, id: t.id }); + } + }, { owner: unit, immediate: true }); + }, + onEnd({ unit }) { if (unit.mem.mlyssPulse) unit.mem.mlyssPulse.cancel(); unit.mem.mlyssPulse = null; }, + }, + talents: [ + { install(battle, unit) { // 净水即生命: 流形 copy / respawn / melee steal / ranged split + unit.mem.mlyssPending = []; + const S = bstate(battle); + S.robbed ??= new WeakMap(); // enemy → { atk, def } stolen by any 流形 + const mine = (t) => isTok(t, tokId, unit); + // her 流形 on the field (split clones aside: deployLimit 1 counts the 流形 itself) + const standing = () => battle.allyUnits.some((t) => mine(t) && live(t) && !t.mem.mlyssClone); + const cancelPending = () => { for (const p of unit.mem.mlyssPending.splice(0)) p.timer?.cancel?.(); }; + battle.on('deploy', ({ unit: t }) => { + if (!mine(t)) return; + const clone = unit.mem.mlyssCloneOf; + if (clone) { // a split copy: same stats as its origin, never copies/splits/respawns itself + unit.mem.mlyssCloneOf = null; + t.mem.mlyssClone = true; + t.mem.isClone = true; // content/tokens.js skips clones too (her redeploy must bring a real 流形, not adopt a clone) + copyInto(battle, t, clone, 1, true); + return; + } + cancelPending(); // a 流形 stands again (tactical re-summon, S3 refresh): no second one later + t.mem.mlyssHome = [t.tileR, t.tileC]; + const sp = num(tokTal(t, 'sp').sp); + if (sp > 0 && !unit.mem.mlyssFirst && t.skill) t.skill.gainSp(sp, 'talent'); // elite: first deployment +5 SP + unit.mem.mlyssFirst = true; + if (unit.skill?.active && unit.skill.isTimed) buffManifold(battle, t, unit.skill.timeLeft); + }, { owner: unit }); + battle.on('skillStart', ({ unit: t }) => { if (mine(t) && !t.mem.mlyssClone) copyNearest(battle, unit, t); }, { owner: unit }); + // trigger rule MLYSS_WTRMAN ("全部技能,受流形影响") [ASSUMED]: DEFAULT (engine), and an enemy inside the range of one + // of her copied 流形 also satisfies it (her S3 acts through the 流形: pull/stun or bind around them) + if (unit.skill) unit.skill.addTriggerRange(() => battle.allyUnits.filter((t) => mine(t) && live(t) && t.mem.mlyss)); + battle.on('tick', () => { // without a dedicated token kit the copy fires once ready and someone can be copied + for (const t of battle.allyUnits) { + if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready || t.skill.opCooling) continue; + if (pickCopyTarget(battle, unit, t)) t.skill.activate('MLYSS_WTRMAN'); + } + }, { owner: unit }); + // "其被击败后会在25秒后自动刷新": one pending respawn at a time, on its tile, only while she is on the field and no + // 流形 of hers stands (her redeploy summons it again as her 援军, tokens.js); expired / replaced ones do not return + battle.on('death', ({ unit: t, reason }) => { + if (t === unit) { cancelPending(); return; } + if (!mine(t) || t.mem.mlyssClone || reason !== 'killed' || !t.mem.mlyssHome || standing()) return; + cancelPending(); + const [r, c] = t.mem.mlyssHome; + const entry = { spawn: null, timer: null }; + let tries = 0; + entry.spawn = () => { + const i = unit.mem.mlyssPending.indexOf(entry); + if (i >= 0) unit.mem.mlyssPending.splice(i, 1); + if (battle.finished || !live(unit) || standing() || tries++ > 120) return; + if (battle.isReservedTile(r, c) || !battle.spawnToken(unit, tokId, r, c)) { // its tile is taken: retry in 1 s + entry.timer = battle.after(1, entry.spawn, { owner: unit }); + unit.mem.mlyssPending.push(entry); + } + }; + entry.timer = battle.after(respawnOf(t), entry.spawn, { owner: unit }); + unit.mem.mlyssPending.push(entry); + }, { owner: unit }); + battle.on('attack', ({ attacker: t, targets }) => { + if (!mine(t) || !t.mem.mlyss || !live(t)) return; + const tal = tokTal(t, 'steal_atk'); + const m = t.mem.mlyss; + if (!m.ranged) { // melee copy: 每次攻击偷取敌方10点攻击力与防御力(最高250点)— a capped copy steals nothing more + const sa = num(tal.steal_atk), sd = num(tal.steal_def, sa); + const capA = num(tal.steal_atk_max, Infinity), capD = num(tal.steal_def_max, capA); + let changed = false; + for (const e of targets) { + if (!e.alive || e.side !== 'enemy') continue; + const ga = Math.max(0, Math.min(sa, capA - m.stolenAtk)), gd = Math.max(0, Math.min(sd, capD - m.stolenDef)); + if (!(ga > 0 || gd > 0)) break; + m.stolenAtk += ga; m.stolenDef += gd; + const rb = S.robbed.get(e) || { atk: 0, def: 0 }; + rb.atk += ga; rb.def += gd; + S.robbed.set(e, rb); + battle.addBuff(e, { key: 'mlyss:robbed', mods: { atkFlat: -rb.atk, defFlat: -rb.def }, source: t }); + changed = true; + } + if (changed) battle.addBuff(t, { key: 'mlyss:stolen', mods: { atkFlat: m.stolenAtk, defFlat: m.stolenDef } }); + } else if (!t.mem.mlyssClone) { // ranged copy: split every N attacks (the clone lasts `interval` s) + const every = Math.max(1, Math.floor(bv(tal, 'max_stack_cnt', 10))); + if (++m.attacks % every !== 0) return; + const tiles = N4.slice(1).map(([dr, dc]) => [t.tileR + dr, t.tileC + dc]) + .filter(([r, c]) => battle.grid.inRect(r, c) && !battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: true })); + const tile = bestTile(battle, tiles); + if (!tile) return; + unit.mem.mlyssCloneOf = t; + let cl = null; + try { cl = battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: num(tal.interval, 25), kit: { skill: null } }); } finally { unit.mem.mlyssCloneOf = null; } + if (cl) battle.fx('split', { x: cl.x, y: cl.y, id: cl.id, src: t.id }); + } + }, { owner: unit }); + battle.on('damaged', (ctx) => { // S3, ranged copies: attacks bind + const t = ctx.source; + if (!mine(t) || !t.mem.mlyss?.ranged || !unit.skill?.active || !ctx.dmg?.isAttack || !ctx.target.alive || !(bindDur > 0)) return; + battle.applyStatus(ctx.target, 'bind', { duration: bindDur, source: unit }); + }, { owner: unit }); + battle.on('hit', (ctx) => { // module: 援军 / 流形 take less damage from the enemies they block + const t = ctx.target, s = ctx.source; + if (!t || !s || s.side !== 'enemy' || s.blockedBy !== t) return; + if (mine(t)) { // 流形 (her 援军): token module part, unless its token kit (content/tokens.js) applies it + if (!t.kit?.fromTokens) { const ds = num(tokTal(t, 'damage_scale').damage_scale, 1); if (ds > 0 && ds < 1) ctx.dmg.mul *= ds; } + } else if (reinfCut > 0 && t === unit.trait?.reinforcement) ctx.dmg.mul *= 1 - reinfCut; // engine 援军 + }, { owner: unit }); + // module 落叶四季: "援军阻挡的敌人更容易受到我方的攻击" — the 流形's module part (token talent taunt_level: +1) on + // the enemies it blocks: our operators pick higher-taunt enemies first (targeting.js sortEnemyTargets) + battle.every(0.25, () => { + for (const t of battle.allyUnits) { + if (!mine(t) || !live(t) || !t.blocking.length) continue; + const lv = num(tokTal(t, 'taunt_level').taunt_level); + if (!(lv > 0)) continue; + for (const e of t.blocking) if (e.alive) battle.addBuff(e, { key: 'mlyss:exposed', mods: { taunt: lv }, duration: 0.4, refresh: 'replace', source: t }); + } + }, { owner: unit }); + } }, + { install(battle, unit) { // 开源节流: 莱茵生命 ops of the owner cost less DP to redeploy + const S = bstate(battle); + S.rhine ??= new Set(); + if (S.rhine.has(unit.ownerId)) return; + S.rhine.add(unit.ownerId); + const cut = num(t1.cost), first = num(t1.runtime_cost); + battle.on('battleStart', () => { + const rh = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && RHINE.has(a.def?.charId)).sort((a, b) => a.deploySeq - b.deploySeq); + rh.forEach((a, i) => { a.base.cost = Math.max(0, a.base.cost + cut + (i === 0 ? first : 0)); }); + }); + } }, + ], + }; +} + +export default { + chess_char_6_11_a: mlyss, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_12-rosmon.js b/server/sim/content/kits/ops/chess_char_6_12-rosmon.js new file mode 100644 index 00000000..b3d82b43 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_12-rosmon.js @@ -0,0 +1,105 @@ +// server/sim/content/kits/ops/chess_char_6_12-rosmon.js — 迷迭香 (char_391_rosmon) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets } from '../../../targeting.js'; +import { summonToken } from '../../tokens.js'; +import { num, tbb, live, opsOf, selectedSkill, batOf } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 迷迭香 chess_char_6_12 (投掷手) — S2 末梢阻断; 歼灭战装备; 感知稳定 + +/** S2 末梢阻断 "溅射范围扩大": radius 1.5 (PRTS 溅射半径一览, 技能: 迷迭香 末梢阻断 1.5; ×1.3 [ASSUMED] until 0.1.1). */ +const ROSMON_S2_SPLASH = 1.5; + +function rosmon(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const sid = selectedSkill(chess, def); + const prob = num(bb['attack@prob']), stun = num(bb['attack@stun']); + const gearId = (chess?.tokens || []).find((t) => /rosmon_shield/.test(String(t))) || 'token_10012_rosmon_shield'; + const skills = { + // S1 思维膨大 (attack SP): the next attack also deals extra_atk_scale × ATK arts to every enemy it hits + skchr_rosmon_1: { + kind: 'instant', + attack: { + onEachHit({ battle, unit, target, kind }) { + if (!target || !target.alive || (kind !== 'main' && kind !== 'splash')) return; + battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb.extra_atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); + }, + }, + }, + // S3 “如你所愿”: base attack time ×0.5 (akdata: 乘算负数), ATK +atk, 2 targets but blocked enemies only (install); + // two 战术装备 on melee tiles of her range (the token kit: appear stun, blocked enemies DEF −160, lifetime) + skchr_rosmon_3: { + kind: 'duration', + mods: { batPct: batOf(bb.base_attack_time, def, true), atkPct: num(bb.atk) }, + targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))) }, + onStart({ battle, unit }) { + let n = 0; + for (let i = 0; i < 2; i++) if (summonToken(battle, unit, gearId, 'melee')) n++; + battle.fx('summon', { x: unit.x, y: unit.y, id: unit.id, n }); + }, + }, + }; + return { + skills, + install(battle, unit) { + if (sid !== 'skchr_rosmon_3') return; + battle.on('beforeAttack', (ctx) => { // "仅选择被阻挡的敌人作为目标" + if (ctx.attacker !== unit || !unit.skill?.active) return; + // her range, plus the enemies she blocks herself — always her targets (Battle.blockedTargets, DESIGN §20.3) + const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile); + for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e); + for (let i = c.length - 1; i >= 0; i--) if (!c[i].blockedBy) c.splice(i, 1); + sortEnemyTargets(battle, unit, c, ctx.profile?.priority ?? null); + ctx.targets = c.slice(0, Math.max(1, Math.floor(num(ctx.profile?.maxTargets, 2)))); + }, { owner: unit }); + }, + skill: { + kind: 'duration', + mods: { batPct: batOf(bb.base_attack_time, def), atkPct: num(bb.atk) }, + onStart({ unit }) { + const p = unit.profile; + unit.mem.rosSaved = { r: p.splashRadius, n: p.shockTimes }; + p.splashRadius = Math.max(num(p.splashRadius, 0.9), ROSMON_S2_SPLASH); + p.shockTimes = num(p.shockTimes, 2) + Math.floor(num(bb.add_times)); + }, + onEnd({ unit }) { + const s = unit.mem.rosSaved; + if (s) { unit.profile.splashRadius = s.r; unit.profile.shockTimes = s.n; } + unit.mem.rosSaved = null; + }, + }, + talents: [ + { install(battle, unit) { // S2: attack & aftershock stun chance + battle.on('damaged', (ctx) => { + if (ctx.source !== unit || !unit.skill?.active || !(stun > 0) || !ctx.target.alive || ctx.target.side !== 'enemy') return; + if (!ctx.dmg?.isAttack && !ctx.dmg?.tags?.includes('aftershock')) return; + if (battle.rng() < prob) battle.applyStatus(ctx.target, 'stun', { duration: stun, source: unit }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 歼灭战装备 + const v = num(t0.def_penetrate_fixed); + if (v) battle.addBuff(unit, { key: 'rosmon:pierce', mods: { defIgnoreFlat: v }, persist: true, allowDead: true }); + } }, + { install(battle, unit) { // 感知稳定 + const atk = num(t1.atk); + battle.on('deploy', ({ unit: u }) => { + if (u !== unit || !atk) return; + battle.after(0, () => { + if (!live(unit)) return; + const casters = opsOf(battle, unit.ownerId).filter((a) => a.def?.profession === 'CASTER'); + const pick = battle.rng.pick(casters); + if (!pick) return; + battle.addBuff(unit, { key: 'rosmon:stable', mods: { atkPct: atk } }); + battle.addBuff(pick, { key: 'rosmon:stable', mods: { atkPct: atk } }); + battle.fx('link', { x: pick.x, y: pick.y, id: pick.id, src: unit.id }); + }, { owner: unit }); + }, { owner: unit }); + } }, + ], + }; +} + +export default { + chess_char_6_12_a: rosmon, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_13-angel2.js b/server/sim/content/kits/ops/chess_char_6_13-angel2.js new file mode 100644 index 00000000..7f25ab2a --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_13-angel2.js @@ -0,0 +1,180 @@ +// server/sim/content/kits/ops/chess_char_6_13-angel2.js — 新约能天使 (char_1041_angel2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets, aggroCmp } from '../../../targeting.js'; +import { summonToken, TOKEN_IDS } from '../../tokens.js'; +import { num, bv, tbb, live, hasBond, ANY, isTok, selectedSkill, batOf, aura } from '../shared/tier6.js'; + +/** Barrier of `total` HP decaying linearly to 0 over `dur` seconds. */ +function decayingShield(battle, u, key, total, dur) { + if (!(total > 0)) return null; + const decays = dur > 0; + return battle.addBuff(u, { + key, shield: total, duration: decays ? dur : Infinity, visible: true, interval: decays ? 0.5 : 0, + onTick: decays ? ({ unit, buff }) => { buff.shield = Math.max(0, buff.shield - (total * 0.5) / dur); unit.markDirty(); } : null, + }); +} + +// ------------------------------------------------------------------------------------------------------------------ +// 新约能天使 chess_char_6_13 (怪杰) — S2 开火成瘾症; 火力电台; 铳弹协约; module 新朋友圣城生活套组 +// S2 (PRTS 备注): the ally is the friendly operator of her attack range with the highest 仇恨值 ("选择的友方干员为攻击范围内 +// 仇恨值最高的我方干员": highest taunt level, then the latest deployed — targeting.js aggroCmp, the order enemies attack +// in; it used to be the highest ASPD, so the shield often went to a back-row shooter instead of the operator in front); +// both barriers are shield_max_hp_ratio × the holder's OWN max HP ("获得的屏障均以自身生命上限为标准计算"), losing +// initial/shield_max_duration per second ("屏障每秒衰减量为:初始屏障量/30"); a new one replaces the old one ("重复获得此 +// 屏障时,重置屏障量与衰减速度"). + +const AIRSTRIKE_RADIUS = 1; // [ASSUMED] bombardment splash radius (not in data) + +function angel2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const sid = selectedSkill(chess, def); + const steal = num(bb.steal), extra = Math.floor(num(bb.addtional_ammo_each)); + const shieldRatio = num(bb.shield_max_hp_ratio), shieldDur = num(bb.shield_max_duration); + const coordId = (chess?.tokens || []).find((t) => /angel2_target/.test(String(t))) || TOKEN_IDS.deliveryTarget; + const perAttack = 5; // S3 "每次攻击消耗5发" (the 5 连击) + const coord = (battle, unit) => battle.allyUnits.find((t) => isTok(t, coordId, unit) && t.alive) ?? null; + const placeCoord = (battle, unit) => { + unit.mem.angelCoord = false; + if (coord(battle, unit)) return; + const t = summonToken(battle, unit, coordId, 'melee'); + if (t) battle.fx('anchor', { x: t.x, y: t.y, id: t.id, src: unit.id }); + }; + const skills = { + // S1 天空大扫除: 8 bullets at attack@atk_scale × ATK, flyers first; stopped by hand ⇒ the remaining bullets are fired + // at random enemies of her range + skchr_angel2_1: { + kind: 'ammo', + ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 8))), + attack: { atkScale: num(bb['attack@atk_scale'], 1) }, + targeting: { priority: 'fly' }, + onAttack({ unit, skill }) { unit.mem.angelLeft = skill.ammoLeft - 1; }, + onEnd({ battle, unit, skill, reason }) { + const left = Math.max(0, Math.floor(num(unit.mem.angelLeft))); + unit.mem.angelLeft = 0; + if (reason !== 'stopped' || !unit.alive || !left) return; + for (let i = 0; i < left; i++) { + const c = battle.enemiesInKeys(unit.rangeKeys, unit, ANY); + const e = battle.rng.pick(c); + if (!e) break; + battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@atk_scale'], 1), type: 'phys', isAttack: true, isSkill: true, tags: ['skill', 'volley'] }); + battle.emit('ammoUsed', { unit, left: left - i - 1, skill }); + } + }, + }, + // S3 使命必达!: ATK +atk; attacks are 5 hits of attack@atk_scale × ATK spending 5 of the 50 bullets; at the start, + // with a 投递坐标 (placed after each deployment, install): attack@cannon_atk_scale × ATK physical splash there and the + // knocked-out ground operator with the longest remaining redeploy time lands on it with attack@sp SP + skchr_angel2_3: { + kind: 'ammo', + ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 50))), + mods: { atkPct: num(bb.atk) }, + attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: perAttack }, + onStart({ battle, unit }) { + if (unit.mem.angelCoord) placeCoord(battle, unit); + const c = coord(battle, unit); + if (!c) return; + const r = c.tileR, col = c.tileC; + battle.fx('airstrike', { x: col, y: r, id: unit.id, r: AIRSTRIKE_RADIUS }); + for (const e of battle.foesInRadius(col, r, AIRSTRIKE_RADIUS)) { + if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@cannon_atk_scale'], 1), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'delivery'] }); + } + const waiting = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && !a.alive && !a.removed && a !== unit + && a.def?.position !== 'RANGED' && battle.grid.canStand(r, col, { ranged: false })); + if (!waiting.length) return; + waiting.sort((a, b) => (b.respawnAt ?? 0) - (a.respawnAt ?? 0) || b.base.respawnTime - a.base.respawnTime || a.id - b.id); + const a = waiting[0]; + battle.retreat(c, { reason: 'expired', permanent: true }); + if (battle.redeploy(a, { free: true, tile: [r, col] })) { + if (a.skill) a.skill.gainSp(num(bb['attack@sp']), 'skill'); + battle.fx('appear', { x: col, y: r, id: a.id, src: unit.id }); + } + }, + onAttack({ battle, unit, skill }) { // bullets 2..5 of the 5 连击 (the engine spends the 1st) + for (let i = 1; i < perAttack && skill.ammoLeft > 1; i++) { + skill.ammoLeft--; + battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); + } + }, + }, + }; + return { + skills, + install(battle, unit) { + if (sid === 'skchr_angel2_1') { // bullets loaded at the start (after every skillStart ammo bonus: 逃犯引渡手续 …) + battle.on('skillStart', ({ unit: u, skill }) => { if (u === unit) unit.mem.angelLeft = skill.ammoLeft; }, { owner: unit, priority: -500 }); + } + if (sid !== 'skchr_angel2_3') return; + // "部署后获得投递坐标": one coordinate per deployment, placed (the player's choice in the original) on a ground tile + // of her range on the enemy path next to the first enemy that enters her range (at the latest when S3 starts) + battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.angelCoord = !coord(battle, unit); }, { owner: unit }); + battle.on('tick', () => { + if (!unit.mem.angelCoord || !live(unit) || !battle.enemiesInKeys(unit.rangeKeys, unit, ANY).length) return; + placeCoord(battle, unit); + }, { owner: unit }); + }, + skill: { + kind: 'ammo', + ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 10))), + mods: { batPct: batOf(bb.base_attack_time, def) }, + attack: { atkScale: num(bb['attack@atk_scale'], 1) }, + onStart({ battle, unit, skill }) { + const cands = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op' && live(a)); + cands.sort(aggroCmp); + const victim = steal > 0 ? cands[0] ?? null : null; + unit.mem.angelVictim = victim; + decayingShield(battle, unit, 'angel2:barrier', unit.s.maxHp * shieldRatio, shieldDur); + if (victim) { + battle.addBuff(victim, { key: 'angel2:stolen', mods: { aspd: -steal }, source: unit, visible: true }); + battle.addBuff(unit, { key: 'angel2:steal', mods: { aspd: steal } }); + decayingShield(battle, victim, 'angel2:barrier', victim.s.maxHp * shieldRatio, shieldDur); + if (extra > 0) skill.addAmmo(extra); + battle.fx('steal', { x: victim.x, y: victim.y, id: victim.id, src: unit.id }); + } + }, + onEnd({ battle, unit }) { + const v = unit.mem.angelVictim; + unit.mem.angelVictim = null; + if (v) battle.removeBuff(v, 'angel2:stolen'); + battle.removeBuff(unit, 'angel2:steal'); + }, + }, + talents: [ + { install(battle, unit) { // 火力电台 + const hr = num(t0.hp_ratio), prob = num(t0.prob), sc = num(t0.aoe_atk_scale, num(t0.damage_scale)); + battle.on('ammoUsed', ({ unit: u }) => { + if (!live(unit) || !u || u.side !== 'ally') return; + if (hr > 0) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true, silent: true }); + if (!(prob > 0) || !(sc > 0) || battle.rng() >= prob) return; + const cands = battle.enemiesInKeys(u.rangeKeys || [], u, ANY); + if (!cands.length) return; + sortEnemyTargets(battle, u, cands, null); + const c = cands[0]; + battle.fx('airstrike', { x: c.x, y: c.y, id: unit.id, r: AIRSTRIKE_RADIUS }); + for (const e of battle.foesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定 + if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, isSplash: true, tags: ['talent', 'airstrike'] }); + } + }, { owner: unit }); + } }, + { install(battle, unit) { // 铳弹协约 + const atk = num(t1.atk), mult = num(t1.mult, 1); + if (atk) aura(battle, unit, 0.5, () => { + for (const a of battle.allyUnits) { + if (!live(a) || a.kind !== 'op' || !a.skill || a.skill.kind !== 'ammo') continue; + battle.addBuff(a, { key: 'angel2:covenant', mods: { atkPct: atk * (hasBond(a, 'lateranoShip') ? mult : 1) }, duration: 0.75, refresh: 'replace' }); + } + }); + } }, + { install(battle, unit) { // elite module: HP > 80 % ⇒ SP +0.25/s + const thr = bv(tb, 'hp_ratio', 0), spr = bv(tb, 'sp_recovery_per_sec', 0); + if (spr > 0 && tb['angel2_tr[e].hp_ratio'] != null) aura(battle, unit, 0.25, () => { + if (unit.hpRatio > num(tb['angel2_tr[e].hp_ratio'], thr)) battle.addBuff(unit, { key: 'angel2:calm', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' }); + }); + } }, + ], + }; +} + +export default { + chess_char_6_13_a: angel2, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_14-lumen.js b/server/sim/content/kits/ops/chess_char_6_14-lumen.js new file mode 100644 index 00000000..8d510dde --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_14-lumen.js @@ -0,0 +1,126 @@ +// server/sim/content/kits/ops/chess_char_6_14-lumen.js — 流明 (char_4042_lumen) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { + num, bv, tbb, live, onDefaultSkill, instantKind, ABNORMAL, hasAbnormal, cleanseAbnormal, +} from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 流明 chess_char_6_14 (疗养师) — S3 灯火不灭; 凡人之愿; 应急处理; module 纯铜单筒望远镜 + +function lumen(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const isDef = onDefaultSkill(chess); + const hs = num(bb.heal_scale, 1); + const abnormalAllies = (battle, unit) => battle.alliesInGrid(unit).filter((a) => hasAbnormal(a)).sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq); + const cleanse = (battle, t) => { cleanseAbnormal(battle, t); }; + const skills = { + // S1 沐雨: the next heal also gives its target and the allies around it (8 tiles) aura.heal_scale × ATK healing + // per second for aura.projectile_life_time s + skchr_lumen_1: { + kind: 'instant', + heal: true, + attack: { healScale: 1 }, + onHit({ battle, unit, target }) { + if (!target) return; + const sc = bv(bb, 'heal_scale'), life = bv(bb, 'projectile_life_time', 4), iv = Math.max(0.2, bv(bb, 'interval', 1)); + if (!(sc > 0) || !(life > 0)) return; + battle.fx('healField', { x: target.x, y: target.y, id: unit.id }); + for (const a of battle.alliesInRadius(target.x, target.y, 1.5, target.ownerId)) { + if (a.kind === 'device' || a.s.flags.noHeal || a.profile?.noHeal) continue; // a heal: never on 禁疗 / 孤立 + battle.addBuff(a, { + key: `lumen:rain:${unit.id}`, duration: life, interval: iv, refresh: 'replace', visible: true, source: unit, + onTick: ({ unit: x }) => { if (x.hp < x.s.maxHp) battle.heal(unit, x, unit.s.atk * sc, { hot: true }); }, + }); + } + }, + }, + // S2 沛霖 (2 charges): heal up to max_target allies of her range for heal_scale × ATK; cast with full charges ⇒ + // also cleanse their 异常状态 (蓄力额外效果) + skchr_lumen_2: { + kind: instantKind(def), + heal: true, + onStart({ battle, unit, skill }) { + const full = skill.maxCharges > 1 && skill.charges >= skill.maxCharges - 1; // (this cast already spent one) + const inj = battle.injuredAlliesInKeys(unit.rangeKeys, unit); + const abn = full ? abnormalAllies(battle, unit).filter((a) => !inj.includes(a)) : []; + const tgts = inj.concat(abn).slice(0, Math.max(1, Math.floor(num(bb.max_target, 2)))); + for (const t of tgts) { + battle.heal(unit, t, unit.s.atk * num(bb.heal_scale, 1), { skillHeal: true }); + if (full) cleanse(battle, t); + } + battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id, n: tgts.length, charged: full }); + }, + }, + }; + return { + skills, + skill: { + kind: 'ammo', + heal: true, + ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 4))), + mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, + attack: { healScale: 1 }, + onHit({ battle, unit, target }) { if (unit.mem.lumenAbn && target) cleanse(battle, target); }, + onAttack(ctx) { // only heals on abnormal targets consume a bullet (engine: ctx.noAmmo — no bullet, no ammoUsed) + if (!ctx.unit.mem.lumenAbn) ctx.noAmmo = true; + ctx.unit.mem.lumenAbn = false; + }, + }, + talents: [ + { install(battle, unit) { // S3 targeting / bullet bookkeeping + if (!isDef) return; + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.skill?.active) return; + const abn = abnormalAllies(battle, unit); + unit.mem.lumenAbn = abn.length > 0; + if (abn.length) ctx.targets = [abn[0]]; + if (ctx.profile && ctx.profile !== unit.profile) ctx.profile.healScale = abn.length ? hs : 1; + }, { owner: unit }); + battle.on('tick', () => { // abnormal allies at full HP are healed too + const sk = unit.skill; + if (!sk?.active || !unit.canAct || unit.atkCd > 0 || unit.s.flags.disarm) return; + if (battle.injuredAlliesInKeys(unit.rangeKeys, unit).length) return; + const abn = abnormalAllies(battle, unit); + if (!abn.length) return; + battle.forceAttack(unit, [abn[0]]); + unit.atkCd = unit.s.interval; + }, { owner: unit }); + } }, + { install(battle, unit) { // 凡人之愿: healed targets gain 抵抗 + const base = bv(t0, 'status_resistance[limit]', 0), special = num(t0['lumen_t_1[special].status_resistance[limit]'], base); + const res = -num(t0.one_minus_status_resistance, -0.5), thr = num(t0.hp_ratio, 0.75); + battle.on('heal', (ctx) => { + const t = ctx.target; + if (ctx.source !== unit || !t || !(res > 0) || !(base > 0)) return; + const after = Math.min(t.s.maxHp, t.hp + ctx.amount); + battle.applyStatus(t, 'resist', { duration: after / t.s.maxHp > thr ? special : base, value: Math.min(1, res), source: unit }); + }, { owner: unit, priority: -10 }); + } }, + { install(battle, unit) { // 应急处理 + const sc = num(t1.heal_scale), cd = num(t1.duration, 12); + let ready = -Infinity; + battle.on('statusApplied', (ctx) => { + const t = ctx.target; + if (!live(unit) || !t || t.side !== 'ally' || !ABNORMAL.has(ctx.status) || battle.time < ready || !(sc > 0)) return; + if (!unit.rangeKeySet?.has(t.tileR * COLS + t.tileC)) return; + ready = battle.time + cd; + battle.heal(unit, t, unit.s.atk * sc); + battle.fx('emergency', { x: t.x, y: t.y, id: t.id, src: unit.id }); + }, { owner: unit }); + } }, + { install(battle, unit) { // elite module: taunt −1 and 抵抗 + const taunt = num(tb.taunt_level), res = -num(tb.one_minus_status_resistance); + if (taunt) battle.addBuff(unit, { key: 'lumen:scope', mods: { taunt }, persist: true, allowDead: true }); + // a permanent 抵抗 (survives death/redeploy): any buff with status 'resist' counts (engine resistOf — the + // strongest one applies, never compounding with 凡人之愿 / 灵知) + if (res > 0) battle.addBuff(unit, { key: 'lumen:scopeResist', status: 'resist', visible: true, persist: true, allowDead: true, data: { value: Math.min(1, res) } }); + } }, + ], + }; +} + +export default { + chess_char_6_14_a: lumen, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_15-qiubai.js b/server/sim/content/kits/ops/chess_char_6_15-qiubai.js new file mode 100644 index 00000000..43fbda6c --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_15-qiubai.js @@ -0,0 +1,103 @@ +// server/sim/content/kits/ops/chess_char_6_15-qiubai.js — 仇白 (char_4082_qiubai) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { num, tbb, parseN, enemiesIn, skillGridOf, aura } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 仇白 chess_char_6_15 (领主) — S3 问雪; 入隙; 落英; module 雪浸过的斗笠 + +function qiubai(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const extraTargets = parseN(def?.skill?.description, /额外攻击(\d+)个目标/, 0); + const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; + const gapScale = t0.atk_scale_t != null ? num(t0.atk_scale_t) : num(t0.atk_scale), bothMul = t0.atk_scale_t != null ? num(t0.value, 1) : 1; + const modArts = num(tb.atk_scale_m); + // module 欲雪时: 落英 "首次命中敌人时提升至100%且束缚时间提升至3秒" (duration_advanced); trait "攻击范围内存在2名及以上敌人时 + // 攻击速度+12" (talent-0 part cnt / attack_speed) + const firstBind = num(t1.duration_advanced), hitOnce = new WeakSet(); + const crowdCnt = num(t0.cnt), crowdAs = num(t0.attack_speed); + const skillGridQ = skillGridOf(def); + const groundIn = (battle, unit) => enemiesIn(battle, unit).filter((e) => !e.isFlying); + const skills = { + // S1 留羽 (attack SP): the next attack binds its target `duration` s; when that bind ends the target and the enemies + // near it take aoe_scale × ATK arts + skchr_qiubai_1: { + kind: 'instant', + attack: { + onHit({ battle, unit, target }) { + if (!target || !target.alive) return; + const dur = num(bb.duration, 2); + battle.applyStatus(target, 'bind', { duration: dur, source: unit }); + const seq = unit.deploySeq; + battle.after(dur, () => { + if (!unit.alive || unit.deploySeq !== seq) return; + const x = target.x, y = target.y; // (a fallen target keeps its last position) + battle.fx('aoe', { x, y, id: unit.id }); + for (const e of battle.foesInRadius(x, y, 1.2, true)) { // splash around the target: 中点判定 + if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.aoe_scale, 1), type: 'arts', isSkill: true, isSplash: e !== target, tags: ['skill'] }); + } + }, { owner: unit }); + }, + }, + }, + // S2 承影 (attack SP, 5 s): sword_begin_atk_scale × ATK arts on the ground enemies of the front range at the start, + // skill range + ATK +atk, ground enemies in range 停顿, sword_end_atk_scale × ATK physical on them at the end + skchr_qiubai_2: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + ...(skillGridQ ? { targeting: { rangeGrid: skillGridQ } } : {}), + onStart({ battle, unit }) { + battle.fx('sword', { x: unit.x, y: unit.y, id: unit.id }); + for (const e of groundIn(battle, unit)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.sword_begin_atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); + }, + onTick({ battle, unit }) { for (const e of groundIn(battle, unit)) battle.applyStatus(e, 'sluggish', { duration: 0.25, source: unit }); }, + onEnd({ battle, unit, reason }) { + if (reason === 'death' || !unit.alive) return; + for (const e of groundIn(battle, unit)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.sword_end_atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] }); + }, + }, + }; + return { + skills, + talents: crowdCnt > 0 && crowdAs ? [{ install(battle, unit) { + aura(battle, unit, 0.25, () => { + if (enemiesIn(battle, unit).length >= crowdCnt) battle.addBuff(unit, { key: 'qiubai:crowd', mods: { aspd: crowdAs }, duration: 0.4, refresh: 'replace' }); + }); + } }] : [], + trait: { + afterHit(battle, unit, target) { + if (!target || !target.alive) return; + if (modArts > 0) battle.dealDamage(unit, target, { amount: unit.s.atk * modArts, type: 'arts', tags: ['module'] }); + const slug = !!target.findBuff('sluggish'), bind = !!(target.findBuff('bind') || target.s.flags.bind); + if ((slug || bind) && gapScale > 0 && target.alive) { // 入隙 + battle.dealDamage(unit, target, { amount: unit.s.atk * gapScale * (slug && bind ? bothMul : 1), type: 'arts', tags: ['talent'] }); + } + if (!target.alive) return; + if (firstBind > 0 && !hitOnce.has(target)) { // 落英 (module): first hit on an enemy + hitOnce.add(target); + battle.applyStatus(target, 'bind', { duration: firstBind, source: unit }); + return; + } + if (num(t1.prob) > 0 && battle.rng() < num(t1.prob)) battle.applyStatus(target, 'bind', { duration: num(t1.duration), source: unit }); // 落英 + }, + }, + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + targeting: { maxTargets: 1 + extraTargets, ...(skillGrid ? { rangeGrid: skillGrid } : {}) }, + attack: { dmgType: 'arts', dmgMul: () => 1 }, + onStart({ unit }) { unit.mem.qbStack = 0; }, + onAttack({ battle, unit }) { + const max = Math.floor(num(bb.max_stack_cnt)); + if (unit.mem.qbStack >= max) return; + unit.mem.qbStack++; + battle.addBuff(unit, { key: 'qiubai:snow', mods: { aspd: num(bb.attack_speed) * unit.mem.qbStack } }); + }, + onEnd({ battle, unit }) { battle.removeBuff(unit, 'qiubai:snow'); unit.mem.qbStack = 0; }, + }, + }; +} + +export default { + chess_char_6_15_a: qiubai, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_16-halo2.js b/server/sim/content/kits/ops/chess_char_6_16-halo2.js new file mode 100644 index 00000000..7e321642 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_16-halo2.js @@ -0,0 +1,146 @@ +// server/sim/content/kits/ops/chess_char_6_16-halo2.js — 溯光星源 (char_1047_halo2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { canTargetEnemy } from '../../../targeting.js'; +import { bodyDist, bodyInKeys } from '../../../body.js'; +import { + num, bv, tbb, moduleBb, live, ANY, enemiesIn, onDefaultSkill, selectedSkill, batOf, pullToward, aura, +} from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 溯光星源 chess_char_6_16 (凝滞师) — S3 并流连锁; 数据建模; 能源解析 + +function halo2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess); + const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); + const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; + const n = Math.max(1, Math.floor(num(bb['attack@max_target'], 1))), share = num(bb['attack@atk_share']); + const slug = (unit) => unit.profile?.onHitStatus?.key === 'sluggish' ? num(unit.profile.onHitStatus.duration) : 0; + const skills = { + // S1 星图闪烁: ATK +atk; every attack then bounces attack@chain.max_target times between enemies (back and forth + // allowed, never twice in a row on one; attack@projectile_range tiles), each bounce a full hit with her 停顿 + skchr_halo2_1: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + attack: { + onHit({ battle, unit, target }) { + if (!target) return; + const jumps = Math.max(0, Math.floor(num(bb['attack@chain.max_target'], 3))), R = num(bb['attack@projectile_range'], 1.7); + let prev = target, px = target.x, py = target.y; + for (let i = 0; i < jumps; i++) { + let best = null, bd = Infinity; + for (const e of battle.foesInRadius(px, py, R)) { + if (e === prev || !canTargetEnemy(unit, e, ANY)) continue; + const d = bodyDist(e, px, py); + if (d < bd - 1e-9) { bd = d; best = e; } + } + if (!best) break; + battle.dealDamage(unit, best, { amount: unit.s.atk, type: 'arts', isAttack: true, isSkill: true, tags: ['chain'] }); + const sd = slug(unit); + if (sd > 0 && best.alive) battle.applyStatus(best, 'sluggish', { duration: sd, source: unit }); + prev = best; px = best.x; py = best.y; + } + }, + }, + }, + // S2 星束引力 (attack SP): the next attack targets the enemy of her range farthest from its goal (install), atk_scale + // × ATK arts with a `sluggish` s 停顿, and links up to max_target enemies within ability_range_radius of it: pulled + // toward it (force) and hit by atk_scale_link × ATK arts + skchr_halo2_2: { + kind: 'instant', + attack: { + atkScale: num(bb.atk_scale, 1), + onHitStatus: { key: 'sluggish', duration: num(bb.sluggish, 3) }, + onHit({ battle, unit, target }) { + if (!target) return; + const R = num(bb.ability_range_radius, 2), k = Math.max(0, Math.floor(num(bb.max_target, 2))); + const near = battle.foesInRadius(target.x, target.y, R).filter((e) => e !== target && canTargetEnemy(unit, e, ANY)) + .sort((a, b) => bodyDist(a, target.x, target.y) - bodyDist(b, target.x, target.y) || a.spawnSeq - b.spawnSeq) + .slice(0, k); + for (const e of near) { + battle.fx('link', { x: e.x, y: e.y, id: unit.id, ids: [target.id, e.id] }); + pullToward(battle, { x: target.x, y: target.y }, e, num(bb.force), 0.3); + if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale_link, num(bb.atk_scale, 1)), type: 'arts', isSkill: true, tags: ['skill', 'link'] }); + } + }, + }, + }, + }; + return { + skills, + install(battle, unit) { + if (sid !== 'skchr_halo2_2') return; + battle.on('beforeAttack', (ctx) => { // "选择攻击范围内距离目标点最远的1个敌人为目标" + if (ctx.attacker !== unit || !unit.skill?.active || !unit.skill.pending) return; + const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile); + if (!c.length) return; + let best = c[0]; + for (const e of c) if (battle.remainingDistance(e) > battle.remainingDistance(best) + 1e-9) best = e; + ctx.targets = [best]; + }, { owner: unit }); + }, + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) }, + targeting: { maxTargets: n, ...(skillGrid ? { rangeGrid: skillGrid } : {}) }, + onStart({ unit }) { unit.mem.haloLocks = []; }, + onEnd({ unit }) { unit.mem.haloLocks = []; }, + }, + talents: [ + { install(battle, unit) { // S3: persistent locks + damage transfer between linked targets + if (!isDef) return; + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.skill?.active) return; + const valid = (e) => e && e.alive && canTargetEnemy(unit, e, ANY) && bodyInKeys(e, unit.rangeKeySet); + const keep = (unit.mem.haloLocks || []).filter(valid); + for (const e of ctx.targets) if (keep.length < n && !keep.includes(e)) keep.push(e); + ctx.targets = keep.slice(0, n); + const fresh = ctx.targets.some((e) => !(unit.mem.haloLocks || []).includes(e)); + unit.mem.haloLocks = ctx.targets.slice(); + if (fresh && ctx.targets.length > 1) battle.fx('link', { x: ctx.targets[0].x, y: ctx.targets[0].y, id: unit.id, ids: ctx.targets.map((e) => e.id) }); + }, { owner: unit }); + battle.on('hit', (ctx) => { + const locks = unit.mem.haloLocks; + if (!(share > 0) || !unit.skill?.active || !locks || locks.length < 2 || ctx.dmg.type !== 'arts' || ctx.dmg.tags.includes('link')) return; + if (!locks.includes(ctx.target) || unit.mem.haloLinking) return; + // re-entrancy guard: a transferred hit (or anything it causes, e.g. enemy damage sharing) never re-links + unit.mem.haloLinking = true; + try { + for (const o of locks) if (o !== ctx.target && o.alive) { + battle.dealDamage(ctx.source ?? unit, o, { amount: ctx.dmg.amount * share, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'link'] }); + } + } finally { unit.mem.haloLinking = false; } + }, { owner: unit }); + } }, + { install(battle, unit) { // 数据建模 (+ module ATK at max stacks) + const as = num(t0.attack_speed), max = Math.max(1, Math.floor(num(t0.max_stack_cnt, 18))), fullAtk = num(mod.atk); + battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.haloStack = 0; }, { owner: unit }); + battle.on('statusApplied', (ctx) => { + if (ctx.source !== unit || ctx.status !== 'sluggish' || !live(unit) || (unit.mem.haloStack || 0) >= max) return; + unit.mem.haloStack = (unit.mem.haloStack || 0) + 1; + const k = unit.mem.haloStack; + battle.addBuff(unit, { key: 'halo2:model', mods: { aspd: as * k, atkPct: k >= max ? fullAtk : 0 } }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 能源解析 + const base = bv(t1, 'damage_scale', 1), maxS = bv(t1, 'damage_scale_max', base), iv = bv(t1, 'interval', 7); + const stay = new WeakMap(); + aura(battle, unit, 0.25, () => { + const inR = enemiesIn(battle, unit); + for (const e of battle.enemies) if (!inR.includes(e)) stay.delete(e); + for (const e of inR) { + const t = (stay.get(e) ?? 0) + 0.25; + stay.set(e, t); + const v = t > iv ? maxS : base; + // 脆弱 (ba.fragile, 同名效果取最高): the catalogue status, refreshed while the enemy stays in range + if (v > 1) battle.applyStatus(e, 'fragile', { duration: 0.4, value: v - 1, source: unit }); + } + }); + } }, + ], + }; +} + +export default { + chess_char_6_16_a: halo2, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_17-nearl2.js b/server/sim/content/kits/ops/chess_char_6_17-nearl2.js new file mode 100644 index 00000000..7736654c --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_17-nearl2.js @@ -0,0 +1,149 @@ +// server/sim/content/kits/ops/chess_char_6_17-nearl2.js — 耀骑士临光 (char_1014_nearl2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { absoluteRangeKeys } from '../../../targeting.js'; +import { bodyInKeys } from '../../../body.js'; +import { + num, tbb, live, hasBond, onDefaultSkill, selectedSkill, N4, bstate, freeTiles, bestTile, +} from '../shared/tier6.js'; + +/** Deploy-order tracker: last op deployed per owner (read before the update by priority-0 deploy handlers). */ +function ensureDeployTracker(battle) { + const S = bstate(battle); + if (S.lastOp) return S.lastOp; + S.lastOp = new Map(); + battle.on('deploy', ({ unit }) => { if (unit.kind === 'op') S.lastOp.set(unit.ownerId, unit); }, { priority: -100 }); + return S.lastOp; +} + +// ------------------------------------------------------------------------------------------------------------------ +// 耀骑士临光 chess_char_6_17 (无畏者) — S3 耀阳颔首; 不畏苦暗; 破晓; module 耀阳锋刃 + +function nearl2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const t0Grid = def?.talents?.[0]?.rangeGrid?.length ? def.talents[0].rangeGrid : N4; + const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; + const tokId = (chess?.tokens || []).find((t) => /nearl2_sword/.test(String(t))) || 'token_10019_nearl2_sword'; + const sunBurst = (battle, unit, r, c, scale, stun) => { + const keys = new Set(absoluteRangeKeys(N4, r, c, 1, 0)); + battle.fx('sunBurst', { x: c, y: r, id: unit.id }); + for (const e of battle.enemies) { + if (!e.alive || e.hidden || !bodyInKeys(e, keys)) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'true', isSkill: true, tags: ['skill'] }); + if (stun > 0 && e.alive) battle.applyStatus(e, 'stun', { duration: stun, source: unit }); + } + }; + const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); + const lastOps = { map: null }; + const skills = { + // S1 灿焰长刃 (toggle, 持续时间无限): skill range, ATK / ASPD + + skchr_nearl2_1: { + kind: 'toggle', + mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, + ...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}), + }, + // S2 逐夜烁光 (passive, ON_DEPLOY): for the skill duration after each deployment ATK +atk and `times` 护盾 layers + // (whole hits negated); then she withdraws and this redeploy time is ×respawn_time (×1 when the operator deployed + // right before her is 【卡西米尔】) + skchr_nearl2_2: { + kind: 'passive', + onStart({ battle, unit, skill }) { + const dur = num(skill.duration, num(def?.skill?.duration, 22)); + if (!(dur > 0)) return; + const prev = lastOps.map?.get(unit.ownerId); + const combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip'); + battle.addBuff(unit, { key: 'nearl2:night', mods: { atkPct: num(bb.atk) }, duration: dur, visible: true }); + const hits = Math.floor(num(bb.times)); + if (hits > 0) battle.addBuff(unit, { key: 'nearl2:shield', shieldHits: hits, duration: dur, visible: true }); + const seq = unit.deploySeq; + battle.after(dur, () => { + if (!live(unit) || unit.deploySeq !== seq) return; + battle.retreat(unit, { reason: 'retreat' }); + const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1); + if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul; + battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo }); + }, { owner: unit }); + }, + }, + }; + // module “骑士家族”: "被击倒时不撤退且回复所有生命但生命上限-60%,攻击速度+30(单次部署只触发1次)" and 不畏苦暗 + // "部署时及首次被击倒时…" (the burst again) + const standVal = num(tb.value), standAs = num(tb.attack_speed), standHp = num(tb.hp_ratio, 1); + return { + skills, + install(battle, unit) { + if (sid === 'skchr_nearl2_2') lastOps.map = ensureDeployTracker(battle); + if (standVal > 0) { + battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.nearlStood = false; }, { owner: unit }); + battle.on('fatal', (ctx) => { + if (ctx.unit !== unit || ctx.prevented || unit.mem.nearlStood) return; + ctx.prevented = true; + unit.mem.nearlStood = true; + battle.addBuff(unit, { key: 'nearl2:stand', mods: { hpMul: Math.max(0.05, 1 - standVal), aspd: standAs }, visible: true }); + unit.hp = Math.max(1, unit.s.maxHp * standHp); + battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id }); + battle.emit('nearl2:knockdown', { unit }); + }, { owner: unit, priority: -60 }); + } + }, + trait: num(tb.atk_scale, 1) > 1 ? { dmgMul: (b, u, t) => (t.blockedBy ? num(tb.atk_scale, 1) : 1) } : null, + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, + ...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}), + onStart({ battle, unit, skill }) { + const tile = bestTile(battle, freeTiles(battle, unit, N4)); + const sword = tile ? battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: skill.timeLeft }) : null; + unit.mem.sun = sword; + if (sword) battle.fx('summon', { x: sword.x, y: sword.y, id: sword.id, src: unit.id }); + // the token kit (content/tokens.js) performs the appear burst; without it (or without a free tile) do it here + if (!sword || !sword.kit?.fromTokens) { + sunBurst(battle, unit, sword ? sword.tileR : unit.tileR, sword ? sword.tileC : unit.tileC, num(bb.value), num(bb.value2)); + } + }, + onEnd({ battle, unit }) { + const s = unit.mem.sun; + unit.mem.sun = null; + if (s && s.alive) battle.retreat(s, { reason: 'expired', permanent: true }); + }, + }, + talents: [ + { install(battle, unit) { // S3: attacks on units blocked by her or 耀阳 deal true damage + if (!isDef) return; + battle.on('hit', (ctx) => { + if (ctx.source !== unit || !unit.skill?.active || !ctx.dmg.isAttack) return; + const b = ctx.target.blockedBy; + if (b && (b === unit || b === unit.mem.sun)) ctx.dmg.type = 'true'; + }, { owner: unit }); + } }, + { install(battle, unit) { // 不畏苦暗 — the 4 tiles around her, air units too [ASSUMED: no 对空 note on PRTS, like “耀阳”] + const last = ensureDeployTracker(battle); + const sc = num(t0.atk_scale), stun = num(t0.stun); + const dawn = (times) => { + const foes = battle.unitsInGrid(unit, t0Grid, { side: 'enemy' }); + if (foes.length) battle.fx('sunBurst', { x: unit.x, y: unit.y, id: unit.id }); + for (const e of foes) { + for (let i = 0; i < times && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'true', tags: ['talent'] }); + if (stun > 0 && e.alive) battle.applyStatus(e, 'stun', { duration: stun, source: unit }); + } + }; + const kazTimes = () => { const prev = last.get(unit.ownerId); return prev && prev !== unit && hasBond(prev, 'kazimierzShip') ? 2 : 1; }; + battle.on('deploy', ({ unit: u }) => { + if (u !== unit || !(sc > 0)) return; + unit.mem.nearlDawnTimes = kazTimes(); + dawn(unit.mem.nearlDawnTimes); + }, { owner: unit }); + // module “骑士家族”: "…及首次被击倒时" — the burst again when the trait keeps her standing + battle.on('nearl2:knockdown', ({ unit: u }) => { if (u === unit && sc > 0) dawn(unit.mem.nearlDawnTimes ?? 1); }, { owner: unit }); + } }, + { install(battle, unit) { // 破晓 + const p = num(t1.def_penetrate); + if (p) battle.addBuff(unit, { key: 'nearl2:dawn', mods: { defIgnorePct: p }, persist: true, allowDead: true }); + } }, + ], + }; +} + +export default { + chess_char_6_17_a: nearl2, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_18-whitw2.js b/server/sim/content/kits/ops/chess_char_6_18-whitw2.js new file mode 100644 index 00000000..96a015f6 --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_18-whitw2.js @@ -0,0 +1,306 @@ +// server/sim/content/kits/ops/chess_char_6_18-whitw2.js — 荒芜拉普兰德 (char_1038_whitw2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js'; +import { rotateOffset } from '../../../dir.js'; +import { bodyDist } from '../../../body.js'; +import { num, tbb, hasBond, ANY, selectedSkill, skillGridOf, WHOLE_FIELD, bstate, aura } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸 + +/** + * S3 终幕·浩劫's drone flight — PRTS 荒芜拉普兰德 S3 备注 "技能流程": ① for `attack@times` (1.3) s after the cast, or after + * a drone is added ("补充浮游单元"), the drones spread evenly outward from her, one along her facing ("散开的方向始终包括 + * 自身的朝向"): 初速度 0.1, 加速度 1.9, 最大速度 2.0 — [ASSUMED] after an addition (头狼 stage 3 mid-skill) only the added + * drone spreads, along her facing, while the others carry on; ② then each picks the target nearest to itself, ties + * nearest to her ("距离自身最近>距离本体最近"), anywhere on the field, and flies at it: 初速度 2.0, 加速度 1.0, 最大速度 + * 4.0, restarting at 2.0 whenever its target leaves or turns unselectable on the way; ③ once there it stays on the target + * and attacks it like a normal drone ("此状态下的攻击行为同正常浮游单元"); when that target leaves / turns unselectable it + * reappears at a random point of the 1.5-side square around it and picks again (②). With no selectable target it circles + * (radius 0.9, 1.0 tiles/s, counter-clockwise) with its heading as the tangent, the circle on its left. Speeds in tiles/s. + */ +const WHITW2_SPREAD = Object.freeze({ v0: 0.1, acc: 1.9, max: 2 }); +const WHITW2_CHASE = Object.freeze({ v0: 2, acc: 1, max: 4 }); +const WHITW2_REAPPEAR_SIDE = 1.5; +const WHITW2_ORBIT = Object.freeze({ r: 0.9, v: 1 }); + +function whitw2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1); + const sid = selectedSkill(chess, def); + const spreadTime = Math.max(0, num(bb['attack@times'], 1.3)), R = num(bb['attack@range_radius'], 0.9); + const dmgScale = num(bb['attack@magic_atk_scale'], 1), fear = num(bb['attack@fear']), slow = num(bb['attack@move_speed']); + // "非移动敌人": blocked, or not walking (stunned, bound, asleep, waiting, speed 0 …) + const still = (e) => !!e.blockedBy || e.moving === false || !(e.s.moveSpeed > 0) || !!(e.s.flags.stun || e.s.flags.freeze || e.s.flags.bind || e.s.flags.sleep || e.s.flags.noMove); + const skillGridW = skillGridOf(def); + + // ---- S3 drones (virtual: positions in unit.mem.drones, fx events for the client) -------------------------------- + const droneCount = (unit) => 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0); + // ① `k` drones leave her evenly spread, the first along her facing (row 0 is the bottom row: angles in the (col, row) + // plane, counter-clockwise) + const releaseDrones = (unit, k) => { + const a0 = Math.atan2(unit.fwd[0], unit.fwd[1]); + for (let i = 0; i < k; i++) { + const a = a0 + (2 * Math.PI * i) / k; + unit.mem.drones.push({ x: unit.x, y: unit.y, hx: Math.cos(a), hy: Math.sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null }); + } + }; + // speed v → v + acc·dt (capped); the distance covered at the mean of the two (exact under constant acceleration) + const accelerate = (d, lim, dt) => { const v1 = Math.min(lim.max, d.v + lim.acc * dt), s = ((d.v + v1) / 2) * dt; d.v = v1; return s; }; + // ② the selectable enemy nearest to the drone, ties broken by the one nearest to her. [ASSUMED] distances are measured + // to every enemy's position (a huge enemy's centre, its 判定中心) — the owner's decision of 2026-10-04: the centre, so + // leader rounds stay close to 0.1.1; the sim's general convention for operator-side distance picks, the hit rectangle + // (body.js bodyDist: targeting.js sortEnemyTargets 'nearest', tier3 enemiesAround, 异客 / 溯光星源's chains), was + // considered — it made a huge leader (胄, 管) the nearest enemy of every drone around it (DESIGN §22.9) + const pickTarget = (battle, unit, d, ok) => { + let best = null, bd = Infinity, bh = Infinity; + for (const e of battle.enemies) { + if (!ok(e)) continue; + const de = Math.hypot(e.x - d.x, e.y - d.y), dh = Math.hypot(e.x - unit.x, e.y - unit.y); + if (de < bd - 1e-9 || (de <= bd + 1e-9 && dh < bh - 1e-9)) { best = e; bd = de; bh = dh; } + } + return best; + }; + // nothing selectable: circle counter-clockwise on its left, its heading the tangent + const circle = (d, dt) => { + const { r, v } = WHITW2_ORBIT; + if (!d.orbit) d.orbit = { cx: d.x - d.hy * r, cy: d.y + d.hx * r }; + const a = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt; + d.x = d.orbit.cx + r * Math.cos(a); d.y = d.orbit.cy + r * Math.sin(a); + d.hx = -Math.sin(a); d.hy = Math.cos(a); + }; + // one tick of one drone: spread → (pick) → chase → on the target, attacking like a normal drone. [ASSUMED] its attack + // clock runs all the time (one attack per interval of hers at most, whatever it chased in between) and its first hit + // lands as it arrives when the clock is ready; [ASSUMED] a drone on a target sits at its position (a huge enemy's + // centre, its 判定中心), and it reaches a huge enemy when it touches the hit rectangle. [ASSUMED] the turn rate (PRTS + // 转向速度 1/6 per frame = attack@projectile_turn_speed × 1/30 s) is not modelled: a chasing drone heads straight at + // its target. [ASSUMED] the fear's source is her (the enemy flees from her, not from the drone). + const flyDrone = (battle, unit, d, dt, ok) => { + d.cd = Math.max(0, d.cd - dt); + if (d.phase === 'spread') { + const s = accelerate(d, WHITW2_SPREAD, dt); + d.x += d.hx * s; d.y += d.hy * s; + d.age += dt; + if (d.age + 1e-9 < spreadTime) return; + d.phase = 'seek'; + } + if (d.phase === 'lock' && !ok(d.t)) { + // ③ its target left / is no longer selectable: it reappears at a random point of the square around that spot (the + // draw taken in her facing-RIGHT frame, so a battle turned with her direction plays the same) + const h = WHITW2_REAPPEAR_SIDE / 2; + const [ar, ac] = rotateOffset(battle.rng.range(-h, h), battle.rng.range(-h, h), unit.dir); + d.x = d.t.x + ac; + d.y = d.t.y + ar; + d.t = null; d.phase = 'seek'; + } else if (d.phase === 'chase' && !ok(d.t)) { d.t = null; d.phase = 'seek'; } + if (d.phase === 'seek') { + const t = pickTarget(battle, unit, d, ok); + if (!t) { circle(d, dt); return; } + d.t = t; d.phase = 'chase'; d.v = WHITW2_CHASE.v0; d.orbit = null; + battle.fx('droneLock', { x: t.x, y: t.y, id: t.id, src: unit.id }); // PRTS: the red wolf-eye mark over the target + } + const t = d.t; + if (d.phase === 'chase') { + const s = accelerate(d, WHITW2_CHASE, dt); + if (bodyDist(t, d.x, d.y) > s + 1e-9) { + const dx = t.x - d.x, dy = t.y - d.y, L = Math.hypot(dx, dy); + if (L > 1e-9) { d.hx = dx / L; d.hy = dy / L; } + d.x += d.hx * s; d.y += d.hy * s; + return; + } + d.phase = 'lock'; // reached: "追上时使目标恐惧…并锁定其攻击" + if (fear > 0) battle.applyStatus(t, 'fear', { duration: fear, source: unit }); + } + d.x = t.x; d.y = t.y; + if (d.cd > 1e-9 || !ok(t) || !unit.alive) return; // (the fear's hooks could have knocked her out) + d.cd = unit.s.interval; + const f = unit.profile?.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; + d.ramp = d.rampId === t.id ? Math.min(f.max, d.ramp + f.delta) : f.init; + d.rampId = t.id; + battle.fx('drone', { x: d.x, y: d.y, id: unit.id }); + battle.dealDamage(unit, t, { amount: unit.s.atk * unit.s.atkScaleMul * d.ramp, type: 'arts', tags: ['droneAttack'] }); + }; + + const skills = { + // S1 慵怠者悲鸣: passive 浮游单元+1 (trait: one more hit per attack); toggled on: ATK +atk and the drones lock a random + // non-moving enemy anywhere on the field (re-locking when it moves or falls; install), else her range. The whole-field + // grid only selects those targets — no rangeId, no 攻击范围 in the text — so the card keeps her 3-1 (showOwnRange) + skchr_whitw2_1: { + kind: 'toggle', + mods: { atkPct: num(bb.atk) }, + targeting: { rangeGrid: WHOLE_FIELD, showOwnRange: true }, + onStart({ unit }) { unit.mem.lazyLock = null; }, + }, + // S2 逐猎狂飙: 浮游单元+attack@cnt, skill range, ATK +atk: every drone locks a random enemy of the range until it falls + // (install); each drone hit ramps on its own target (trait init → max) and fears it attack@fear s with attack@prob + skchr_whitw2_2: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + ...(skillGridW ? { targeting: { rangeGrid: skillGridW } } : {}), + attack: { + hits: 1, + dmgMul(battle, unit, target) { + const f = unit.profile?.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; + const m = unit.mem.hunt; + if (!m) return f.init; + const v = m.ramp.has(target.id) ? Math.min(f.max, m.ramp.get(target.id) + f.delta) : f.init; + m.ramp.set(target.id, v); + return v; + }, + onEachHit({ battle, unit, target, kind }) { + if (kind !== 'main' || !target || !target.alive || !(num(bb['attack@fear']) > 0)) return; + if (battle.rng() < num(bb['attack@prob'])) battle.applyStatus(target, 'fear', { duration: num(bb['attack@fear']), source: unit }); + }, + }, + onStart({ unit }) { unit.mem.hunt = { locks: [], ramp: new Map() }; }, + onEnd({ unit }) { unit.mem.hunt = null; }, + }, + }; + return { + skills, + // S1 "被动效果:浮游单元+1": one more drone hit with every normal attack + trait: sid === 'skchr_whitw2_1' ? { hits: 2 } : null, + install(battle, unit) { + if (sid === 'skchr_whitw2_1') { + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.skill?.active) return; + const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY); + let L = unit.mem.lazyLock; + if (!ok(L) || !still(L)) { + const c = battle.enemies.filter((e) => ok(e) && still(e)); + L = unit.mem.lazyLock = c.length ? battle.rng.pick(c) : null; + } + if (L) { ctx.targets = [L]; return; } + // nothing stands still: her own (initial) range + const own = battle.enemiesInKeys(unit.baseRangeKeys || [], unit, ctx.profile); + sortEnemyTargets(battle, unit, own, ctx.profile?.priority ?? null); + ctx.targets = own.slice(0, 1); + }, { owner: unit }); + } + if (sid === 'skchr_whitw2_2') { + battle.on('beforeAttack', (ctx) => { + const m = unit.mem.hunt; + if (ctx.attacker !== unit || !unit.skill?.active || !m) return; + // the skill range, plus the enemies she blocks (always her targets, Battle.blockedTargets — DESIGN §20.3) + const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile); + for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e); + if (!c.length) { ctx.targets = []; return; } + const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0); + m.locks = m.locks.filter((e) => e.alive && c.includes(e)).slice(0, n); + while (m.locks.length < n) { + const free = c.filter((e) => !m.locks.includes(e)); + m.locks.push(battle.rng.pick(free.length ? free : c)); + } + ctx.targets = m.locks.slice(); + }, { owner: unit }); + } + }, + // S3 终幕·浩劫: ATK +atk; 1 + attack@cnt drones (+1 from 头狼 stage 3) fly the PRTS 技能流程 (WHITW2_* above). Every + // drone is out, so she makes no normal attack of her own (`noAttack`); a drone on its target attacks it every attack + // interval of hers (her live ASPD; [ASSUMED] the first hit as it arrives) for ATK × its OWN funnel ramp — the trait's + // init, +delta per hit on the same target, the cap (头狼 stage 1 raises it), back to init on a new target (PRTS 分支特性 + // 信息 驭械术师 "浮游单元攻击不同目标…时,上述的伤害立刻恢复至初始值"). That damage is arts and neither a normal attack + // nor skill damage (PRTS S3 备注 "该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害", which for this skill + // overrides the branch note "通过技能释放的浮游单元造成技能直接伤害": no 'attack' hook, isAttack / isSkill false — the + // on-attack items skip it; it is still 普通伤害, so the 叙拉古 6 proc rolls on it, bonds/core.js siracusaRolls), and + // 缴械 does not stop it (PRTS 驭械术师 "…不受缴械类效果 + // 制约"); [ASSUMED] nor do her stun, freeze or silence (PRTS names only 缴械) — the skill ticks on and so do the drones. + // Around every drone (attack@range_radius): move speed attack@move_speed and, once per second, attack@magic_atk_scale + // × ATK arts (不叠加: one hit per enemy whatever the number of drones); [ASSUMED] that area hit keeps `isSkill` (a skill + // DoT — the 备注 speaks of 直接伤害) and is 持续伤害 (tag dot: PRTS 备注 "持续法术伤害" — no 叙拉古 6 roll, 锡人's + // 凋敝魂灵 raises it). A knocked-out / withdrawn wolf (onEnd cleared the drones mid-tick, e.g. from a + // kill hook) deals nothing more in that tick. + skill: { + kind: 'duration', + mods: { atkPct: num(bb.atk) }, + attack: { noAttack: true }, + onStart({ battle, unit }) { + unit.mem.drones = []; + unit.mem.droneAcc = 0; + releaseDrones(unit, droneCount(unit)); + battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n: unit.mem.drones.length }); + }, + onTick({ battle, unit, dt }) { + const D = unit.mem.drones; + if (!D) return; + const gone = () => !unit.alive || unit.mem.drones !== D; + // 头狼 stage 3 reached while the skill runs: the extra drone is released (PRTS ① "补充浮游单元"; [ASSUMED] only it spreads) + const add = droneCount(unit) - D.length; + if (add > 0) { releaseDrones(unit, add); battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n: add }); } + const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY); + for (const d of D) { + if (gone()) return; + flyDrone(battle, unit, d, dt, ok); + } + if (gone()) return; + const near = new Set(); + for (const d of D) for (const e of battle.foesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e); + if (slow) for (const e of near) battle.addBuff(e, { key: 'whitw2:slow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow) }, source: unit }); + unit.mem.droneAcc += dt; + if (unit.mem.droneAcc + 1e-9 >= 1) { + unit.mem.droneAcc -= 1; + for (const e of near) { + if (gone()) return; + if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone', 'dot'] }); + } + // (a drone on its target already pulses with each of its attacks) + for (const d of D) if (d.phase !== 'lock') battle.fx('drone', { x: d.x, y: d.y, id: unit.id }); + } + }, + onEnd({ unit }) { unit.mem.drones = null; }, + }, + talents: [ + { install(battle, unit) { // 头狼 + const iv = Math.max(1, num(t0.interval, 20)), cap = num(t0.scale, 1), sil = num(t0['attack@silence_duration']); + const baseFunnel = unit.profile?.funnel ? { ...unit.profile.funnel } : null; + const baseHits = Math.max(1, Math.floor(num(unit.profile?.hits, 1))); + battle.on('deploy', ({ unit: u }) => { + if (u !== unit) return; + unit.mem.wolfStage = 0; + if (unit.profile) { unit.profile.hits = baseHits; if (baseFunnel) unit.profile.funnel = { ...baseFunnel }; } + }, { owner: unit }); + aura(battle, unit, 1, () => { + const st = Math.min(3, Math.floor((battle.time - unit.deployedAt + 1e-6) / iv)); + if (st <= (unit.mem.wolfStage || 0)) return; + unit.mem.wolfStage = st; + // stage 1: 伤害上限提高10% (funnel cap × scale) + if (st >= 1 && baseFunnel && unit.profile) unit.profile.funnel = { ...baseFunnel, max: baseFunnel.max * cap }; + // stage 3: 数量+1 — one more drone hits her target with every normal attack (and S3 releases one more) + if (st >= 3 && unit.profile) unit.profile.hits = baseHits + 1; + battle.fx('talent', { x: unit.x, y: unit.y, id: unit.id, name: 'alpha', stage: st }); + }); + // stage 2: 造成伤害时使目标特殊能力失效2秒 (= silence) + if (sil > 0) battle.on('damaged', (ctx) => { + if (ctx.source !== unit || (unit.mem.wolfStage || 0) < 2 || !ctx.target.alive || ctx.target.side !== 'enemy' || ctx.type === 'element') return; + battle.applyStatus(ctx.target, 'silence', { duration: sil, source: unit }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 叙拉古的荣幸: one team effect per player, the strongest copy's numbers + const S = bstate(battle); + S.siracusa ??= new Map(); + const cur = S.siracusa.get(unit.ownerId); + const sp = num(t1.sp), as = num(t1.attack_speed); + if (cur) { cur.sp = Math.max(cur.sp, sp); cur.as = Math.max(cur.as, as); return; } + const cfg = { sp, as }; + S.siracusa.set(unit.ownerId, cfg); + const sira = (u) => u && u.kind === 'op' && u.ownerId === unit.ownerId && hasBond(u, 'siracusaShip'); + // 初始技力+5: every (re)deployment starts with +5 SP (not on top of the SP a unite helper carries over) + battle.on('deploy', ({ unit: u, initial }) => { + if (!(cfg.sp > 0) || !sira(u) || !u.skill) return; + if (initial && u.carry && Number.isFinite(u.carry.sp)) return; + u.skill.gainSp(cfg.sp, 'talent'); + }); + // elite: 首次触发技能后攻击速度+10 + battle.on('skillStart', ({ unit: u }) => { + if (!cfg.as || !sira(u) || u.mem.siracusaHonor === u.deploySeq) return; + u.mem.siracusaHonor = u.deploySeq; + battle.addBuff(u, { key: 'whitw2:honor', mods: { aspd: cfg.as } }); + }); + } }, + ], + }; +} + +export default { + chess_char_6_18_a: whitw2, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_19-blkkgt.js b/server/sim/content/kits/ops/chess_char_6_19-blkkgt.js new file mode 100644 index 00000000..2f35b23f --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_19-blkkgt.js @@ -0,0 +1,137 @@ +// server/sim/content/kits/ops/chess_char_6_19-blkkgt.js — 锏 (char_4116_blkkgt) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js'; +import { num, tbb, parseN, live, ANY, enemiesIn, instantKind, pullToward } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 锏 chess_char_6_19 (剑豪) — S3 归于宁静; “天生的武者”; 活着的传奇; module “过往的注脚” + +function blkkgt(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const slashes = Math.max(1, parseN(def?.skill?.description, /总计(\d+)次/, 10)); + const iv = Math.max(0.05, num(bb.d_hit_interval, 0.3)), pullIv = Math.max(0.1, num(bb.p_hit_interval, 1)); + const maxT = Math.max(1, Math.floor(num(bb.max_target, 1))); + const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; + // the skill range (x-1): her range while S3 runs — also for the finisher, since onEnd runs before the engine + // removes the skill's range + const skillKeys = (unit) => unit.rangeKeys || []; + // S3 hits and pulls air units too — PRTS 锏 S3 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (her attacks and S1 / + // S2 stay ground-only: "地面敌人"); the air units of the mode are 静态刚体, so the pull leaves them in place (Battle.pull) + const victims = (battle, unit) => { + const c = battle.enemiesInKeys(skillKeys(unit), unit, { ...unit.profile, canHitFly: true, groundOnly: false }); + sortEnemyTargets(battle, unit, c, null); + return c.slice(0, maxT); + }; + const slash = (battle, unit, scale) => { + const v = victims(battle, unit); + if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length }); + for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'slash'] }); + }; + // "持续将敌人中等力度地拖拽至自身中心,之后…较大力地拖拽至自身": the official 力度 − 重量 pulls (PRTS 推与拉: the last one + // aims at her own tile centre, the others at the usual 拉力起点 half a tile ahead; 急停 0.6708 around her) + const pull = (battle, unit, force, last = false) => { + for (const e of enemiesIn(battle, unit, skillKeys(unit))) { + if (last) pullToward(battle, unit, e, force); else battle.pullToFront(e, unit, force); + } + }; + const skills = { + // S1 纯粹的武力 (attack SP): the next attack hits up to max_target ground enemies of the 3×3 around her, each twice + // (her trait) at atk_scale_s1 × ATK + skchr_blkkgt_1: { + kind: 'instant', + attack: { atkScale: num(bb.atk_scale_s1, 1) }, + targeting: { maxTargets: Math.max(1, Math.floor(num(bb.max_target, 5))), ...(skillGrid ? { rangeGrid: skillGrid } : {}) }, + }, + // S2 无声的嘲笑 (attack SP, 2 charges): up to max_target ground enemies of the front skill range take 2 slashes + // (3 when blocked) of dot_scale × ATK; the talent procs at 100 % meanwhile (skill active + bb.prob) + skchr_blkkgt_2: { + kind: instantKind(def), + onStart({ battle, unit }) { + const grid = skillGrid || [[0, 0], [0, 1]]; + const c = battle.unitsInGrid(unit, grid, { side: 'enemy' }).filter((e) => !e.isFlying && canTargetEnemy(unit, e, unit.profile)); + sortEnemyTargets(battle, unit, c, null); + const v = c.slice(0, Math.max(1, Math.floor(num(bb.max_target, 5)))); + const nFree = Math.floor(num(bb['blkkgt_s_2[not_blocked].trig_cnt'], 2)), nBlocked = Math.floor(num(bb['blkkgt_s_2[blocked].trig_cnt'], 3)); + if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length }); + for (const e of v) { + const n = e.blockedBy ? nBlocked : nFree; + for (let i = 0; i < n && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.dot_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'slash'] }); + } + }, + }, + }; + // module 新合同: trait "攻击时无视敌人70点的防御力"; talent 活着的传奇 "攻击力+8%…地面敌人首次进入自身攻击范围时,使其战栗6秒" + const penFlat = num(tb.def_penetrate_fixed), legendAtk = num(t1.atk), legendTremble = num(t1.not_combat); + return { + skills, + install(battle, unit) { + if (penFlat > 0 || legendAtk) battle.addBuff(unit, { key: 'blkkgt:contract', mods: { defIgnoreFlat: penFlat, atkPct: legendAtk }, persist: true, allowDead: true }); + // S3 slashes: 晕眩免疫, 冻结免疫 (PRTS 备注) + battle.on('beforeStatus', (c) => { + if (c.target === unit && unit.mem.dgb && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true; + }, { owner: unit }); + if (legendTremble > 0) { + const seen = new WeakSet(); + battle.on('tick', () => { + if (!live(unit)) return; + for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, ANY)) { + if (e.isFlying || seen.has(e)) continue; + seen.add(e); + battle.applyStatus(e, 'tremble', { duration: legendTremble, source: unit }); + } + }, { owner: unit }); + } + }, + skill: { + kind: 'duration', + duration: slashes * iv, + ...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}), + attack: { noAttack: true }, + // PRTS 备注 "※多段斩击期间,自身获得无敌,晕眩免疫,冻结免疫": invulnerable while the slashes run (removed in onEnd, + // before the finisher); stun / freeze refused by her `beforeStatus` hook (install) while `mem.dgb` is set + onStart({ battle, unit }) { + unit.mem.dgb = { n: 1, acc: 0, pacc: 0 }; + battle.addBuff(unit, { key: 'blkkgt:slashes', duration: slashes * iv + 1, flags: { invulnerable: true }, visible: true, source: unit }); + slash(battle, unit, num(bb.d_atk_scale, 1)); + }, + onTick({ battle, unit, dt }) { + const m = unit.mem.dgb; + if (!m) return; + m.acc += dt; + m.pacc += dt; + while (m.acc + 1e-9 >= iv && m.n < slashes) { m.acc -= iv; m.n++; slash(battle, unit, num(bb.d_atk_scale, 1)); } + if (m.pacc + 1e-9 >= pullIv) { m.pacc -= pullIv; pull(battle, unit, num(bb.p_force)); } + }, + onEnd({ battle, unit, reason }) { + unit.mem.dgb = null; + battle.removeBuff(unit, 'blkkgt:slashes'); + if (reason === 'death' || !unit.alive) return; + battle.fx('finale', { x: unit.x, y: unit.y, id: unit.id }); + unit.mem.dgbFinale = true; // the finisher is part of the skill (talent at 100 %, module +10 %) + try { slash(battle, unit, num(bb.e_atk_scale_end, 1)); } finally { unit.mem.dgbFinale = false; } + pull(battle, unit, num(bb.e_force), true); + }, + }, + talents: [ + { install(battle, unit) { // 活着的传奇 → “天生的武者” → module (skill damage +10 %) + const prob = num(t0.prob), sc = num(t0.atk_scale, 1), tr = num(t0.not_combat), pen = num(t1.def_penetrate), skillMul = num(tb.damage_scale, 1); + battle.on('hit', (ctx) => { + const t = ctx.target, d = ctx.dmg; + if (ctx.source !== unit || !t || t.side !== 'enemy' || d.type === 'element') return; + if (pen > 0 && t.findBuff('tremble')) d.defIgnorePct += pen; + const p = unit.skill?.active || unit.mem.dgbFinale ? num(bb.prob, prob) : prob; + if (p > 0 && (p >= 1 || battle.rng() < p)) { + d.amount *= sc; + if (tr > 0 && t.alive) battle.applyStatus(t, 'tremble', { duration: tr, source: unit }); + } + if (skillMul > 1 && d.isSkill) d.mul *= skillMul; + }, { owner: unit }); + } }, + ], + }; +} + +export default { + chess_char_6_19_a: blkkgt, +}; diff --git a/server/sim/content/kits/ops/chess_char_6_20-agoat2.js b/server/sim/content/kits/ops/chess_char_6_20-agoat2.js new file mode 100644 index 00000000..4201ca0d --- /dev/null +++ b/server/sim/content/kits/ops/chess_char_6_20-agoat2.js @@ -0,0 +1,144 @@ +// server/sim/content/kits/ops/chess_char_6_20-agoat2.js — 纯烬艾雅法拉 (char_1016_agoat2) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { COLS } from '../../../constants.js'; +import { num, bv, tbb, parseN, WHOLE_FIELD, bstate, aura } from '../shared/tier6.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// 纯烬艾雅法拉 chess_char_6_20 (行医) — S3 火山回响; 氤氲; 火山灰疗愈 + +function agoat2(bb, chess, def) { + const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; + const hs = num(bb['attack@heal_scale'], 1); + const shots = Math.max(1, parseN(def?.skill?.description, /(\d+)连发/, 1)); + const elemLoad = (a) => (a.elem ? a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion : 0); + const skills = { + // S1 无声润物 (toggle, heal): ATK +atk, one extra heal target, every ally of her range recovers ep_heal_ratio × ATK + // 元素损伤 per second + skchr_agoat2_1: { + kind: 'toggle', + heal: true, + mods: { atkPct: num(bb.atk) }, + targeting: { maxTargets: 2 }, + onStart({ unit }) { unit.mem.agoatAcc = 0; }, + onTick({ battle, unit, dt }) { + unit.mem.agoatAcc = (unit.mem.agoatAcc || 0) + dt; + if (unit.mem.agoatAcc + 1e-9 < 1) return; + unit.mem.agoatAcc -= 1; + const v = unit.s.atk * bv(bb, 'ep_heal_ratio'); + if (v > 0) for (const a of battle.alliesInGrid(unit)) battle.reduceElement(a, v); + }, + }, + // S2 云霭荫佑 (heal): one heal (her normal amount + element recovery) on every ally of her range, then a barrier + // over that range for `duration` s absorbing agoat2_s_2[shield].atk_scale × ATK of 元素损伤 in all (install) + skchr_agoat2_2: { + kind: 'instant', + heal: true, + onStart({ battle, unit }) { + const er = num(unit.profile?.heal?.elementHealRatio, num(tb.ep_heal_ratio, 0.5)); + const allies = battle.alliesInGrid(unit); + for (const a of allies) { + if (er > 0) battle.reduceElement(a, unit.s.atk * er); + if (a.hp < a.s.maxHp) battle.heal(unit, a, unit.s.atk, { skillHeal: true }); + } + const S = bstate(battle); + S.agoatVeils ??= []; + S.agoatVeils.push({ keys: new Set(unit.rangeKeys || []), pool: unit.s.atk * bv(bb, 'atk_scale', 5), until: battle.time + num(bb.duration, 12), src: unit }); + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, duration: num(bb.duration, 12) }); + if (!S.agoatVeilHook) { + S.agoatVeilHook = true; + battle.on('elementHit', (ctx) => { // the barrier absorbs 元素损伤 of the allies inside it + const t = ctx.target, d = ctx.dmg; + if (!t || t.side !== 'ally' || !d || d.type !== 'element' || d.cancel) return; + S.agoatVeils = S.agoatVeils.filter((v) => v.pool > 1e-9 && battle.time < v.until); + const k = t.tileR * COLS + t.tileC; + for (const v of S.agoatVeils) { + if (!v.keys.has(k)) continue; + const eff = d.amount * d.mul; + if (!(eff > 0)) return; + const take = Math.min(v.pool, eff); + v.pool -= take; + if (take >= eff - 1e-9) { d.cancel = true; return; } + d.mul *= (eff - take) / eff; + } + }, { priority: -50 }); + } + }, + }, + }; + return { + skills, + install(battle, unit) { + // module 想要留下的生命: "攻击范围内存在受到元素损伤的友方单位时,攻击速度+8" + const as = num(tb.attack_speed); + if (as) aura(battle, unit, 0.25, () => { + if (battle.alliesInGrid(unit).some((a) => elemLoad(a) > 0)) battle.addBuff(unit, { key: 'agoat2:linger', mods: { aspd: as }, duration: 0.4, refresh: 'replace' }); + }); + }, + skill: { + kind: 'duration', + heal: true, + targeting: { rangeGrid: WHOLE_FIELD, maxTargets: shots }, + attack: { healScale: hs }, + onStart({ skill, unit }) { + const h = unit.profile?.heal || { mode: 'single' }; + skill.spec.attack.heal = { ...h, elementHealRatio: num(h.elementHealRatio) * hs }; + }, + }, + talents: [ + { install(battle, unit) { // S3: 5 shots, different targets first + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.skill?.active || !ctx.targets.length || ctx.targets.length >= shots) return; + const base = ctx.targets.slice(); + const out = []; + for (let i = 0; i < shots; i++) out.push(base[i % base.length]); + ctx.targets = out; + }, { owner: unit }); + } }, + { install(battle, unit) { // 氤氲 + // "普通治疗使目标每秒额外受到一次治疗量和元素损伤回复量为10%的增益治疗,持续6秒(最多叠加3层)" — PRTS 备注: "本天赋受 + // 特性治疗倍率影响,使用缓存攻击力;叠加时,重置持续时间并更新缓存攻击力": a 10 % heal shaped like her normal one — + // HP heal_scale × ATK and 元素损伤 recovery heal_scale × the trait's ep_heal_ratio × ATK (0.5 / 0.6: 5 % / 6 %, + // it used to be the full 10 %) — per stack, with the ATK of the last application; a new stack refreshes them all. + const sc = num(t0.heal_scale), dur = num(t0.duration, 6), max = Math.max(1, Math.floor(num(t0.max_stack_cnt, 3))); + if (!(sc > 0)) return; + const epRatio = () => num(unit.profile?.heal?.elementHealRatio, num(tb.ep_heal_ratio, 0.5)); + battle.on('heal', (ctx) => { + const t = ctx.target; + if (ctx.source !== unit || ctx.opts?.hot || ctx.opts?.aura || ctx.opts?.regen || ctx.opts?.skillHeal || !t) return; + // keyed per 纯烬: two of them (co-op) each keep their own stacks, ATK and heal source + const b = battle.addBuff(t, { + key: `agoat2:mist:${unit.id}`, duration: dur, refresh: 'stack', stacks: 1, maxStacks: max, interval: 1, source: unit, data: {}, + onTick: ({ unit: a, buff }) => { + const amt = num(buff.data.atk, unit.s.atk) * sc * Math.max(1, buff.stacks); + battle.reduceElement(a, amt * epRatio()); + if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { hot: true }); + }, + }); + if (b) b.data.atk = unit.s.atk; + }, { owner: unit }); + } }, + { install(battle, unit) { // 火山灰疗愈 (×talent_scale during S3) + // "受到的元素损伤降低" is a 元素损伤 multiplier applied on the element hit (PRTS 元素 "…后续可应用元素损伤倍率 + // 提升/降低等效果"), not `elemTakenMul` (元素伤害 / 元素脆弱): the aura buff carries the cut, `elementHit` applies it + const hp = num(t1.max_hp), er = num(t1.ep_damage_resistance), mulS = num(bb.talent_scale, 1); + aura(battle, unit, 0.5, () => { + const f = unit.skill?.active ? mulS : 1; + for (const a of battle.alliesInGrid(unit)) { + battle.addBuff(a, { key: 'agoat2:ash', mods: { hpPct: hp * f }, data: { epCut: Math.min(1, er * f) }, source: unit, duration: 0.75, refresh: 'replace' }); + } + }); + battle.on('elementHit', (ctx) => { + const d = ctx.dmg, b = ctx.target?.findBuff?.('agoat2:ash'); + if (!b || b.source !== unit || !d || d.type !== 'element') return; + const cut = num(b.data?.epCut); + if (cut > 0) d.mul *= Math.max(0, 1 - cut); + }, { owner: unit, priority: 20 }); + } }, + ], + }; +} + +export default { + chess_char_6_20_a: agoat2, +}; diff --git a/server/sim/content/kits/shared/tier1.js b/server/sim/content/kits/shared/tier1.js new file mode 100644 index 00000000..78a5b15d --- /dev/null +++ b/server/sim/content/kits/shared/tier1.js @@ -0,0 +1,242 @@ +// server/sim/content/kits/shared/tier1.js — the general kit helpers (formerly tier1.js's named exports; the tier-1 +// and tier-2 kits use them) and the notes of the Tier 1 operator kits, which live one per file in ../ops/. +// +// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). `bb` = skill blackboard at the chess's +// level (normal Lv4 / elite Lv7), `chess` = raw data/chess.json record (talents with their own bb, trait.bb incl. the +// elite module upgrade, module{active}), `def` = normalised def (def.skill.rangeGrid, def.skill.description …). +// Every number comes from a blackboard; the few constants below exist nowhere in data and are documented. +// Profession defaults (professions.js) are reused: kits only add what the trait text / module says beyond them. +// +// Covered (normal + elite): 1_01 隐现 1_02 角峰 1_03 惊蛰 1_04 深巡 1_05 红豆(H) 1_06 刺玫 1_07 普罗旺斯 1_08 德克萨斯 +// 1_09 跃跃 1_10 古米 1_11 地灵(H) 1_12 艾丝黛尔 1_13 波登可 1_14 格雷伊 1_16 锡人(H) 1_17 深靛 +// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.) 1_15 盟约·辅助干员(H) lives in tier6.js. +// Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's +// `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite); +// talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active +// false) is honoured. Per-skill triggers come from data (the official 技能策略: every MANUAL 重装 skill ⇒ TAKE_DAMAGE — +// but the six of the owner's deliberate deviation, DESIGN §21.29 / tools/build-data.mjs TRIGGER_DEVIATIONS, which are +// DEFAULT —, a MANUAL skill with its own 技能范围 ⇒ SKILL_RANGE, AUTO skills keep their own rule); the few spec rules are +// documented at the skill (冲锋号令 AUTO ⇒ SP_FULL, 花香疗法 heal-type DEFAULT; the two 哨戒铁卫 S2s 深巡 行动能力剥夺 and +// 雷蛇 反击电弧 state the DEFAULT their data carries since that deviation — GitHub issue #4, PR #12). +// Tests: test/content/kits_alt_t1.test.js. +// +// fx kinds emitted (battle.fx(kind, {x, y, …})): aoe {radius, id, skill} · zone {radius, dur, id, skill} · +// counter {id} · crit {id} · dp {n, id} · heal {id} · taunt {id} · summon {id, token} · pull {id} · sonic {radius} · +// shield {id} · overload {id} · takeoff {id} · sleep {id} · buff {id, kind} · reveal {id} · dodge (engine kind). + +import { COLS } from '../../../constants.js'; +import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; +import { offsetTile } from '../../../dir.js'; +import { bodyInKeys, bodyTileReach } from '../../../body.js'; + +// ================================================================================================================= +// shared helpers (named exports; content/index.js only merges the default export) + +/** Finite number or `d` (blackboard values may be strings). */ +export const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d)); + +/** Named talent `i` (data index ≥ 0, in order) of a raw chess record. */ +function talentAt(chess, i) { + const list = (chess?.talents ?? []).filter((t) => t && t.index !== -1); + return list[i] ?? null; +} +/** Blackboard of named talent `i` ({} when absent). */ +export const talentBb = (chess, i = 0) => talentAt(chess, i)?.bb ?? {}; +/** Range grid of named talent `i` (or null). */ +export const talentGrid = (chess, i = 0) => talentAt(chess, i)?.rangeGrid ?? null; +/** Merged blackboard of the hidden module talents (data index −1, elite only). */ +export function moduleBb(chess) { + const o = {}; + for (const t of chess?.talents ?? []) if (t && t.index === -1 && t.bb) Object.assign(o, t.bb); + return o; +} +/** Trait blackboard (elites include their module's trait upgrade). */ +export const traitBb = (chess) => chess?.trait?.bb ?? {}; +/** Elite module active (golden chess). */ +export const moduleOn = (chess) => !!(chess?.module && chess.module.active); + +export const up = (u) => !!u && u.alive && u.deployed; +export const posKey = (u) => Math.round(u.y) * COLS + Math.round(u.x); +/** Chebyshev tile distance between two units (a huge enemy: from the nearest tile it occupies — sim/body.js). */ +export const cheb = (a, b) => (b.hitArea ? bodyTileReach(b, Math.round(a.y), Math.round(a.x)) + : a.hitArea ? bodyTileReach(a, Math.round(b.y), Math.round(b.x)) + : Math.max(Math.abs(Math.round(a.y) - Math.round(b.y)), Math.abs(Math.round(a.x) - Math.round(b.x)))); +/** Normal attack hit on its primary target (no splash, no chain jump). */ +export const isMainHit = (dmg) => !!dmg && dmg.isAttack && !dmg.isSplash && !(dmg.tags && dmg.tags.includes('chain')); +/** Damage ctx caused by an enemy's attack. */ +export const byEnemyAttack = (ctx) => !!ctx.source && ctx.source.side === 'enemy' && !!ctx.dmg && ctx.dmg.isAttack; +/** + * `damaged` ctx of a damage that removed HP and can give 受击回复 SP — the engine's rule (damage.js applyHpLoss: not a 流失 + * (`noSp`, Battle.loseHp), not an element 损伤), whatever its source: an attack, a zone, the 无来源 源石溶剂 tick. + */ +export const hurtSpDamage = (ctx) => !!ctx.dmg && !ctx.dmg.noSp && ctx.type !== 'element' && ctx.amount > 0; +/** A timed skill is running (no SP may be gained). */ +export const skillBusy = (u) => !!(u.skill && u.skill.active && u.skill.isTimed); +/** Give SP unless a timed skill is running (AK: no SP gain during a skill). */ +export function giveSp(u, n, reason = 'talent') { + if (!u || !u.skill || u.skill.noSkill || skillBusy(u) || !(n > 0)) return 0; + return u.skill.gainSp(n, reason); +} + +/** 'hit' handler for damage dealt BY `unit` (pre-mitigation; mutate ctx.dmg). */ +export function onHitBy(battle, unit, fn, priority = 0) { + return battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.target && ctx.target.side === 'enemy') fn(ctx); }, { owner: unit, priority }); +} +/** 'hit' handler for damage dealt TO `unit`. */ +export function onHitOn(battle, unit, fn, priority = 0) { + return battle.on('hit', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority }); +} +/** 'damaged' handler for damage dealt TO `unit`. */ +export function onDamagedOn(battle, unit, fn, priority = 0) { + return battle.on('damaged', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority }); +} + +/** Targetable enemies inside `grid` (relative to the unit; null ⇒ current range), best targets first. */ +export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canHitFly = true, groundOnly = false } = {}) { + const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys; + const list = battle.enemiesInKeys(keys, unit, { canHitFly, groundOnly }); + sortEnemyTargets(battle, unit, list, priority ?? unit.profile?.priority ?? null); + return n > 0 && list.length > n ? list.slice(0, n) : list; +} +/** + * Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range) — never a 孤立 unit + * (炎佑: Battle.allySelectable), as Battle.alliesInGrid. + */ +export function alliesInGridOf(battle, unit, grid = null) { + const set = grid ? new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : (unit.rangeKeySet || new Set(unit.rangeKeys || [])); + return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && battle.allySelectable(a, unit)); +} +/** Any targetable enemy in the unit's current range. */ +export const enemyInRange = (battle, unit) => battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length > 0; + +/** Run `fn` once per battle for `key` (battle-wide handlers shared by several units). */ +const ONCE = new WeakMap(); +export function once(battle, key, fn) { + let s = ONCE.get(battle); + if (!s) ONCE.set(battle, (s = new Set())); + if (s.has(key)) return; + s.add(key); + fn(); +} + +/** Permanent talent stat buff (survives death / redeploy). */ +export function statBuff(battle, unit, key, mods) { + const m = {}; + for (const [k, v] of Object.entries(mods)) if (Number.isFinite(v) && v !== 0 && !(k.endsWith('Mul') && v === 1)) m[k] = v; + if (Object.keys(m).length) battle.addBuff(unit, { key, mods: m, persist: true, allowDead: true, tags: ['talent'] }); +} + +/** Keep buff `key` on `unit` exactly while cond() holds (checked every tick). `mods` may be a function. */ +export function toggleBuff(battle, unit, key, cond, mods, extra = {}) { + const check = () => { + const want = up(unit) && !!cond(); + const has = unit.findBuff(key); + if (want && !has) battle.addBuff(unit, { key, mods: typeof mods === 'function' ? mods() : mods, tags: ['talent'], ...extra }); + else if (!want && has) battle.removeBuff(unit, key); + }; + battle.on('tick', check, { owner: unit }); + battle.on('battleStart', check, { owner: unit }); + battle.on('deploy', (ctx) => { if (ctx.unit === unit && battle.started) check(); }, { owner: unit }); +} + +/** + * Aura while `unit` is on the field: every `interval` s, allies passing `select(a)` get buff `key` for slightly longer + * than the interval. Several sources of the same aura never stack: the strongest `value` wins. + */ +export function installAura(battle, unit, { key, select, mods, value = 0, interval = 0.5 }) { + battle.every(interval, () => { + if (!up(unit)) return; + for (const a of battle.alliesFor(unit)) { + if (!select(a)) continue; + const cur = a.findBuff(key); + if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue; + battle.addBuff(a, { key, duration: interval + 0.1, mods: typeof mods === 'function' ? mods(a) : mods, source: unit, data: { v: value }, tags: ['aura'] }); + } + }, { owner: unit, immediate: true }); +} + +/** Extra natural SP recovery (+perSec) while cond() holds (hooks the per-tick 'time' SP gain). */ +export function spTimeBonus(battle, unit, perSec, cond) { + if (!(perSec > 0)) return; + battle.on('spGain', (ctx) => { + if (ctx.unit === unit && ctx.reason === 'time' && cond()) ctx.amount += perSec * battle.dt; + }, { owner: unit }); +} + +/** Module "攻击范围内敌人的隐匿效果失效": stealthed enemies inside the current range are revealed. */ +export function installReveal(battle, unit, interval = 0.2) { + battle.every(interval, () => { + if (!up(unit) || !unit.rangeKeySet) return; + for (const e of battle.enemies) { + if (!e.alive || e.hidden || !e.s.flags.stealth || !bodyInKeys(e, unit.rangeKeySet)) continue; + const was = !!e.s.flags.reveal; + if (battle.applyStatus(e, 'reveal', { duration: interval + 0.1, source: unit }) && !was) battle.fx('reveal', { x: e.x, y: e.y, id: e.id }); + } + }, { owner: unit }); +} + +/** + * Lingering area (spores, alchemy units): `onPulse(battle)` runs immediately and then every `interval` s, + * `round(duration / interval)` times in total. Not owned by the caster: it keeps working after the caster falls. + */ +export function makeZone(battle, caster, { x, y, radius, duration, interval = 1, skill, onPulse, onEnd }) { + const total = Math.max(1, Math.round(duration / interval)); + let n = 0; + battle.fx('zone', { x, y, radius, dur: duration, id: caster.id, skill }); + return battle.every(interval, (b, sc) => { + onPulse(b); + if (++n >= total) { sc.cancel(); if (onEnd) onEnd(b); } + }, { immediate: true }); +} + +/** + * A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out + * operator lying there, not the home tile of a board unit that has not deployed yet / waits to redeploy — a summon + * there would stop that operator from redeploying until it leaves). + */ +export function summonTileFree(battle, r, c) { + return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c); +} + +/** First free tile around `unit` (Chebyshev ring 1, front first — offsets rotated by its direction) where `ok(r, c)` holds. */ +export function freeTileAround(battle, unit, ok) { + const order = [[0, 1], [1, 1], [-1, 1], [1, 0], [-1, 0], [0, -1], [1, -1], [-1, -1]]; + for (const [dr, dc] of order) { + const [r, c] = offsetTile(unit.tileR, unit.tileC, dr, dc, unit.dir); + if (!summonTileFree(battle, r, c)) continue; + if (ok(r, c)) return [r, c]; + } + return null; +} + +/** Skill spec kind for an instant skill with (possible) charges. */ +export const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant'); + +/** + * SkillRecord `skillId` of a raw chess record (DESIGN §16 operator loadouts): the selected skill (`chess.skill`) or one of + * `chess.skills[]` (every skill of the chess at its level — bb, duration, rangeGrid, trigger). Null when absent. + * A kit's `skills` map builds every entry from its own record, so each spec is right whichever skill is selected. + */ +export function skillRec(chess, skillId) { + if (chess?.skill && (chess.skill.skillId ?? chess.skill.id) === skillId) return chess.skill; + return (chess?.skills ?? []).find((s) => s && s.skillId === skillId) ?? null; +} +/** Blackboard of skill `skillId` of a raw chess record ({} when absent). */ +export const skillBbOf = (chess, skillId) => skillRec(chess, skillId)?.bb ?? {}; + +/** + * Skill blackboard `base_attack_time` → engine batPct of this chess's base attack time. The value is a FLAT change of + * the base attack time in seconds (AK attribute ADDITION: 送葬人 −0.5 on 2.3 s "少量缩短(-0.5)" ⇒ 1.8 s, 红豆 +0.5 + * "略微增大(+0.5)" — the same convention as tier4/tier5), except a positive value described as a shortening, which is + * the new interval ratio (深靛 0.7 "攻击间隔略微缩短" ⇒ ×0.7). The convention is per skill in AK: skills PRTS lists as + * a ratio (雷蛇 反击电弧 "+70%", 古米 食粮烹制 "+130%", 深靛 灯塔守卫者 "-80%") use batPct = value directly, not this. + */ +export function batMod(v, chess = null, desc = '') { + const x = num(v, 0); + if (!x) return 0; + if (x > 0 && x < 1 && /间隔[^,。;]*缩短/.test(desc)) return x - 1; + const bat = num(chess?.stats?.bat, 1) || 1; + return Math.max(-0.9, x / bat); +} +/** "周围8格" = Chebyshev ring 1 ⇒ Euclidean radius covering the 8 neighbours. */ +export const RING1 = 1.5; diff --git a/server/sim/content/kits/shared/tier2.js b/server/sim/content/kits/shared/tier2.js new file mode 100644 index 00000000..85a694f8 --- /dev/null +++ b/server/sim/content/kits/shared/tier2.js @@ -0,0 +1,26 @@ +// server/sim/content/kits/shared/tier2.js — helpers and notes of the Tier 2 operator kits (formerly tier2.js; the kits +// live one per file in ../ops/). +// +// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). Shared helpers live in tier1.js (named +// exports). Numbers come from the skill / talent / trait blackboards (elite module upgrades are in the elite record's +// trait.bb and hidden index −1 talents). fx kinds: see the tier1.js header. +// +// Covered (normal + elite): 2_01 送葬人 2_02 赫默 2_03 崖心(H) 2_04 小满 2_05 哈洛德 2_06 莎草 2_07 幽灵鲨 2_08 泡泡 +// 2_09 休谟斯 2_10 洛洛 2_11 风丸 2_12 砾 2_13 蒂比 2_14 调香师 2_15 协律(H) 2_16 拉普兰德 2_17 折桠 2_18 灰毫 2_19 锡人. +// +// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skill in `skills` +// ({ [skillId]: SkillSpec }, official Lv4 / Lv7 blackboards — the kit function receives the SELECTED skill's `bb`, so +// every spec is built from `bb`; the trigger rule comes from that skill's data). Talent / install hooks that belong to +// the default skill only (target locks, counters, hit triggers) check onDefaultSkill(chess). Module choices need no +// code here: `chess` is the loadout-resolved record (trait.bb / talents of the selected module, or none). + +/** + * Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the + * selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default. + */ +function onDefaultSkill(chess) { + const d = (chess?.skills ?? []).find((s) => s && s.isDefault); + return !d || !chess?.skill || d.skillId === chess.skill.skillId; +} + +export { onDefaultSkill }; diff --git a/server/sim/content/kits/shared/tier3.js b/server/sim/content/kits/shared/tier3.js new file mode 100644 index 00000000..e5015b54 --- /dev/null +++ b/server/sim/content/kits/shared/tier3.js @@ -0,0 +1,224 @@ +// server/sim/content/kits/shared/tier3.js — helpers and notes of the hand-authored kits for the 21 tier-3 chess (19 +// visible + 见行者/巫恋 hidden; formerly tier3.js — the kits live one per file in ../ops/). +// +// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). `bb` is the skill blackboard at the +// chess's level (normal Lv4 `_a` / elite Lv7 `_b`), `chess` the data/chess.json record (talents incl. module upgrades, +// trait incl. module trait bb), `def` the normalised def. Every number comes from a blackboard; the few values that +// only exist in the text ("三连击", "至多5个", "初始两只", "至多3只") are parsed from the description with fallbacks. +// Profession defaults (server/sim/professions.js) are kept and extended: fastshot fly bonus, instructor, reaperrange, +// hunter ammo, funnel ramp, phalanx guard, merchant drain, tactician reinforcement, underminer module weaken. +// Summons: the match hands 伺夜's 狼群 and 巫恋's 诅咒娃娃 to the player as placeable board pieces (tokens.js kits); +// 伺夜's kit adopts his pack piece instead of adding a second pack, 巫恋's S2 brings her placed doll onto its tile +// (tokens.js releaseSkillSummon), and every summon tile avoids the home tile of an ally that has not (re)deployed yet +// (freeTile). Offensive skills whose own range differs from the attack +// range trigger on that skill range (CUSTOM_RANGE: 松果 shortened range, 见行者 push row — research 03 §1.4). +// +// Client VFX (battle.fx kinds used here, all `{x, y, id, …extra}`): 'aoe' (radius, dmgType), 'strike' (special single +// hit), 'volley' (multi-target shot), 'push', 'pull', 'summon' (token), 'coin' (n), 'dp' (n), 'revive', 'buff', +// 'shield', 'shieldBreak', 'camouflage', 'explode'. +// +// Operator loadouts (DESIGN §16): the kit is built for the SELECTED skill (`bb`, `chess.skill`, `def.skill`) and module +// (talents / trait bb). `skill` is the default skill's spec; `skills: { [skillId]: SkillSpec }` holds every other +// selectable skill of the visible chess, each built from its OWN record at the chess's level (skillData: Lv4 normal / +// Lv7 elite blackboard, range, charges) — the loader picks the selected one. Talents / trait / install are shared: the +// parts that belong to one skill only (琳琅诗怀雅 bombs, 菲莱 counters, 雪猎 special bullets…) check the selected skill id. +// Triggers come from the data record of each skill (skills[i].trigger: tools/build-data.mjs resolveTrigger — the official +// 技能策略 incl. SKILL_RANGE for a MANUAL skill's own 技能范围 and the class rows for every MANUAL skill), except +// 薄绿 S1 (the 阵法术师 row SEARCH = "在初始攻击范围内存在敌人时" ⇒ the engine's DEFAULT for a phalanx, checked every +// tick) and the kits' own automatic casts (雪猎 special bullets). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment), +// 斯卡蒂 DRE-X (× vs blocked), 瑕光 GUA-X (heal × under 50 %), 伺夜 TAC-Y (×165 % trait, pack-blocked enemies taunt +1), +// 空弦 MAR-X (fly ×, profession layer). No in-battle effect here: 忍冬 SOL-Y "首次部署时部署费用-4" (the initial +// deployment is free) and the 集成战略-only ISW-A modules of 琳琅诗怀雅 / 空弦 (their stats and trait cost still apply). + +import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; +import { COLS } from '../../../constants.js'; +import { bodyInKeys } from '../../../body.js'; +import { normalizeChess, normalizeSkill } from '../../../simdata.js'; +import { tacticalPoint as sharedTacticalPoint } from '../../tokens.js'; + +// --------------------------------------------------------------------------------------------------------------- +// helpers + +const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); +const defOf = (chess, def) => def ?? normalizeChess(chess); +const talentBb = (d, i) => d?.talents?.[i]?.bb ?? {}; +const traitBb = (d) => d?.traitBb ?? {}; +/** Module-only talent (data: `hidden && fromModule`, e.g. 空弦 精锐 "范围内存在地面敌人时攻击速度+8"). */ +const moduleTalentBb = (chess) => (chess?.talents ?? []).find((t) => t && t.hidden && t.fromModule)?.bb ?? null; +/** Id of the SELECTED skill the kit is built for (DESIGN §16: `bb`, `chess.skill` and `def.skill` belong to it). */ +const selectedId = (chess, d) => d?.skill?.id ?? chess?.skill?.skillId ?? null; +/** + * Normalised record (simdata normalizeSkill shape: bb, rangeGrid, maxCharges, duration, description, trigger) of the + * selectable skill `id` at this chess's level: the selected one is `def.skill` with the kit's `bb`, any other comes + * from `chess.skills[]` (DATA §2.2) — every spec of `skills` is built from its own numbers whichever skill is selected. + */ +function skillData(chess, d, bb, id) { + if (id != null && selectedId(chess, d) === id && d?.skill) return { ...d.skill, bb: bb ?? d.skill.bb ?? {} }; + const rec = (chess?.skills ?? []).find((s) => s && s.skillId === id); + return (rec && normalizeSkill({ skill: rec })) || { id, bb: {}, rangeGrid: null, maxCharges: 1, duration: 0, description: '' }; +} +/** `{ [skillId]: SkillSpec }` of the non-default selectable skills: `builders[id](skillData(id))` each. */ +function altSkills(chess, d, bb, builders) { + const out = {}; + for (const [id, build] of Object.entries(builders)) out[id] = build(skillData(chess, d, bb, id)); + return out; +} +/** instant, or charges when the skill stores several (可充能N次). */ +const instantKindOf = (s) => ((s?.maxCharges ?? 1) > 1 ? 'charges' : 'instant'); +/** Timed stat skill (迅捷打击 / 攻击力强化 / 防御力强化 …): bb atk / def / max_hp / attack_speed on the operator. */ +const statSkill = (s) => { + const b = s.bb || {}; + const mods = {}; + if (num(b.atk)) mods.atkPct = num(b.atk); + if (num(b.def)) mods.defPct = num(b.def); + if (num(b.max_hp)) mods.hpPct = num(b.max_hp); + if (num(b.attack_speed)) mods.aspd = num(b.attack_speed); + return { kind: 'duration', mods }; +}; +/** + * "范围内存在地面敌人时攻击速度+N" (能天使 MAR-Y trait, 空弦 MAR-Y talent): ASPD buff `key` while at least `cnt` ground + * enemies stand in the current range. + */ +function groundAspd(key, aspd, cnt = 1) { + return (battle, unit) => { + let on = false; + battle.every(0.1, () => { + const n = alive(unit) ? battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).filter((e) => !e.isFlying).length : 0; + const want = n >= Math.max(1, cnt); + if (want === on && (!want || unit.findBuff(key))) return; + on = want; + if (want) battle.addBuff(unit, { key, mods: { aspd } }); + else battle.removeBuff(unit, key); + }, { owner: unit }); + }; +} +const alive = (u) => !!(u && u.alive && u.deployed); +const tileKeyOf = (u) => (u.side === 'ally' ? u.tileR * COLS + u.tileC : Math.round(u.y) * COLS + Math.round(u.x)); +/** On a tile of `set`: an ally by its tile, an enemy by its body (every tile a huge enemy occupies — sim/body.js). */ +const onTiles = (x, set) => (x.side === 'ally' ? set.has(tileKeyOf(x)) : bodyInKeys(x, set)); +const gridKeys = (grid, u, ext = 0) => absoluteRangeKeys(grid || [[0, 0]], u.tileR, u.tileC, u.dir, ext); +const fx = (battle, kind, u, extra = {}) => battle.fx(kind, { x: u.x, y: u.y, id: u.id, ...extra }); +const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null); +const NINE = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]; +const CN_NUM = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 }; +/** Number captured by `re` (arabic or a single chinese numeral) in `text`, else `fallback`. */ +function textNum(text, re, fallback) { + const m = String(text ?? '').match(re); + if (!m) return fallback; + return /^\d+(\.\d+)?$/.test(m[1]) ? +m[1] : (CN_NUM[m[1]] ?? fallback); +} +/** + * A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out + * operator lying there, not the home tile of an ally that has not deployed yet / waits to redeploy — the initial + * deployment runs one unit after another: a summon placed while it runs must not steal a later board unit's tile). + */ +function freeTile(battle, r, c) { + return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c); +} +/** Walkable ground tile a melee summon can stand on. */ +const groundTile = (battle, r, c) => battle.grid.groundPassable(r, c) && battle.grid.canStand(r, c); +/** + * Tactical point (战术点) of a tactician: `prefer` (the board piece's tile, i.e. the player's choice) when usable — + * free, standable ground (never 深水区: grid.canStand) inside her initial range ("只能部署在召唤者攻击范围内"; the prep + * already keeps the piece there, PlayerState._legal) —, else the shared tactical point (tokens.js tacticalPoint = + * Battle.findTacticalPoint: a free walkable tile of its initial range on an enemy ground path first, then the nearest). + */ +function tacticalPoint(battle, unit, prefer = null) { + const inRange = (r, c) => (unit.baseRangeKeys || unit.rangeKeys || []).includes(r * COLS + c); + if (prefer && freeTile(battle, prefer[0], prefer[1]) && groundTile(battle, prefer[0], prefer[1]) && inRange(prefer[0], prefer[1])) return prefer; + return sharedTacticalPoint(battle, unit); +} + +/** Targetable enemies on `keys`, sorted by the unit's priority (at most `n` when n > 0). */ +function enemiesOn(battle, unit, keys, n = 0, profile = null) { + const list = battle.enemiesInKeys(keys, unit, profile ?? { canHitFly: true }); + sortEnemyTargets(battle, unit, list, (profile ?? unit.profile)?.priority ?? null); + return n > 0 && list.length > n ? list.slice(0, n) : list; +} + +/** + * Tile aura: every `interval` s (0 ⇒ call the returned update() yourself, e.g. from a skill onTick) buffs the + * units of `side` standing on `tiles()` (filter/mods per unit, mods null ⇒ skipped) and drops the buff from units + * that left. update.clear() removes everything this aura applied. Same-key auras of several sources do not stack. + */ +function aura(battle, unit, o) { + let cur = new Set(); + const iv = o.interval ?? 0.2; + const dur = Math.max(iv * 2, 0.1) + 0.05; + const drop = (x) => { const b = x.findBuff(o.key); if (b && b.source === unit) battle.removeBuff(x, b); }; + const update = () => { + const next = new Set(); + if (alive(unit) && (!o.active || o.active())) { + const keys = o.tiles(); + const set = keys instanceof Set ? keys : new Set(keys || []); + const list = o.side === 'ally' ? battle.allyUnits : battle.enemies; + for (const x of list) { + if (!x.alive || !x.deployed || x.hidden || x.kind === 'device') continue; + if (o.side === 'ally' && !battle.allySelectable(x, unit)) continue; // never a 孤立 unit (炎佑) + if (!onTiles(x, set) || (o.filter && !o.filter(x))) continue; + const mods = typeof o.mods === 'function' ? o.mods(x) : o.mods; + if (!mods) continue; + battle.addBuff(x, { key: o.key, duration: dur, refresh: 'extend', mods, source: unit, tags: ['aura'] }); + next.add(x); + } + } + for (const x of cur) if (!next.has(x)) drop(x); + cur = next; + }; + update.clear = () => { for (const x of cur) drop(x); cur = new Set(); }; + // immediate: installed at construction ⇒ first runs right after the initial deployment (t = 0), not 1 interval later + if (iv > 0) battle.every(iv, update, { owner: unit, immediate: true }); + return update; +} + +/** Buff present exactly while `cond()` holds (checked every tick). */ +function whileTrue(battle, unit, key, cond, mods) { + battle.on('tick', () => { + const on = alive(unit) && cond(); + const has = !!unit.findBuff(key); + if (on && !has) battle.addBuff(unit, { key, mods: typeof mods === 'function' ? mods() : mods }); + else if (!on && has) battle.removeBuff(unit, key); + }, { owner: unit }); +} + +/** Team-wide talents never stack: only the lowest-id living carrier of `tag` of this player applies them. */ +function isLeader(battle, unit, tag) { + let best = null; + for (const a of battle.allyUnits) if (alive(a) && a.ownerId === unit.ownerId && a.mem[tag] && (!best || a.id < best.id)) best = a; + return best === unit; +} + +/** SP gain that respects "no SP while a timed skill runs". */ +function giveSp(u, n, reason = 'talent') { + const sk = u?.skill; + if (!sk || sk.noSkill || !(n > 0) || (sk.active && sk.isTimed)) return 0; + return sk.gainSp(n, reason); +} + +/** + * Funnel ramp tracked per target (耶拉's 2 drones each ramp on their own lock; 1 drone = profession behaviour). Two + * drones locked on the same enemy land in the same tick: they ramp in parallel (one step per volley, same scale). + */ +function funnelMap(battle, unit, target) { + const f = unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; + const m = unit.trait.funnelMap || (unit.trait.funnelMap = new Map()); + const prev = m.get(target.id); + if (prev && prev.t === battle.time) return prev.s; + const s = prev == null ? f.init : Math.min(f.max, prev.s + f.delta); + m.set(target.id, { s, t: battle.time }); + return s; +} +/** Drones return when their lock is dropped: forget ramps of enemies no longer attacked. */ +function installFunnelPrune(battle, unit) { + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.trait.funnelMap) return; + const ids = new Set(ctx.targets.map((t) => t.id)); + for (const k of [...unit.trait.funnelMap.keys()]) if (!ids.has(k)) unit.trait.funnelMap.delete(k); + }, { owner: unit, priority: -100 }); +} + +export { + num, defOf, talentBb, traitBb, moduleTalentBb, selectedId, altSkills, instantKindOf, statSkill, groundAspd, alive, + onTiles, gridKeys, fx, copyGrid, NINE, textNum, freeTile, groundTile, tacticalPoint, enemiesOn, aura, whileTrue, + isLeader, giveSp, funnelMap, installFunnelPrune, +}; diff --git a/server/sim/content/kits/shared/tier4.js b/server/sim/content/kits/shared/tier4.js new file mode 100644 index 00000000..586930e1 --- /dev/null +++ b/server/sim/content/kits/shared/tier4.js @@ -0,0 +1,162 @@ +// server/sim/content/kits/shared/tier4.js — helpers and notes of the hand-authored kits for every tier-4 chess (26: 22 +// visible + 4 hidden; formerly tier4.js — the kits live one per file in ../ops/). +// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). The same kit serves the elite +// (精锐 `_b`) id: `bb` is the Lv4 / Lv7 skill blackboard, talent/trait blackboards come from `def` (module talent and +// trait upgrades of elites are already merged into the data), `def.raw.module.active` tells whether the module runs. +// Every number comes from a blackboard (skill `bb`, `def.talents[i].bb`, `def.traitBb`, token data); literals below +// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius …). +// +// Notes shared by several kits: +// - `base_attack_time` in skill blackboards is a FLAT change of the base attack time in seconds (白面鸮 −1.8 on 2.85 s +// ⇒ 1.05 s; 水月 −0.7 on 3.5 s) and is converted to the engine's `batPct` (÷ base BAT). 焰尾's +0.7 (text: 攻击间隔 +// 缩短) is a multiplier (×0.7) and 信仰搅拌机's 0.6 is the counter interval ratio — both handled in their kits. +// - "技力光环 (同类效果取最高)" (莫斯提马, 白面鸮) share the buff key `aura:spRecovery`; a unit keeps the highest one. +// - Auras refresh short buffs every AURA s so they lapse within a few ticks after the source leaves. +// - Talents that name a faction use data fields: nationId (拉特兰 laterano, 卡西米尔 kazimierz, 谢拉格 kjerag), +// profession (术师 CASTER, 重装 TANK, 先锋 PIONEER), enemy tags (萨卡兹 sarkaz, 海怪 seamonster); 深海猎人 = research +// groupId `abyssal` (data/chess.json has no groupId: charIds from docs/research/03-operators.json). +// - "友方干员" effects touch operators only (summons/devices excluded); SP gifts skip units whose timed skill runs +// (AK: no SP gain during a skill — the engine's gainSp enforces it too; the kits skip such units when picking). +// - On-hit effects of 卡涅利安's attacks land on every enemy the attack strikes — the 阵法术师 trait strikes every enemy +// on her range at once (professions.js `allInRange` / `rangeAoe`, community report E3): S2's 停顿 / 束缚 via a +// `damaged` hook, the charged S3 mark via a `hit` hook (it applies before the damage, PRTS 备注). +// - Dodge from a skill that adds to other dodge sources (焰尾 S3) is rolled independently in a `hit` hook, so the +// sources combine as 1 − Π(1 − p) (the same rule the engine now applies to stacked dodge mods). +// - Operator loadouts (DESIGN §16): every selectable non-default skill of the 22 visible chess is authored in the kit's +// `skills` map (see alt() below); logic of one skill (counters, deferrals, auras…) only runs when it is selected, +// and module effects check the selected module (moduleIs / the module-merged trait & talent blackboards). +// `node tools/kit-coverage.mjs --tier 4 --strict`; tests: test/content/kits_alt_t4.test.js. + +import { bodyInKeys } from '../../../body.js'; +import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; +import { normalizeChess } from '../../../simdata.js'; + +const AURA = 0.2; // aura refresh period (s) +const AURA_DUR = 0.25; // aura buff lifetime (s): lapses ~1 tick after the source stops refreshing it + +// --------------------------------------------------------------------------------------------------------------- +// helpers + +const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : d)); +const tbb = (def, i) => (def && def.talents && def.talents[i] && def.talents[i].bb) || {}; +/** Hidden module talent blackboard (name null, e.g. 水月 move_speed, 魔王 cnt/atk). */ +const moduleBb = (def) => (def?.talents || []).filter((t) => !t.name && t.bb && Object.keys(t.bb).length).reduce((o, t) => Object.assign(o, t.bb), {}); +const grid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null); +/** Flat BAT change (s) → batPct. */ +const batFlat = (def, v) => { const b = num(def?.stats?.bat, 1) || 1; return Math.max(-0.9, num(v, 0) / b); }; +const nationOf = (u) => u?.def?.raw?.nationId ?? null; +const enemyHasTag = (e, tag) => !!e && e.side === 'enemy' && Array.isArray(e.def?.tags) && e.def.tags.includes(tag); +const keySet = (unit) => unit.rangeKeySet || new Set(unit.rangeKeys || []); +/** Every living, visible enemy standing on the unit's range (ignores stealth/untargetable: auras, reveals). */ +function enemiesOnRange(battle, unit, keys = null) { + const set = keys || keySet(unit); + const out = []; + for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, set)) out.push(e); + return out; +} +/** Targetable enemies in the unit's current range (flyers included), best targets first. */ +function targetsInRange(battle, unit, n = 0, keys = null) { + const list = battle.enemiesInKeys(keys || unit.rangeKeys, unit, { canHitFly: true }); + sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null); + return n > 0 && list.length > n ? list.slice(0, n) : list; +} +/** Targetable enemies on the tiles of `g` (offsets) around the unit. */ +function targetsInGrid(battle, unit, g) { + return battle.unitsInGrid(unit, g, { side: 'enemy' }).filter((e) => canTargetEnemy(unit, e, { canHitFly: true })); +} +/** Run `fn` every `iv` s while the unit is alive and deployed. */ +const whileDeployed = (battle, unit, iv, fn) => battle.every(iv, () => { if (unit.alive && unit.deployed) fn(); }, { owner: unit }); +/** Short-lived aura buff. */ +const pulse = (battle, target, key, mods, extra = {}) => battle.addBuff(target, { key, mods, duration: AURA_DUR, ...extra }); +/** Keep (or remove) a permanent self buff. */ +function toggleBuff(battle, unit, key, on, mods) { + const cur = unit.findBuff(key); + if (on) { + if (!cur || JSON.stringify(cur.mods) !== JSON.stringify(mods)) battle.addBuff(unit, { key, mods }); + } else if (cur) battle.removeBuff(unit, key); +} +/** "技力光环(同类效果取最高)": shared key, highest value wins. */ +function spAura(battle, source, value, filter) { + whileDeployed(battle, source, AURA, () => { + for (const a of battle.alliesFor(source, source.ownerId)) { + if (!filter(a)) continue; + const cur = a.findBuff('aura:spRecovery'); + if (cur && cur.source !== source && cur.source?.alive && (cur.data?.v ?? 0) > value) continue; + battle.addBuff(a, { key: 'aura:spRecovery', mods: { spRecoveryFlat: value }, duration: AURA_DUR, source, data: { v: value } }); + } + }); +} +/** Reveal stealthed enemies on the given tiles. */ +/** RES cut mods for a blackboard magic_resistance value (battle.applyStrongest): |v| < 1 = ×(1 + v), else flat v. */ +const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v }); +const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); }; +const skillActive = (u) => !!(u.skill && u.skill.active && u.skill.kind !== 'passive'); + +// ---- operator loadouts (DESIGN §16) ---------------------------------------------------------------------------- +// The kit function receives the def of the SELECTED skill / module: `bb` = that skill's blackboard (normal Lv4 / +// elite Lv7), `def.skill` its record, talents / trait already carry the selected module's changes. `skills` holds the +// hand-authored spec of every selectable non-default skill (only the selected one is built, with its own `bb`); +// install / talent logic that belongs to ONE skill is gated on that skill being the selected one. + +/** Id of the selected skill. */ +const selId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? null; +/** Is `id` the selected skill? */ +const isSel = (def, id) => selId(def) === id; +/** `skills` map of a kit: the spec of the selected skill when a builder exists for it (DESIGN §16 kit contract). */ +function alt(def, builders) { + const id = selId(def); + return id && typeof builders[id] === 'function' ? { [id]: builders[id]() } : {}; +} +/** Instant or charges kind from the data (maxChargeTime). */ +const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant'); +/** No other ally operator / summon on the 4 (or 8) tiles around the unit. */ +function lonely(battle, unit, diag = false) { + for (let dr = -1; dr <= 1; dr++) { + for (let dc = -1; dc <= 1; dc++) { + if ((!dr && !dc) || (!diag && dr && dc)) continue; + const r = unit.tileR + dr, c = unit.tileC + dc; + if (!battle.grid.inBounds(r, c)) continue; + const o = battle.unitAt(r, c); + if (o && o !== unit && o.side === 'ally' && o.kind !== 'device') return false; + } + } + return true; +} +/** + * Module "攻击范围扩大" given as a new range (the selected module's data-only talent `rangeGrid`, e.g. SPC-X / RIN-X: + * the 3×3 caster range + ONE centre tile [0,3]). It replaces the unit's own range — so also the initial range of the + * DEFAULT trigger — while a skill with its own range (莫斯提马 S3, 白面鸮 S2…) keeps that range, as in AK. Null when the + * selected module (or 'none') changes no range. (The rangeGrid-only talent is dropped from `def.talents`: read the + * module record.) + */ +function moduleRangeGrid(def) { + const m = def?.raw?.module; + if (!m || !m.active || !m.id) return null; + const rec = (def.raw.modules || []).find((x) => x && x.uniEquipId === m.id); + for (const t of rec?.talentChanges || []) { const g = grid(t?.rangeGrid); if (g) return g; } + return null; +} +const applyModuleRange = (battle, unit, def) => { const g = moduleRangeGrid(def); if (g) { unit.rangeGrid = g; battle.refreshRange(unit); } }; +/** + * Pull `e` "至面前" of the unit with 力度 `force` (Battle.pullToFront: the official 拉力起点 half a tile ahead, 急停 0.6708 + * around it, 力度 − 重量 — weight ≤ force all the way, one heavier a third of the way, …); returns the distance moved. + */ +function pullToFront(battle, unit, e, force) { + return e && e.alive ? battle.pullToFront(e, unit, num(force, 0)) : 0; +} + +/** Physician module trait (录武官, 华法琳 elites): heals on allies below hp_ratio are ×heal_scale. */ +function installLowHpHealBonus(battle, unit, tb) { + const s = num(tb.heal_scale, 0), r = num(tb.hp_ratio, 0); + if (!(s > 0) || !(r > 0)) return; + battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && c.target.hpRatio < r) c.amount *= s; }, { owner: unit }); +} + +// DESIGN §5.6 documents `(bb, chess)`; content/index.js also passes the normalised def — rebuild it when absent. +const withDefaults = (kits) => Object.fromEntries(Object.entries(kits).map(([id, f]) => [id, (bb, chess, def) => f(bb || {}, chess, def || normalizeChess(chess))])); + +export { + AURA, AURA_DUR, num, tbb, moduleBb, grid, batFlat, nationOf, enemyHasTag, keySet, enemiesOnRange, targetsInRange, + targetsInGrid, whileDeployed, pulse, toggleBuff, spAura, resCut, reveal, skillActive, isSel, alt, instantKind, lonely, + applyModuleRange, pullToFront, installLowHpHealBonus, withDefaults, +}; diff --git a/server/sim/content/kits/shared/tier5.js b/server/sim/content/kits/shared/tier5.js new file mode 100644 index 00000000..2f55059f --- /dev/null +++ b/server/sim/content/kits/shared/tier5.js @@ -0,0 +1,204 @@ +// server/sim/content/kits/shared/tier5.js — helpers and notes of the hand-authored kits for every tier-5 chess (DESIGN +// §7, docs/SIM.md §7.2; formerly tier5.js — the kits live one per file in ../ops/). +// +// export default { [baseChessId]: (bb, chess, def) => Kit }. `bb` = skill blackboard at the chess's level (normal Lv4 / +// elite Lv7), `chess` = data/chess.json record (talents with their own bb — elite records already carry the module talent +// upgrades — trait.bb incl. the module trait upgrade, stats, tokens), `def` = normalised def (skill.rangeGrid, …). +// Every number comes from those blackboards; the few constants below exist only in the skill TEXT (no blackboard key). +// +// Conventions shared by the kits of this file: +// - `base_attack_time` in skill blackboards is an absolute delta in seconds (AK attribute ADDITION: 烛煌 1.6 s − 1.3 s, +// 号角 2.8 s − 1.2 s, 白面鸮 2.85 s − 1.8 s, 寒檀 2.9 s − 2.4 s); it is converted into `batPct` of the chess's base BAT. +// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge, +// × elementalTakenMul = 元素脆弱), with `element` set for the client colour. +// - Mechanics missing from the chess text follow the PRTS 备注 of the base operator (verified 2026-09-28): 号角 S3 overload +// is the second half of the 24 s duration; 圣约送葬人's extra attack consumes no ammo; 夕 S1 splash 1.7; 烛煌 revive stun +// radius 1.7, S3 splash 1.7 and its refilled ammo capped at the skill's ammo; 寒檀 icicles splash 1.5 and cycle left +// row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit at 1.15 (30°/s, hit radius 0.4); 铃兰 T2 is an +// aura (sluggish enemies in range are 脆弱 while sluggish); 缇缇's chain sleep picks the highest-aggro enemy within +// 1.5; 乌尔比安 lands on the anchor tile > the tile beyond > his own tile; 引星棘刺 throws at the farthest forward tile +// when no enemy is in range; 夕 T2 summons only on a deployable target tile. +// - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field. +// SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins. +// - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an +// empty grid) plus their own activation — 塞雷娅 S2 (`autoCast`: an injured ally in the heal area of the skill; her +// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal). 塞雷娅 S1 (自动触发) +// uses the engine's DEFAULT rule with `allies` / `hpAtMost` (about to attack + an ally of the skill area at ≤ half HP: +// the heal replaces that attack). Every other +// kit keeps the data rule (tools/build-data.mjs resolveTrigger, the official 技能策略: DEFAULT = about to attack + an +// enemy in the INITIAL range; SKILL_RANGE for a MANUAL skill's own 技能范围; the class rows — 重装 TAKE_DAMAGE … — for +// every MANUAL skill; AUTO skills never take a class row). +// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor' +// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r}, +// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'crit', +// 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'. + +import { bodyInKeys } from '../../../body.js'; +import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; + +// ---- text-only constants (the official blackboards carry no key for these) -------------------------------------- +/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */ +const HALF_HP = 0.5; +/** + * Abyssal Hunters (【深海猎人】 = character_table groupId `abyssal`; data/chess.json has no groupId, the charIds come from + * docs/research/03-operators.json): 幽灵鲨, 斯卡蒂, 歌蕾蒂娅, 乌尔比安, 归溟幽灵鲨. 浊心斯卡蒂 (char_1012_skadi2) has groupId + * null in the official data — she is NOT an Abyssal Hunter for these talents. + */ +const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']); +/** Never auto-fires (CUSTOM_RANGE with an empty trigger grid): the kit activates the skill itself. */ +const NEVER = Object.freeze({ rule: 'CUSTOM_RANGE', grid: Object.freeze([]) }); +const AURA_IV = 0.25; +const AURA_DUR = 0.5; +// alternate skills (loadouts) — text-only numbers +/** "周围" (8-neighbourhood) as a radius: 隐德来希 S2 血镰, 缇缇 S2 sleep ward, 乌尔比安 S1 anchor, 引星棘刺 S3 area. */ +const RING1 = 1.5; + +// ---- helpers ------------------------------------------------------------------------------------------------------ +const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); +const on = (u) => !!u && u.alive && u.deployed && !u.removed; +/** In the unit's current range: an ally by its tile, an enemy by its body (a huge one's every tile — sim/body.js). */ +const inRange = (unit, x) => !!unit.rangeKeySet && bodyInKeys(x, unit.rangeKeySet); +const talent = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i)?.bb ?? {}; +const talentRec = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i) ?? null; +const traitBb = (chess) => chess?.trait?.bb ?? {}; +const skillGrid = (chess, def) => def?.skill?.rangeGrid ?? chess?.skill?.rangeGrid ?? null; +const maxCharges = (chess, def) => num(def?.skill?.maxCharges, num(chess?.skill?.maxChargeTime, 1)); +/** Blackboard `base_attack_time` (seconds) → batPct of this chess's base attack time. */ +const batPct = (sec, chess) => { const b = num(chess?.stats?.bat, 1) || 1; const v = num(sec) / b; return v ? Math.max(-0.9, v) : 0; }; +/** Drop zero / non-finite entries (a zero mod is noise in the buff list). */ +const mods = (m) => { const o = {}; for (const k of Object.keys(m)) { const v = m[k]; if (typeof v === 'number' && Number.isFinite(v) && v !== 0) o[k] = v; } return o; }; +const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); +const nationOf = (u) => u?.def?.raw?.nationId ?? null; +const inFaction = (u, bond, nations) => !!u?.def && ((u.def.bonds || []).includes(bond) || nations.includes(nationOf(u))); +const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId); +const isOp = (u) => u && u.kind === 'op'; + +// ---- operator loadouts (DESIGN §16) ------------------------------------------------------------------------------- +// A kit function is resolved per SELECTED skill: `bb` / `chess.skill` / `def.skill` are the selected skill's. The default +// skill keeps its `skill` spec; every other selectable skill is a `skills[skillId]` entry. Kit `install` / talents run +// under every skill, so default-skill machinery in them is guarded by the selected skill id (`sid`). +/** Id of the selected skill of a chess record / def. */ +const selectedId = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null; +/** `skills` map whose entries are built only when read (each spec is a function of the SELECTED skill's bb). */ +function lazySkills(builders) { + const o = {}; + for (const [id, build] of Object.entries(builders)) Object.defineProperty(o, id, { enumerable: true, get: build }); + return o; +} +/** Spec kind of an instant skill (charges when the selected skill can charge). */ +const instantKind = (chess, def) => (maxCharges(chess, def) > 1 ? 'charges' : 'instant'); +/** Skill-range targeting override of the selected skill (undefined without a skill range). */ +const skillRange = (chess, def, extra = null) => { + const g = skillGrid(chess, def); + return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined; +}; +/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */ +function crowdAspd(battle, unit, key, aspd, cnt) { + if (!aspd || !(cnt > 0)) return; + whileOn(battle, unit, 0.2, () => { + if (battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).length >= cnt) battle.addBuff(unit, { key, duration: 0.3, mods: { aspd } }); + }); +} +/** "攻击范围内存在元素损伤爆发的敌人时,技力自然恢复速度+X/秒" (modules PRI-Y). */ +function burstSpUp(battle, unit, key, sp) { + if (!(sp > 0)) return; + whileOn(battle, unit, AURA_IV, () => { + if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).some((e) => e.s.flags.burstLock)) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } }); + }); +} + +/** First living deployed ally with the same operator (charId) — used to keep same-name talents from stacking. */ +function leaderOf(battle, unit) { + const cid = unit.def?.charId; + for (const a of battle.allyUnits) if (on(a) && a.def?.charId === cid) return a; + return null; +} + +/** Elemental HP damage (元素伤害): engine type 'elemental' (× elementalTakenMul in the pipeline, never trueTakenMul). */ +function elementHit(battle, src, tgt, amount, tag, element = null) { + if (!tgt || !tgt.alive || !(amount > 0)) return 0; + return battle.dealDamage(src, tgt, { amount, type: 'elemental', element, canDodge: false, tags: ['element', tag] }); +} + +/** Periodic check while the unit is on the field. */ +function whileOn(battle, unit, sec, fn) { + battle.every(sec, () => { if (on(unit)) fn(); }, { owner: unit }); +} + +/** "同类效果取最高" SP-recovery aura: refresh `aura:spRecovery` on matching allies unless a higher one is present. */ +function spAura(battle, unit, value, filter) { + if (!(value > 0)) return; + whileOn(battle, unit, AURA_IV, () => { + for (const a of battle.alliesFor(unit)) { + if (!filter(a)) continue; + const cur = a.findBuff('aura:spRecovery'); + if (cur && num(cur.mods?.spRecoveryFlat) > value + 1e-9) continue; + battle.addBuff(a, { key: 'aura:spRecovery', duration: AURA_DUR, mods: { spRecoveryFlat: value } }); + } + }); +} + +/** Module PRI-X (本源术师): damage vs enemies in an element burst ×damage_scale. */ +function burstDamageUp(battle, unit, mul) { + if (!(mul > 1)) return; + battle.on('hit', (c) => { + if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return; + if (c.target.s.flags.burstLock) c.dmg.mul *= mul; + }, { owner: unit }); +} + +/** Modules PHY-X / GUA-X: heals on allies below hp_ratio ×heal_scale. */ +function lowHpHealUp(battle, unit, tb) { + const mul = num(tb.heal_scale), thr = num(tb.hp_ratio); + if (!(mul > 1) || !(thr > 0)) return; + battle.on('heal', (c) => { + if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally') return; + if (c.target.hpRatio < thr) c.amount *= mul; + }, { owner: unit }); +} + +/** + * Modules "攻击范围扩大" (夕 SPC-X, 白面鸮 RIN-X): the range becomes the SELECTED module's own grid — the data's + * range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + ONE centre tile [0,3] — like tier4's + * 莫斯提马 / 莱恩哈特 / 白面鸮 (integration review: a flat forward +1 added a whole column, 3 tiles, and widened the skill + * ranges too). It replaces the unit's own range, so the DEFAULT trigger's initial range follows; skills with their own + * range keep it. Without such a grid in the data: one extra forward tile [ASSUMED]. + */ +function moduleRangeUp(battle, unit, chess) { + if (!chess?.isGolden || !/攻击范围扩大/.test(String(chess?.trait?.moduleDesc ?? ''))) return; + const m = chess.module; + const rec = m && m.active && m.id ? (chess.modules || []).find((x) => x && x.uniEquipId === m.id) : null; + const g = (rec?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid; + if (g) { + unit.rangeGrid = g.map((p) => [p[0], p[1]]); + battle.refreshRange(unit); + return; + } + // a permanent (persist, never-expiring) rangeExtend: the engine counts it in the initial range of the DEFAULT skill + // trigger ("敌人进入初始攻击范围") as well + battle.addBuff(unit, { key: 't5:moduleRange', mods: { rangeExtend: 1 }, persist: true, allowDead: true }); +} + +/** Permanent (talent) stat buff that survives death/redeploy. */ +function permBuff(battle, unit, key, m) { + const mm = mods(m); + if (Object.keys(mm).length) battle.addBuff(unit, { key, mods: mm, persist: true, allowDead: true }); +} + +/** Enemies inside an absolute-key grid of `grid` offsets around `unit` (best targets first). */ +function enemiesInGrid(battle, unit, grid, n = 0, { ignoreStealth = false } = {}) { + const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], unit.tileR, unit.tileC, unit.dir, 0); + let list; + if (ignoreStealth) { + const set = new Set(keys); + list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && bodyInKeys(e, set)); + } else list = battle.enemiesInKeys(keys, unit, { canHitFly: true }); + sortEnemyTargets(battle, unit, list, null); + return n > 0 && list.length > n ? list.slice(0, n) : list; +} + +export { + HALF_HP, NEVER, AURA_IV, AURA_DUR, RING1, num, on, inRange, talent, talentRec, traitBb, skillGrid, maxCharges, batPct, + mods, dist, inFaction, isAbyssal, isOp, selectedId, lazySkills, instantKind, skillRange, crowdAspd, burstSpUp, + leaderOf, elementHit, whileOn, spAura, burstDamageUp, lowHpHealUp, moduleRangeUp, permBuff, enemiesInGrid, +}; diff --git a/server/sim/content/kits/shared/tier6.js b/server/sim/content/kits/shared/tier6.js new file mode 100644 index 00000000..0276acb3 --- /dev/null +++ b/server/sim/content/kits/shared/tier6.js @@ -0,0 +1,282 @@ +// server/sim/content/kits/shared/tier6.js — helpers and notes of the hand-authored kits (formerly tier6.js; the kits +// live one per file in ../ops/) for every tier-6 chess (阶 VI, DIY slots excluded) plus the +// hidden chess granted by effects (盟约·辅助干员 chess_char_1_15, band Pith "优等生"; 妮芙 chess_char_6_10 is the hidden +// tier-6 entry). `export default { [baseChessId]: (bb, chess, def) => Kit }` (docs/SIM.md §7.2). +// +// Every number comes from the blackboards (skill bb at the chess level: normal Lv4 / elite Lv7; talents from +// `def.talents[i].bb`, elite module upgrades included; module-only talent parts from `chess.talents[index −1]`; trait +// module upgrades from `def.traitBb`). A few numbers exist only in the official text; they are parsed from it +// (e.g. 锏 "总计10次斩击", 艾雅法拉 "5连发", 仇白 "额外攻击2个目标", 海嗣 duration). The profession defaults of +// professions.js (bard aura, phalanx guard, chain bounces, funnel ramp, bombarder aftershocks, geek drain, tactician +// reinforcement …) are kept and built upon. +// +// Simplifications (one line per id; see also the report of the content phase): +// 1_15 盟约·辅助干员 every damage she deals attaches all three elements, neural → burn → apoptosis (see pithst). +// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties); +// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight]; +// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked +// out / withdrawn after the end, she fires no further shell (those already in the air still land); +// "wanted" needs a continuous 8 s stay in some 拉特兰 range; wanted targets are added to her range tiles. +// 6_02 圣聆初雪 "诱导" (ba.attract 无法被阻挡并向目标位置移动) = the engine `attract` status for attract_time: the enemy +// is unblockable and walks (own speed, grid path) to the nearest ground tile around her, then waits there; +// snow lives on the ground tiles of her current range; the frozen "保护目标" token is not used (she just +// freezes herself). +// 6_03 余 the fire wall is her column; "crossing" = source and target on opposite sides of it; bullet clearing = +// cancelling a ranged enemy hit that crosses it; talent 2 given to all ops = each op regenerates itself +// (elite: and gets the ×1.14 arts vs burning targets); 庇护 = phys/arts taken ×(1−0.25). +// 6_04 浊心斯卡蒂 S3 HP drain is non-lethal (like the geek drain); 鼓舞 = flat ATK added after the target's multipliers; +// 海嗣 is inert (no attack) and only extends her aura (and her DEFAULT trigger); it expires after its +// duration and is re-summoned after its redeploy time when DP ≥ its cost (auto-redeploy emulation). +// 6_05 异客 storm zone radius 1.5 tiles (not in data); strikes chain like her trait (4 targets, trait falloff); +// the storm stops when she leaves the field. +// 6_06 佩佩 splash stun via `damaged` (isSplash) hook; module ×1.15 when ≥3 enemies in the splash area. +// 6_07 维娜 "attack enemies blocked by allies in talent range" = those enemies' tiles are added to her range; +// S3 puts a 黄金盟誓 on every free deployable melee tile of her talent-1 area (fences too), each for +// the skill duration (the token's maxDeployCount 1 is its hand limit — tokens.js). +// 6_08 焰影苇草 灼痕 = marker (ATK −20 %) + the 法术脆弱 status (同名效果取最高); applied during S3 it lasts until the +// skill ends (duration = remaining skill time). +// 6_09 塑心 cannot normal attack; each skill charge is one attack; 精神逆构 multiplies every apoptosis gauge fill on +// enemies in her range (`elementHit`; several 塑心 do not stack — the strongest applies). +// 6_10 妮芙 失魂 = 元素伤害 ('elemental') per second while the target's apoptosis burst lasts. +// 6_11 缪尔赛思 流形 copies the nearest allied operator of its player on the field (no hand/bench in battle; same pick +// as content/tokens.js) when its own skill starts; it copies the listed stats + range + damage type (not +// splash/chain/multi-hit shapes); clones do not split again. MLYSS_WTRMAN trigger = DEFAULT or an enemy +// in a (copied) 流形's range. An uncopied 流形 never attacks and its copy skill waits (ready) until someone +// can be copied. "被击败后25秒后自动刷新": one pending respawn at a time, only while she is on the field +// and no 流形 of hers stands (her knock-out cancels it; her redeploy re-summons it as her 援军). +// 6_12 迷迭香 "溅射范围扩大": S2 radius 1.5 (PRTS 溅射半径一览; ×1.3 [ASSUMED] until 0.1.1); 感知稳定 picks among the +// owner's deployed casters (none ⇒ no buff). +// 6_13 新约能天使 bombardment radius 1 tile (not in data). +// 6_14 流明 S3 heals an abnormal ally even at full HP (forced heal); 抵抗 = the engine `resist` status. +// 6_15 仇白 入隙 reads the target's sluggish/bind statuses; module adds 10 % ATK arts per hit. +// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the +// 脆弱 status (同名效果取最高). +// 6_17 耀骑士临光 "上一名部署干员" = the op of the same owner deployed right before her (deploy order). +// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events) flying PRTS's 技能流程 (spread attack@times s, chase 2.0 → 4.0 +// tiles/s; [ASSUMED] the turn rate is not modelled: straight at the target); every drone is out, so she +// makes no normal attack herself, while each drone on its target attacks like a normal drone (her attack +// interval, ATK × its own funnel ramp; neither attack nor skill damage — PRTS 备注); 头狼 stage 2 +// "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once more; S3 releases one more drone, +// mid-skill too). Base drone count 1. +// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end; +// S3 slashes and pulls air units too (PRTS 备注 "可对空"; a 静态刚体 — every drone of the mode — is hit +// but stays put: Battle._displaceable). +// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist. +// +// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills` +// ({ [skillId]: SkillSpec }, built from the SELECTED skill's `bb`: normal Lv4 / elite Lv7; triggers from that skill's +// data). Talent / install hooks written for the default skill check `onDefaultSkill(chess)` (余 闲云隐市 for all, 维娜 +// S3 targets, 焰影苇草 灼痕 until the skill ends, 流明 S3 bullets, 溯光星源 S3 locks, 耀骑士临光 S3 true damage, 缪尔赛思 +// S3 bind, 浊心斯卡蒂 tide). Module choices read the loadout-resolved record (trait.bb, talents, hidden module parts); +// the module-specific code: 浊心斯卡蒂 新生代, 维娜 秩序圣“球”, 焰影苇草 “独属自己的一隅”, 塑心 音乐家的旅程, 缪尔赛思 +// 落叶四季, 仇白 欲雪时, 耀骑士临光 “骑士家族”, 锏 新合同, 纯烬艾雅法拉 想要留下的生命 (生息演算 / 集成战略-only parts of +// the RA / IS modules are not modelled). Alternate-skill simplifications: +// 6_01 S2 the aim (disarmed meanwhile) spends its bullet when it begins; target = a wanted enemy (lowest DEF first). +// 6_02 S1 casts with an enemy in her range (data SEARCH read as "search in range" [ASSUMED]; the engine SEARCH = +// any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles); +// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on a protection point (goal tile) reaching +// max snow, deploy attributes ignored, whose snow is used up (PRTS 备注; until 0.1.3 any standable tile froze, never the +// gate); spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second. +// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile, in the cast's +// tick (PRTS: 0.13 s after the damage), leaders included unless 自缚. +// 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代 +// "30点伤害减免" = +30 effective DEF (physical) / −30 ÷ (1 − RES) before mitigation (arts). +// 6_06 S2 the ASPD stacks last the whole battle; S1 "异常状态时可以释放" = cleanse + cast (tick check). +// 6_07 S2 passive counts the OTHER allies of the talent-1 area. +// 6_08 S2 carriers = operators of her range (ground first, then lowest HP); a fireball shoots an enemy of the carrier's +// range (else hers). +// 6_09 S2 partner = the highest-ATK other operator of her range (re-picked every 0.5 s); S3 picks by base stats. +// 6_11 S1 one DP every interval, the remainder when it ends; module 落叶四季 "援军阻挡的敌人更容易受到我方的攻击" = the +// 流形 variant's taunt_level (+1) on the enemies it blocks. +// 6_05 S2 / 6_12 S3 base_attack_time scales the base attack time (akdata "乘算负数"); see batOf. +// 6_12 S1 the extra arts hit lands on the main + splash victims (not the aftershocks). +// 6_13 S1 "主动关闭" = skill.stop(); S3 the 投递坐标 (the player's pick) goes next to the first enemy entering her range +// (at the latest when S3 starts); delivered = the knocked-out non-ranged operator of hers with the latest +// respawnAt; the bombardment radius is AIRSTRIKE_RADIUS. +// 6_14 S1 the HoT covers the allies within 1.5 tiles of the healed target; S2 "蓄力" = cast with every charge full. +// 6_15 S1 the bind-end explosion radius is 1.2 tiles [ASSUMED]. +// 6_16 S1 bounces are full hits within attack@projectile_range, never on the same enemy twice in a row. +// 6_18 S1 "非移动敌人" = blocked / not walking / immobilised enemies; S2 every drone ramps on its own target. +// 6_20 S2 the barrier is one pool over her range at the cast, absorbing gauge fills (before 元素抗性). +// +// Summons: skill summons (黄金盟誓, “耀阳”) are spawned without a kit so content/tokens.js's token kit applies (true +// damage / appear burst / lifetime); the fallbacks here only run when no token kit exists (`kit.fromTokens`). +// Owner-coupled behaviour of placeable summons (海嗣 aura/lifetime/redeploy, 流形 copy/steal/split/respawn) lives in +// these kits — tokens.js skips it for summoners with a hand-authored kit (its `managed()` rule). +// Engine notes: "extra targets" (蕾缪安 wanted, 维娜 S3) use the engine's extra range keys (`battle.setExtraRange`, +// merged into every range rebuild); 海嗣 / 流形 extend their summoner's DEFAULT trigger through +// `skill.addTriggerRange`; element-type damage skips the `hit` hook, so element (损伤) amplification uses the +// `elementHit` hook (`onElementHit`: × dmg.mul before the gauge fill). + +import { absoluteRangeKeys } from '../../../targeting.js'; +import { COLS, ROWS, PULL_STOP_RADIUS } from '../../../constants.js'; +import { hasHp } from '../../../damage.js'; + +// ------------------------------------------------------------------------------------------------------------------ +// helpers + +const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); + +/** Blackboard value by exact key, else the first key ending with `.key` / `]key` (prefixed official keys). */ +function bv(bb, key, d = 0) { + if (!bb) return d; + const v = bb[key]; + if (typeof v === 'number' && Number.isFinite(v)) return v; + for (const k of Object.keys(bb)) { + if (k.endsWith('.' + key) || k.endsWith(']' + key)) { + const x = bb[k]; + if (typeof x === 'number' && Number.isFinite(x)) return x; + } + } + return d; +} +const tbb = (def, i) => (def && def.talents && def.talents[i] && def.talents[i].bb) || {}; +const tdesc = (def, i) => String((def && def.talents && def.talents[i] && def.talents[i].description) || ''); +/** Module-only (hidden, index −1) talent parts of a chess record, merged (first occurrence wins). */ +function moduleBb(chess) { + const out = {}; + for (const t of (chess && chess.talents) || []) { + if (!t || t.index !== -1 || !t.bb) continue; + for (const [k, v] of Object.entries(t.bb)) if (!(k in out)) out[k] = v; + } + return out; +} +const parseN = (text, re, d) => { const m = String(text ?? '').match(re); return m ? +m[1] : d; }; +const live = (u) => !!u && u.alive && u.deployed && !u.removed && !u.hidden; +const isElite = (e) => !!e && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS'); +const hasBond = (u, id) => !!(u && u.def && Array.isArray(u.def.bonds) && u.def.bonds.includes(id)); +const keyOf = (u) => Math.round(u.y) * COLS + Math.round(u.x); +const opsOf = (battle, ownerId) => battle.allies(ownerId).filter((a) => a.kind === 'op'); +const ANY = Object.freeze({ canHitFly: true }); +const enemiesIn = (battle, unit, keys) => battle.enemiesInKeys(keys || unit.rangeKeys || [], unit, ANY); +const isTok = (t, id, owner) => !!t && t.kind === 'token' && t.defId === id && t.ownerUnit === owner; + +/** + * Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the + * selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default. + * Talent / install hooks written for the default skill check it (they run under every selected skill). + */ +function onDefaultSkill(chess) { + const d = (chess?.skills ?? []).find((s) => s && s.isDefault); + return !d || !chess?.skill || d.skillId === chess.skill.skillId; +} +/** Id of the selected skill (loadout-resolved record, else the def). */ +const selectedSkill = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null; +/** + * Skill blackboard `base_attack_time` → engine batPct, the rule of the AK damage calculator (akdata attributes.js, + * checked against the game): a shortening (v < 0) is a FLAT change of the base attack time in seconds (送葬人 −0.5 on + * 2.3 s ⇒ 1.8 s, the tier1 `batMod` convention), a lengthening (v > 0) scales it (迷迭香 S2 +0.5 ⇒ ×1.5, 佩佩 S3 +0.2 ⇒ + * ×1.2). `mul` = a skill the calculator lists as "攻击间隔缩短,但是是乘算负数" (异客 S2 −0.3 ⇒ ×0.7, 迷迭香 S3 −0.5 ⇒ ×0.5). + */ +function batOf(v, def, mul = false) { + const x = num(v); + if (!x) return 0; + if (x > 0 || mul) return Math.max(-0.9, x); + const bat = num(def?.stats?.bat, 1) || 1; + return Math.max(-0.9, x / bat); +} +/** Spec kind of an instant skill (charges when the data gives it several). */ +const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant'); +/** The selected skill's own range grid (null: the unit's range). */ +const skillGridOf = (def) => (def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null); +/** + * 异常状态 (ba.debuff "包括晕眩、寒冷、冻结等") — the control statuses cleansed by 流明 (抵抗 itself is the engine `resist` + * status: RESIST_STATUSES). Stat debuffs (虚弱/脆弱/…) are not 异常状态. + */ +const ABNORMAL = new Set(['stun', 'freeze', 'cold', 'sleep', 'silence', 'fear', 'attract', 'tremble', 'bind', 'levitate', + 'disarm', 'palsy', 'sluggish', 'slow']); +const hasAbnormal = (u) => u.buffs.some((b) => b.status && ABNORMAL.has(b.status)); +/** Remove every 异常状态 of `u`; returns how many were removed. */ +function cleanseAbnormal(battle, u) { + let n = 0; + for (const b of u.buffs.slice()) if (b.status && ABNORMAL.has(b.status)) { battle.removeStatus(u, b.status); n++; } + if (n) battle.fx('cleanse', { x: u.x, y: u.y, id: u.id }); + return n; +} +const AROUND8 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 1], [-1, -1], [-1, 0], [-1, 1]]); +const N4 = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]); +/** Every offset of the 19×21 field: "攻击范围扩大至整个战场". */ +const WHOLE_FIELD = (() => { + const g = []; + for (let dr = -(ROWS - 1); dr <= ROWS - 1; dr++) for (let dc = -(COLS - 1); dc <= COLS - 1; dc++) g.push([dr, dc]); + return Object.freeze(g); +})(); + +const STATE = new WeakMap(); +/** Per-battle shared state for kits (trackers registered once per battle). */ +function bstate(battle) { + let s = STATE.get(battle); + if (!s) { s = {}; STATE.set(battle, s); } + return s; +} + +/** + * Free deployable tiles of `grid` around `unit` (melee ground tiles unless `ranged`). Skips `battle.isReservedTile` + * (a living unit, or the home tile of a piece that has not deployed yet / waits to redeploy: a summon parked there + * would keep that operator off the field). + */ +function freeTiles(battle, unit, grid, { ranged = false, ground = true } = {}) { + const out = []; + const own = unit.tileR * COLS + unit.tileC; + for (const k of absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) { + if (k === own) continue; + const r = (k / COLS) | 0, c = k % COLS; + if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue; + if (!battle.grid.canStand(r, c, { ranged })) continue; + if (ground && !battle.grid.groundPassable(r, c, true)) continue; + out.push([r, c]); + } + return out; +} + +/** Tile closest to any living enemy (first tile when there is no enemy). */ +function bestTile(battle, tiles) { + if (!tiles.length) return null; + const en = battle.enemies.filter((e) => e.alive && !e.hidden); + if (!en.length) return tiles[0]; + let best = tiles[0], bd = Infinity; + for (const t of tiles) { + let d = Infinity; + for (const e of en) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0])); + if (d < bd - 1e-9) { bd = d; best = t; } + } + return best; +} + +/** + * Element (损伤) amplification: element gauge fills skip the `hit` hook but fire `elementHit` { source, target, dmg } + * before the fill — multiply `dmg.mul` there (one instance, exact burst threshold). `fn(ctx)` returns the factor. + */ +function onElementHit(battle, unit, fn) { + battle.on('elementHit', (ctx) => { + if (!ctx.dmg || ctx.dmg.type !== 'element' || !ctx.target) return; + const f = fn(ctx); + if (Number.isFinite(f) && f > 0 && f !== 1) ctx.dmg.mul *= f; + }, { owner: unit }); +} + +/** Deal `amount` of element `el` (元素损伤) — nothing on a target at 0 HP (a lethal hit's `damaged` hook, see hasHp). */ +const elementDmg = (battle, src, tgt, el, amount, tags = ['skill']) => + (amount > 0 && hasHp(tgt) ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0); + +/** + * Pull `e` with 力度 `force` towards the point / unit `unit` ("向自己中心…拖拽", "拉向目标所在位置"), stopping `stop` tiles + * from it — Battle.pull: the official 力度 − 重量 pull (PRTS 推与拉; user playtest #6 item 14). The default stop is the + * 急停 radius 0.6708 around a pulling unit. A pulling ally is also the pull's `center`, so an enemy it blocks itself + * stays where it is held (as in Battle.pullToFront) — e.g. the 流形 S3 pulse then still stuns it. + */ +function pullToward(battle, unit, e, force, stop = PULL_STOP_RADIUS) { + if (!e || !e.alive || !unit) return 0; + return battle.pull(e, num(force), { to: { x: unit.x, y: unit.y }, center: unit.side === 'ally' ? unit : null, stop }); +} + +/** Stat-aura pulse: short buffs refreshed every `period` s on `targets()`. */ +function aura(battle, unit, period, fn) { + battle.every(period, () => { if (live(unit)) fn(); }, { owner: unit }); +} + +export { + num, bv, tbb, tdesc, moduleBb, parseN, live, isElite, hasBond, keyOf, opsOf, ANY, enemiesIn, isTok, onDefaultSkill, + selectedSkill, batOf, instantKind, skillGridOf, ABNORMAL, hasAbnormal, cleanseAbnormal, AROUND8, N4, WHOLE_FIELD, + bstate, freeTiles, bestTile, onElementHit, elementDmg, pullToward, aura, +}; diff --git a/server/sim/content/kits/tier1.js b/server/sim/content/kits/tier1.js index a24da09e..5370e714 100644 --- a/server/sim/content/kits/tier1.js +++ b/server/sim/content/kits/tier1.js @@ -1,1080 +1,8 @@ -// server/sim/content/kits/tier1.js — Tier 1 operator kits (+ the shared kit helpers used by tier2.js). -// -// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). `bb` = skill blackboard at the chess's -// level (normal Lv4 / elite Lv7), `chess` = raw data/chess.json record (talents with their own bb, trait.bb incl. the -// elite module upgrade, module{active}), `def` = normalised def (def.skill.rangeGrid, def.skill.description …). -// Every number comes from a blackboard; the few constants below exist nowhere in data and are documented. -// Profession defaults (professions.js) are reused: kits only add what the trait text / module says beyond them. -// -// Covered (normal + elite): 1_01 隐现 1_02 角峰 1_03 惊蛰 1_04 深巡 1_05 红豆(H) 1_06 刺玫 1_07 普罗旺斯 1_08 德克萨斯 -// 1_09 跃跃 1_10 古米 1_11 地灵(H) 1_12 艾丝黛尔 1_13 波登可 1_14 格雷伊 1_16 锡人(H) 1_17 深靛 -// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.) 1_15 盟约·辅助干员(H) lives in tier6.js. -// Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's -// `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite); -// talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active -// false) is honoured. Per-skill triggers come from data (the official 技能策略: every MANUAL 重装 skill ⇒ TAKE_DAMAGE — -// but the six of the owner's deliberate deviation, DESIGN §21.29 / tools/build-data.mjs TRIGGER_DEVIATIONS, which are -// DEFAULT —, a MANUAL skill with its own 技能范围 ⇒ SKILL_RANGE, AUTO skills keep their own rule); the few spec rules are -// documented at the skill (冲锋号令 AUTO ⇒ SP_FULL, 花香疗法 heal-type DEFAULT; the two 哨戒铁卫 S2s 深巡 行动能力剥夺 and -// 雷蛇 反击电弧 state the DEFAULT their data carries since that deviation — GitHub issue #4, PR #12). -// Tests: test/content/kits_alt_t1.test.js. -// -// fx kinds emitted (battle.fx(kind, {x, y, …})): aoe {radius, id, skill} · zone {radius, dur, id, skill} · -// counter {id} · crit {id} · dp {n, id} · heal {id} · taunt {id} · summon {id, token} · pull {id} · sonic {radius} · -// shield {id} · overload {id} · takeoff {id} · sleep {id} · buff {id, kind} · reveal {id} · dodge (engine kind). - -import { COLS, CHAIN_RADIUS } from '../../constants.js'; -import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js'; -import { frontOf, offsetTile } from '../../dir.js'; -import { bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js'; - -// ================================================================================================================= -// shared helpers (named exports; content/index.js only merges the default export) - -/** Finite number or `d` (blackboard values may be strings). */ -export const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d)); - -/** Named talent `i` (data index ≥ 0, in order) of a raw chess record. */ -function talentAt(chess, i) { - const list = (chess?.talents ?? []).filter((t) => t && t.index !== -1); - return list[i] ?? null; -} -/** Blackboard of named talent `i` ({} when absent). */ -export const talentBb = (chess, i = 0) => talentAt(chess, i)?.bb ?? {}; -/** Range grid of named talent `i` (or null). */ -export const talentGrid = (chess, i = 0) => talentAt(chess, i)?.rangeGrid ?? null; -/** Merged blackboard of the hidden module talents (data index −1, elite only). */ -export function moduleBb(chess) { - const o = {}; - for (const t of chess?.talents ?? []) if (t && t.index === -1 && t.bb) Object.assign(o, t.bb); - return o; -} -/** Trait blackboard (elites include their module's trait upgrade). */ -export const traitBb = (chess) => chess?.trait?.bb ?? {}; -/** Elite module active (golden chess). */ -export const moduleOn = (chess) => !!(chess?.module && chess.module.active); - -export const up = (u) => !!u && u.alive && u.deployed; -export const posKey = (u) => Math.round(u.y) * COLS + Math.round(u.x); -/** Chebyshev tile distance between two units (a huge enemy: from the nearest tile it occupies — sim/body.js). */ -export const cheb = (a, b) => (b.hitArea ? bodyTileReach(b, Math.round(a.y), Math.round(a.x)) - : a.hitArea ? bodyTileReach(a, Math.round(b.y), Math.round(b.x)) - : Math.max(Math.abs(Math.round(a.y) - Math.round(b.y)), Math.abs(Math.round(a.x) - Math.round(b.x)))); -/** Normal attack hit on its primary target (no splash, no chain jump). */ -export const isMainHit = (dmg) => !!dmg && dmg.isAttack && !dmg.isSplash && !(dmg.tags && dmg.tags.includes('chain')); -/** Damage ctx caused by an enemy's attack. */ -export const byEnemyAttack = (ctx) => !!ctx.source && ctx.source.side === 'enemy' && !!ctx.dmg && ctx.dmg.isAttack; -/** - * `damaged` ctx of a damage that removed HP and can give 受击回复 SP — the engine's rule (damage.js applyHpLoss: not a 流失 - * (`noSp`, Battle.loseHp), not an element 损伤), whatever its source: an attack, a zone, the 无来源 源石溶剂 tick. - */ -export const hurtSpDamage = (ctx) => !!ctx.dmg && !ctx.dmg.noSp && ctx.type !== 'element' && ctx.amount > 0; -/** A timed skill is running (no SP may be gained). */ -export const skillBusy = (u) => !!(u.skill && u.skill.active && u.skill.isTimed); -/** Give SP unless a timed skill is running (AK: no SP gain during a skill). */ -export function giveSp(u, n, reason = 'talent') { - if (!u || !u.skill || u.skill.noSkill || skillBusy(u) || !(n > 0)) return 0; - return u.skill.gainSp(n, reason); -} - -/** 'hit' handler for damage dealt BY `unit` (pre-mitigation; mutate ctx.dmg). */ -export function onHitBy(battle, unit, fn, priority = 0) { - return battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.target && ctx.target.side === 'enemy') fn(ctx); }, { owner: unit, priority }); -} -/** 'hit' handler for damage dealt TO `unit`. */ -export function onHitOn(battle, unit, fn, priority = 0) { - return battle.on('hit', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority }); -} -/** 'damaged' handler for damage dealt TO `unit`. */ -export function onDamagedOn(battle, unit, fn, priority = 0) { - return battle.on('damaged', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority }); -} - -/** Targetable enemies inside `grid` (relative to the unit; null ⇒ current range), best targets first. */ -export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canHitFly = true, groundOnly = false } = {}) { - const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys; - const list = battle.enemiesInKeys(keys, unit, { canHitFly, groundOnly }); - sortEnemyTargets(battle, unit, list, priority ?? unit.profile?.priority ?? null); - return n > 0 && list.length > n ? list.slice(0, n) : list; -} -/** - * Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range) — never a 孤立 unit - * (炎佑: Battle.allySelectable), as Battle.alliesInGrid. - */ -export function alliesInGridOf(battle, unit, grid = null) { - const set = grid ? new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : (unit.rangeKeySet || new Set(unit.rangeKeys || [])); - return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && battle.allySelectable(a, unit)); -} -/** Any targetable enemy in the unit's current range. */ -export const enemyInRange = (battle, unit) => battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length > 0; - -/** Run `fn` once per battle for `key` (battle-wide handlers shared by several units). */ -const ONCE = new WeakMap(); -export function once(battle, key, fn) { - let s = ONCE.get(battle); - if (!s) ONCE.set(battle, (s = new Set())); - if (s.has(key)) return; - s.add(key); - fn(); -} - -/** Permanent talent stat buff (survives death / redeploy). */ -export function statBuff(battle, unit, key, mods) { - const m = {}; - for (const [k, v] of Object.entries(mods)) if (Number.isFinite(v) && v !== 0 && !(k.endsWith('Mul') && v === 1)) m[k] = v; - if (Object.keys(m).length) battle.addBuff(unit, { key, mods: m, persist: true, allowDead: true, tags: ['talent'] }); -} - -/** Keep buff `key` on `unit` exactly while cond() holds (checked every tick). `mods` may be a function. */ -export function toggleBuff(battle, unit, key, cond, mods, extra = {}) { - const check = () => { - const want = up(unit) && !!cond(); - const has = unit.findBuff(key); - if (want && !has) battle.addBuff(unit, { key, mods: typeof mods === 'function' ? mods() : mods, tags: ['talent'], ...extra }); - else if (!want && has) battle.removeBuff(unit, key); - }; - battle.on('tick', check, { owner: unit }); - battle.on('battleStart', check, { owner: unit }); - battle.on('deploy', (ctx) => { if (ctx.unit === unit && battle.started) check(); }, { owner: unit }); -} - -/** - * Aura while `unit` is on the field: every `interval` s, allies passing `select(a)` get buff `key` for slightly longer - * than the interval. Several sources of the same aura never stack: the strongest `value` wins. - */ -export function installAura(battle, unit, { key, select, mods, value = 0, interval = 0.5 }) { - battle.every(interval, () => { - if (!up(unit)) return; - for (const a of battle.alliesFor(unit)) { - if (!select(a)) continue; - const cur = a.findBuff(key); - if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue; - battle.addBuff(a, { key, duration: interval + 0.1, mods: typeof mods === 'function' ? mods(a) : mods, source: unit, data: { v: value }, tags: ['aura'] }); - } - }, { owner: unit, immediate: true }); -} - -/** Extra natural SP recovery (+perSec) while cond() holds (hooks the per-tick 'time' SP gain). */ -export function spTimeBonus(battle, unit, perSec, cond) { - if (!(perSec > 0)) return; - battle.on('spGain', (ctx) => { - if (ctx.unit === unit && ctx.reason === 'time' && cond()) ctx.amount += perSec * battle.dt; - }, { owner: unit }); -} - -/** Module "攻击范围内敌人的隐匿效果失效": stealthed enemies inside the current range are revealed. */ -export function installReveal(battle, unit, interval = 0.2) { - battle.every(interval, () => { - if (!up(unit) || !unit.rangeKeySet) return; - for (const e of battle.enemies) { - if (!e.alive || e.hidden || !e.s.flags.stealth || !bodyInKeys(e, unit.rangeKeySet)) continue; - const was = !!e.s.flags.reveal; - if (battle.applyStatus(e, 'reveal', { duration: interval + 0.1, source: unit }) && !was) battle.fx('reveal', { x: e.x, y: e.y, id: e.id }); - } - }, { owner: unit }); -} - -/** - * Lingering area (spores, alchemy units): `onPulse(battle)` runs immediately and then every `interval` s, - * `round(duration / interval)` times in total. Not owned by the caster: it keeps working after the caster falls. - */ -export function makeZone(battle, caster, { x, y, radius, duration, interval = 1, skill, onPulse, onEnd }) { - const total = Math.max(1, Math.round(duration / interval)); - let n = 0; - battle.fx('zone', { x, y, radius, dur: duration, id: caster.id, skill }); - return battle.every(interval, (b, sc) => { - onPulse(b); - if (++n >= total) { sc.cancel(); if (onEnd) onEnd(b); } - }, { immediate: true }); -} - -/** - * A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out - * operator lying there, not the home tile of a board unit that has not deployed yet / waits to redeploy — a summon - * there would stop that operator from redeploying until it leaves). - */ -export function summonTileFree(battle, r, c) { - return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c); -} - -/** First free tile around `unit` (Chebyshev ring 1, front first — offsets rotated by its direction) where `ok(r, c)` holds. */ -export function freeTileAround(battle, unit, ok) { - const order = [[0, 1], [1, 1], [-1, 1], [1, 0], [-1, 0], [0, -1], [1, -1], [-1, -1]]; - for (const [dr, dc] of order) { - const [r, c] = offsetTile(unit.tileR, unit.tileC, dr, dc, unit.dir); - if (!summonTileFree(battle, r, c)) continue; - if (ok(r, c)) return [r, c]; - } - return null; -} - -/** Skill spec kind for an instant skill with (possible) charges. */ -export const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant'); - -/** - * SkillRecord `skillId` of a raw chess record (DESIGN §16 operator loadouts): the selected skill (`chess.skill`) or one of - * `chess.skills[]` (every skill of the chess at its level — bb, duration, rangeGrid, trigger). Null when absent. - * A kit's `skills` map builds every entry from its own record, so each spec is right whichever skill is selected. - */ -export function skillRec(chess, skillId) { - if (chess?.skill && (chess.skill.skillId ?? chess.skill.id) === skillId) return chess.skill; - return (chess?.skills ?? []).find((s) => s && s.skillId === skillId) ?? null; -} -/** Blackboard of skill `skillId` of a raw chess record ({} when absent). */ -export const skillBbOf = (chess, skillId) => skillRec(chess, skillId)?.bb ?? {}; - -/** - * Skill blackboard `base_attack_time` → engine batPct of this chess's base attack time. The value is a FLAT change of - * the base attack time in seconds (AK attribute ADDITION: 送葬人 −0.5 on 2.3 s "少量缩短(-0.5)" ⇒ 1.8 s, 红豆 +0.5 - * "略微增大(+0.5)" — the same convention as tier4/tier5), except a positive value described as a shortening, which is - * the new interval ratio (深靛 0.7 "攻击间隔略微缩短" ⇒ ×0.7). The convention is per skill in AK: skills PRTS lists as - * a ratio (雷蛇 反击电弧 "+70%", 古米 食粮烹制 "+130%", 深靛 灯塔守卫者 "-80%") use batPct = value directly, not this. - */ -export function batMod(v, chess = null, desc = '') { - const x = num(v, 0); - if (!x) return 0; - if (x > 0 && x < 1 && /间隔[^,。;]*缩短/.test(desc)) return x - 1; - const bat = num(chess?.stats?.bat, 1) || 1; - return Math.max(-0.9, x / bat); -} - -// ================================================================================================================= -// tier-local constants that exist nowhere in data - -/** Radius (tiles) of 波登可's spore cloud. No blackboard key; PRTS 波登可 备注 "孢子群范围半径为0.9,可对空". */ -const SPORE_RADIUS = 0.9; -/** "周围8格" = Chebyshev ring 1 ⇒ Euclidean radius covering the 8 neighbours. */ -export const RING1 = 1.5; -/** 普罗旺斯 杀戮嗅觉 "普通攻击不再以生命值高于80%的敌人作为目标" — the 80 % exists only in the skill text. */ -const PROVE_S2_MAX_HP = 0.8; - -// ================================================================================================================= -// kit builders shared by two chess ids - -/** - * 锡人 “大拉里” (chess_char_1_16 hidden and chess_char_2_19): throw an alchemy unit at the current target — - * for projectile_delay_time s, ground enemies within projectile_range tiles take atk_scale × ATK arts per second, - * allies there recover hp_recovery_per_sec_ratio × ATK per second. Elite: 凋敝魂灵 (skill@damage_scale: damage-over- - * time taken by ground enemies inside a unit ×1.2) and the module's +sp_recovery_per_sec SP while a unit exists. - */ -export function tinmanKit(bb, chess, def) { - const dur = num(bb.projectile_delay_time, 10); - const radius = num(bb.projectile_range, 1.5); - const dmgScale = num(bb.atk_scale); - const healRatio = num(bb.hp_recovery_per_sec_ratio); - const wither = num(talentBb(chess, 1)['skill@damage_scale'], 1); - const spBonus = num(moduleBb(chess).sp_recovery_per_sec); - const witherKey = 'tinman:wither'; - return { - skill: { - kind: instantKind(def), - onStart({ battle, unit }) { - const tgt = enemiesInGrid(battle, unit, null, { n: 1, groundOnly: true })[0] ?? enemiesInGrid(battle, unit, null, { n: 1 })[0]; - const x = tgt ? tgt.x : unit.x + unit.fwd[1], y = tgt ? tgt.y : unit.y + unit.fwd[0]; - const atk = unit.s.atk; - unit.mem.tinZones = (unit.mem.tinZones ?? 0) + 1; - makeZone(battle, unit, { - x, y, radius, duration: dur, skill: 'tinman', onPulse(b) { - for (const e of b.foesInRadius(x, y, radius)) { - if (e.isFlying || e.s.flags.untargetable) continue; - if (wither > 1) b.addBuff(e, { key: witherKey, duration: 1.05, data: { mul: wither }, source: unit }); - b.dealDamage(unit, e, { amount: atk * dmgScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] }); - } - if (healRatio > 0) for (const a of b.alliesInRadius(x, y, radius, null)) if (a.hp < a.s.maxHp) b.heal(unit, a, atk * healRatio, { tags: ['zone'] }); - }, - onEnd() { unit.mem.tinZones = Math.max(0, (unit.mem.tinZones ?? 1) - 1); }, - }); - }, - }, - talents: [{ install(battle, unit) { - if (wither > 1) { - // "持续伤害" = damage over time only: 'dot'-tagged ticks and the per-second ticks of a necrosis / apoptosis - // burst — never instantaneous bursts (burn/neural bursts, skill/summon bursts are also tagged 'burst') - once(battle, witherKey, () => battle.on('hit', (ctx) => { - if (!ctx.target || ctx.target.side !== 'enemy') return; - const tags = ctx.dmg.tags || []; - if (!(tags.includes('dot') || tags.includes('necrosis') || tags.includes('apoptosis'))) return; - const w = ctx.target.findBuff(witherKey); - if (w) ctx.dmg.amount *= w.data?.mul ?? 1; - })); - } - spTimeBonus(battle, unit, spBonus, () => (unit.mem.tinZones ?? 0) > 0); - } }], - }; -} - -// ================================================================================================================= -// kits - -export default { - // --------------------------------------------------------------------------------------------------------------- - // 1_01 隐现 “解决麻烦”: ammo (attack@trigger_time) ATK+atk, base attack time +base_attack_time s, prefers ranged - // enemies, taunt −1. 火力支援: after `duration` s on the field own ammo +self_ammo (from the next cast); elite: another - // random 【拉特兰】 op's ammo +ally_ammo while that deployment lasts (one grant per source). - // Alternate S1 “不惹麻烦” (AUTO, attack SP): attack@trigger_time rounds of attack@atk_scale × ATK physical (an ammo - // skill, so 火力支援's larger magazine applies to it too). - chess_char_1_01_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const dur = num(t.duration), selfAmmo = num(t.self_ammo), allyAmmo = num(t.ally_ammo); - const s1 = skillBbOf(chess, 'skchr_inside_1'); - return { - skill: { - kind: 'ammo', ammo: num(bb['attack@trigger_time'], 1), - mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess, def?.skill?.description ?? chess?.skill?.desc), taunt: -1 }, - targeting: { priority: 'ranged' }, - }, - skills: { - skchr_inside_1: { kind: 'ammo', ammo: Math.max(1, Math.floor(num(s1['attack@trigger_time'], 4))), attack: { atkScale: num(s1['attack@atk_scale'], 1) } }, - }, - talents: [{ install(battle, unit) { - const onField = () => up(unit) && battle.time - unit.deployedAt + 1e-9 >= dur; - battle.on('skillStart', ({ unit: u, skill }) => { - if (u === unit && skill.kind === 'ammo' && onField() && selfAmmo > 0) skill.addAmmo(selfAmmo); - }, { owner: unit }); - if (!(allyAmmo > 0)) return; - // elite: once per deployment, `duration` s after it, one random other 【拉特兰】 operator with an ammo skill gets - // +ally_ammo for as long as this deployment lasts (one grant per source — redeploying never stacks it) - battle.on('deploy', ({ unit: u }) => { - if (u !== unit) return; - const seq = unit.deploySeq; - battle.after(dur, () => { - // (the larger magazine applies from the next activation: a running skill keeps its rounds) - if (!up(unit) || unit.deploySeq !== seq) return; - const pool = battle.allyUnits.filter((a) => a !== unit && a.kind === 'op' && a.ownerId === unit.ownerId && a.alive - && (a.def.bonds || []).includes('lateranoShip') && a.skill && a.skill.kind === 'ammo'); - const pick = battle.rng.pick(pool); - if (!pick) return; - (pick.mem.insiderAmmo ??= new Map()).set(unit.id, { src: unit, seq, n: allyAmmo }); - battle.fx('buff', { x: pick.x, y: pick.y, id: pick.id, kind: 'ammo' }); - }, { owner: unit }); - }, { owner: unit }); - once(battle, 'insider:bonusAmmo', () => battle.on('skillStart', ({ unit: u, skill }) => { - const m = u.mem.insiderAmmo; - if (!m || skill.kind !== 'ammo') return; - let n = 0; - for (const [k, g] of m) { - if (up(g.src) && g.src.deploySeq === g.seq) n += g.n; - else m.delete(k); - } - if (n > 0) skill.addAmmo(n); - })); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_02 角峰 抗寒体质: HP +max_hp, DEF +def, RES ×(1+magic_resistance). 雪原卫士: RES +magic_resistance (flat). - // Alternate S1 体能强化 (重装 ⇒ TAKE_DAMAGE trigger from data): HP +max_hp, +hp_recovery_per_sec HP per second - // (the 生命回复速度 attribute). Elite module PRO-Y (block 4) is a stat of the module (attr blockCnt). - chess_char_1_02_a: (bb, chess) => { - const s1 = skillBbOf(chess, 'skchr_yak_1'); - return { - skill: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def), resMul: 1 + num(bb.magic_resistance) } }, - skills: { skchr_yak_1: { kind: 'duration', mods: { hpPct: num(s1.max_hp), hpRegen: num(s1.hp_recovery_per_sec) } } }, - talents: [{ install(battle, unit) { statBuff(battle, unit, 'talent:yak', { resFlat: num(talentBb(chess, 0).magic_resistance) }); } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_03 惊蛰 初雷: ATK +atk, chain jumps lose no damage. 通流无阻: vs unblocked enemies ATK ×atk_scale (every jump). - // Alternate S1 攻击力强化·γ型: ATK +atk (the chain keeps its falloff). Elite module CHA-X (4 jumps, −10 %, 0.8 s 停顿) - // is the chain profile of the module trait (professions.js TUNE.chain). - chess_char_1_03_a: (bb, chess) => { - const scale = num(talentBb(chess, 0).atk_scale, 1); - return { - skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: {} }, - skills: { 'skcom_atk_up[3]': { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, 'skcom_atk_up[3]').atk) } } }, - talents: [{ install(battle, unit) { - if (scale !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && !target.blockedBy) dmg.amount *= scale; }); - } }], - install(battle, unit) { - // the chain shape (count / sluggish, elite module included) comes from the resolved chain profile - const ch = unit.profile.chain || { count: num(traitBb(chess)['attack@max_target'], 3), radius: CHAIN_RADIUS, sluggish: num(traitBb(chess)['attack@sluggish'], 0.5) }; - if (unit.skill && unit.skill.spec.attack) unit.skill.spec.attack.chain = { ...ch, falloff: 0 }; - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_04 深巡 行动能力剥夺: longer line range (skill grid), ATK +atk, ASPD +attack_speed, fin darts pierce - // attack@max_target enemies on the line and cause attack@sluggish s of 停顿. - // 技能策略 → DEFAULT (PR #12; DESIGN §21.29): the official 下半 class row hands every MANUAL 重装 skill TAKE_DAMAGE, - // which makes a 2-2 ranged 哨戒铁卫 wait until something hits her — in practice until she blocks (GitHub issue #4). By - // the owner's deliberate deviation from that row (2026-10-03, community feedback) this offensive ranged skill takes - // the basic strategy (SP ready + about to attack + an enemy inside the initial range); her data says DEFAULT too - // (rawRule keeps the official TAKE_DAMAGE). Only the rule changes: spCost / initSp / spType still come from data. - // 细胞活性抑制剂: attacks inflict `damage` arts per `interval` s for `duration` s (damage_seamonster vs 【海怪】). - // Elite module (SPT-X): stealth of enemies inside the range is cancelled. - // Alternate S1 侵袭破坏应对 (重装 ⇒ TAKE_DAMAGE trigger from data): ATK +atk, DEF +def. - chess_char_1_04_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const s1 = skillBbOf(chess, 'skchr_udflow_1'); - return { - skill: { - trigger: 'DEFAULT', - kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, - targeting: { rangeGrid: def?.skill?.rangeGrid ?? null, maxTargets: num(bb['attack@max_target'], 1) }, - attack: { onHitStatus: { key: 'sluggish', duration: num(bb['attack@sluggish'], 1) } }, - }, - skills: { skchr_udflow_1: { kind: 'duration', mods: { atkPct: num(s1.atk), defPct: num(s1.def) } } }, - talents: [{ install(battle, unit) { - const dur = num(t.duration), iv = num(t.interval, 1); - if (!(dur > 0) || !(num(t.damage) > 0)) return; - battle.on('damaged', (ctx) => { - if (ctx.source !== unit || !ctx.dmg?.isAttack || ctx.target.side !== 'enemy' || !ctx.target.alive) return; - const sea = (ctx.target.def?.tags || []).includes('seamonster'); - const amount = sea ? num(t.damage_seamonster, num(t.damage) * 2) : num(t.damage); - battle.addBuff(ctx.target, { - key: `udflow:dot:${unit.id}`, duration: dur, interval: iv, refresh: 'extend', source: unit, visible: false, - onTick: ({ battle: b, unit: e }) => b.dealDamage(unit, e, { amount, type: 'arts', canDodge: false, tags: ['dot'] }), - }); - }, { owner: unit }); - } }], - install(battle, unit) { if (moduleOn(chess) && chess.isGolden) installReveal(battle, unit); }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_05 红豆 (hidden) 槌音: base attack time +base_attack_time s, ATK +atk. 蛮力穿刺: each attack prob1 (prob2 while the skill - // runs) for ATK +atk on that attack. Elite module (CHG-Y): vs enemies below hp_ratio HP, ATK ×atk_scale. - chess_char_1_05_a: (bb, chess) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - return { - skill: { kind: 'duration', mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) } }, - talents: [{ install(battle, unit) { - const key = 'vigna:proc'; - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit) return; - const p = unit.skill?.active ? num(t.prob2) : num(t.prob1); - if (p > 0 && battle.rng.chance(p)) { - battle.addBuff(unit, { key, duration: 0.1, mods: { atkPct: num(t.atk) } }); - battle.fx('crit', { x: unit.x, y: unit.y, id: unit.id }); - } - }, { owner: unit }); - // melee hits resolve synchronously inside the attack: the one-attack buff is dropped right after it - battle.on('attack', (ctx) => { if (ctx.attacker === unit && unit.findBuff(key)) battle.removeBuff(unit, key); }, { owner: unit }); - if (tb.hp_ratio != null && num(tb.atk_scale, 1) !== 1) { - onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.hpRatio < num(tb.hp_ratio)) dmg.amount *= num(tb.atk_scale, 1); }); - } - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_06 刺玫 荆藤庇荫: ATK +atk; the ally with the highest max HP in range gets taunt +taunt_level; whenever that ally is - // attacked, 刺玫 deals atk_scale × ATK arts to the attacker, and her trait heal (scale × damage) only goes to that ally. - // 土壤基肥改良: the highest-max-HP ally in range receives heal_scale × healing ("受到的治疗效果提升") — never its natural / - // skill HP regeneration (the 生命回复速度 attribute is "不受治疗加成影响": 宴 分神, 角峰 体能强化; PRTS). - // Alternate S1 战术咏唱·γ型: ASPD +attack_speed (the trait heal keeps going to the most injured ally in range). - // Elite module INC-X (heal 60 % of the damage) is the incantation profile of the module trait (TUNE.incantationmedic). - chess_char_1_06_a: (bb, chess) => { - const hs = num(talentBb(chess, 0).heal_scale, 1); - const tauntKey = 'vendla:taunt'; - // the protégé only exists while 荆藤庇荫 runs (mem.protege is set by its onStart only) - const protege = (u) => (u.skill?.active && up(u.mem.protege) ? u.mem.protege : null); - return { - skills: { 'skcom_magic_rage[3]': { kind: 'duration', mods: { aspd: num(skillBbOf(chess, 'skcom_magic_rage[3]').attack_speed) } } }, - trait: { - // 咒愈师 trait: EVERY damage she deals heals an ally for 50 % of it (professions.js `installIncantation`, - // buff_template_data `vendla_tr` = ON_AFTER_OUTPUT_DAMAGE) — while 荆藤庇荫 runs her S2 says "仅对该角色触发刺玫 - // 特性", so her protégé is the target then; otherwise it is the lowest-HP ally in range. - install(battle, u) { - battle.on('damaged', (c) => { - const t = c.target; - if (c.source !== u || !u.alive || !t || t.side !== 'enemy' || !(c.amount > 0)) return; - if (c.type === 'element' || c.type === 'elemental') return; - const ally = (c.dmg && c.dmg.traitAlly) || protege(u) || battle.lowestHpAllyInRange(u); - if (ally) battle.heal(u, ally, c.amount * (u.profile.healRatio ?? 0.5), { tags: ['incantation'] }); - }, { owner: u }); - }, - }, - skill: { - kind: 'duration', mods: { atkPct: num(bb.atk) }, - onStart({ battle, unit, skill }) { - const cands = alliesInGridOf(battle, unit).sort((a, b) => b.s.maxHp - a.s.maxHp || a.deploySeq - b.deploySeq); - const p = cands[0] ?? null; - unit.mem.protege = p; - if (!p) return; - battle.addBuff(p, { key: tauntKey, duration: skill.duration + 0.1, mods: { taunt: num(bb.taunt_level, 1) }, visible: true, source: unit }); - battle.fx('taunt', { x: p.x, y: p.y, id: p.id }); - }, - onEnd({ battle, unit }) { - if (unit.mem.protege) battle.removeBuff(unit.mem.protege, tauntKey); - unit.mem.protege = null; - }, - }, - talents: [{ install(battle, unit) { - battle.on('damaged', (ctx) => { - const p = protege(unit); - if (!p || ctx.target !== p || !byEnemyAttack(ctx) || !ctx.source.alive || !unit.canAct) return; - // "并仅对该角色触发刺玫特性": this counter damage is healed by the trait (install above) for the protégé — the - // damage instance names her, so no separate heal here (it would double) - battle.dealDamage(unit, ctx.source, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, canDodge: false, tags: ['counter'], traitAlly: p }); - battle.fx('counter', { x: ctx.source.x, y: ctx.source.y, id: unit.id }); - }, { owner: unit }); - if (hs !== 1) { - let cacheT = -1, top = null; - battle.on('heal', (ctx) => { - if (!up(unit) || ctx.opts?.regen) return; - if (cacheT !== battle.time) { - cacheT = battle.time; - top = alliesInGridOf(battle, unit).sort((a, b) => b.s.maxHp - a.s.maxHp || a.deploySeq - b.deploySeq)[0] ?? null; - } - if (ctx.target === top) ctx.amount *= hs; - }, { owner: unit }); - } - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_07 普罗旺斯 狼眼 (passive): per hp_ratio_drop of HP the target has lost, ATK +atk_scale_up against it — CONTINUOUS, - // not in 20 % steps (PRTS note: "并非以每20%为固定节点 … 攻击力倍率提升至[100%+(100%-目标生命比例)×加成量×5]", i.e. - // ×(1 + lost / hp_ratio_drop × atk_scale_up)). - // 狩猎箭头: each attack prob (prob2 when the enemy is on the tile right in front) for ATK ×atk_scale. - // 狼眼 is an ATK increase ⇒ it scales her (ATK-based) attacks only, never fixed-value damage she sources - // (叙拉古 5000+50·L true proc, fixed item/DoT ticks…). - // Alternate S2 杀戮嗅觉: ATK +atk, but normal attacks never target an enemy above 80 % HP (with only such enemies in - // range she holds her fire). Her attacks stay normal attacks. (狼眼 is S1 only: absent when S2 is carried.) - // Elite module ARC-X only changes stats (ATK/DEF, faster redeploy). - chess_char_1_07_a: (bb, chess) => { - const t = talentBb(chess, 0); - const drop = num(bb.hp_ratio_drop), upPer = num(bb.atk_scale_up); - const S2 = 'skchr_prove_2'; - const huntable = (e) => e.hpRatio <= PROVE_S2_MAX_HP + 1e-9; - const s2On = (u) => !!(u.skill && u.skill.active && u.skill.id === S2); - return { - skill: { kind: 'passive' }, - skills: { [S2]: { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, S2).atk) } } }, - trait: { - canAttack: (battle, u) => !s2On(u) || battle.enemiesInKeys(u.rangeKeys, u, u.profile).some(huntable), - }, - install(battle, unit) { - if (unit.skill?.id !== S2) return; - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !s2On(unit) || ctx.targets.every(huntable)) return; - const prof = ctx.profile || unit.profile; - const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof).filter(huntable); - sortEnemyTargets(battle, unit, cands, prof.priority); - ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length)); - }, { owner: unit }); - }, - talents: [{ install(battle, unit) { - onHitBy(battle, unit, ({ target, dmg }) => { - if (!dmg.isAttack) return; - if (drop > 0 && upPer > 0) { - const lost = Math.max(0, Math.min(1, 1 - target.hpRatio)); - if (lost > 0) dmg.amount *= 1 + (lost / drop) * upPer; - } - if (!isMainHit(dmg)) return; - const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir); - const front = bodyOnTile(target, fr, fc); - const p = front ? num(t.prob2) : num(t.prob); - if (p > 0 && battle.rng.chance(p)) { - dmg.amount *= num(t.atk_scale, 1); - battle.fx('crit', { x: target.x, y: target.y, id: unit.id }); - } - }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_08 德克萨斯 剑雨: +cost DP; every enemy around (skill grid) takes two hits of atk_scale × ATK arts and is - // stunned `stun` s — air units too (PRTS 备注 "※可对空"). 战术快递: +cost initial DP. (Elite hidden runtime_cost −4 - // "首次部署时部署费用-4": the initial deployment is free in battle ⇒ no effect.) - // Alternate S1 冲锋号令·γ型 (AUTO, no target): +cost DP at once. An AUTO skill keeps its normal rule in this mode - // (research 03 §1.4: only MANUAL skills are converted by the trigger table) ⇒ it fires as soon as SP is full. - chess_char_1_08_a: (bb, chess, def) => ({ - skills: { - 'skcom_charge_cost[3]': { - kind: 'instant', trigger: 'SP_FULL', - onStart({ battle, unit }) { - const n = num(skillBbOf(chess, 'skcom_charge_cost[3]').cost); - battle.addDp(unit.ownerId, n); - battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id }); - }, - }, - }, - skill: { - kind: 'instant', - onStart({ battle, unit }) { - battle.addDp(unit.ownerId, num(bb.cost)); - battle.fx('dp', { x: unit.x, y: unit.y, n: num(bb.cost), id: unit.id }); - const grid = def?.skill?.rangeGrid; - const foes = grid ? enemiesInGrid(battle, unit, grid) : battle.foesInRadius(unit.x, unit.y, RING1).filter((e) => !e.s.flags.untargetable); - battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'swordRain' }); - for (const e of foes) { - for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill'] }); - if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit }); - } - }, - }, - talents: [{ install(battle, unit) { - const n = num(talentBb(chess, 0).cost); - if (n > 0) battle.on('battleStart', () => battle.addDp(unit.ownerId, n), { owner: unit }); - } }], - }), - - // --------------------------------------------------------------------------------------------------------------- - // 1_09 跃跃 (回环射手: every attack is a boomerang out to the target and back — she attacks again only once it is - // caught, professions.js loopshooter / ai.js throwBoomerang). 乐趣加倍: ATK +atk, each attack throws `cnt` - // boomerangs at the target (cnt hits); they fly the same path, so the flight carries cnt hits and all of them are - // back together ("必须回收全部回旋投掷物才可以进行下一次攻击"). 戏耍随心: prob per hit (each boomerang on its own) for - // ATK ×atk_scale. Elite module (LPS-X, trait atk_scale): vs enemies on the 8 surrounding tiles ATK ×atk_scale. - // Alternate S1 强力击·β型 (AUTO, attack SP): the next attack at atk_scale × ATK. - chess_char_1_09_a: (bb, chess) => { - const t = talentBb(chess, 0); - const near = num(traitBb(chess).atk_scale, 1); - return { - skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: { hits: Math.max(1, Math.floor(num(bb.cnt, 1))) } }, - skills: { skchr_caper_1: { kind: 'instant', attack: { atkScale: num(skillBbOf(chess, 'skchr_caper_1').atk_scale, 1) } } }, - talents: [{ install(battle, unit) { - onHitBy(battle, unit, ({ target, dmg }) => { - if (!dmg.isAttack) return; - if (near !== 1 && cheb(unit, target) <= 1) dmg.amount *= near; - if (!dmg.isSplash && num(t.prob) > 0 && battle.rng.chance(num(t.prob))) { - dmg.amount *= num(t.atk_scale, 1); - battle.fx('crit', { x: target.x, y: target.y, id: unit.id }); - } - }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_10 古米 备用军粮 (自动触发, ct charges — an AUTO skill: no 技能策略 applies, it keeps its own rule; PRTS 备注 "此技能 - // 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式(普通攻击改为治疗技能范围内的一名友方单位),直至古米 - // 完成一次普通攻击的治疗"): cast as soon as a healable ally of the skill range (x-4, herself included) is injured — - // no enemy needed (user playtest #6: it waited until she engaged) — then her next normal attack heals the most - // injured ally of the skill range for heal_scale × ATK instead of hitting an enemy; she makes no normal attack until - // that heal is done. Elite module (GUA-X): targets below hp_ratio HP get ×heal_scale more. - // 平底锅专精: each attack prob for ATK ×atk_scale and a `stun` s stun (only when that hit lands). - // Alternate S2 食粮烹制 (PRTS notes: 10 s 缴械 at the start, the real duration is disarm + 30 s, attacks become heals - // with the attack interval +130 %): cooking for `disarm` s — no attacks, DEF +def; then for the skill's `duration` s - // she heals the most injured ally inside the skill grid (x-4) for ATK per attack, ATK +atk, base attack time - // ×(1 + base_attack_time). The GUA-X heal bonus (below hp_ratio: ×heal_scale) applies to these heals too. - // Trigger: TAKE_DAMAGE (data: the 重装 strategy "不受技能范围影响,受到伤害时释放技能"). - chess_char_1_10_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - const S2 = 'skchr_sunbr_2'; - const r2 = skillRec(chess, S2); - const b2 = r2?.bb ?? {}; - const cook = Math.max(0, num(b2.disarm, 10)); - const cookKey = 'sunbr:cook', serveKey = 'sunbr:serve'; - const healGrid = def?.skill?.rangeGrid ?? null; - return { - skills: { - [S2]: { - kind: 'duration', duration: cook + Math.max(0, num(r2?.duration, 30)), - mods: { batPct: Math.max(0, num(b2.base_attack_time)) }, - targeting: { rangeGrid: r2?.rangeGrid ?? def?.skill?.rangeGrid ?? null }, - attack: { dmgType: 'heal', heal: { mode: 'single' } }, - onStart({ battle, unit }) { - battle.addBuff(unit, { key: cookKey, duration: cook, mods: { defPct: num(b2.def) }, flags: { disarm: true }, tags: ['skill'], visible: true }); - }, - onTick({ battle, unit }) { - if (!unit.findBuff(cookKey) && !unit.findBuff(serveKey)) { - battle.addBuff(unit, { key: serveKey, mods: { atkPct: num(b2.atk) }, tags: ['skill'] }); - battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'heal' }); - } - }, - onEnd({ battle, unit }) { - battle.removeBuff(unit, cookKey); - battle.removeBuff(unit, serveKey); - }, - }, - }, - install(battle, unit) { - if (tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return; - // GUA-X on her skill heals (S1's heal-mode attack, S2's attack heals while it runs — herself included, she stands - // in the grid), never her natural / self regeneration - battle.on('heal', (ctx) => { - if (ctx.source !== unit || !unit.skill?.active || ctx.opts?.regen || ctx.opts?.self) return; - if (ctx.target.hpRatio < num(tb.hp_ratio)) ctx.amount *= num(tb.heal_scale, 1); - }, { owner: unit }); - }, - skill: { - kind: instantKind(def), - heal: true, - ...(healGrid ? { trigger: { rule: 'SKILL_RANGE', grid: healGrid, allies: true }, targeting: { rangeGrid: healGrid } } : {}), - // the heal-mode attack: the most injured ally of the skill range (acquireTargets, heal profile) - attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: num(bb.heal_scale, 1), projectile: 'none' }, - onHit({ battle, target }) { if (target) battle.fx('heal', { x: target.x, y: target.y, id: target.id }); }, - }, - talents: [{ install(battle, unit) { - // the proc is rolled per attack hit; the stun only follows a hit that landed (not dodged / cancelled) - const procs = new WeakSet(); - onHitBy(battle, unit, ({ target, dmg }) => { - if (!isMainHit(dmg) || !(num(t.prob) > 0) || !battle.rng.chance(num(t.prob))) return; - dmg.amount *= num(t.atk_scale, 1); - procs.add(dmg); - battle.fx('crit', { x: target.x, y: target.y, id: unit.id }); - }); - battle.on('damaged', (ctx) => { - if (ctx.source !== unit || !ctx.dmg || !procs.has(ctx.dmg) || !ctx.target.alive) return; - procs.delete(ctx.dmg); - battle.applyStatus(ctx.target, 'stun', { duration: num(t.stun), source: unit }); - }, { owner: unit }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_11 地灵 (hidden) 攻击力强化·β型: ATK +atk. Elite 地质勘探: trait 停顿 +sluggish s (module trait 1.2 s included). - chess_char_1_11_a: (bb, chess) => { - const add = num(talentBb(chess, 0).sluggish); - const kit = { skill: { kind: 'duration', mods: { atkPct: num(bb.atk) } } }; - if (add > 0) kit.trait = { onHitStatus: { key: 'sluggish', duration: num(traitBb(chess).sluggish, 0.8) + add } }; - return kit; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_12 艾丝黛尔 舍身突击 (hurt SP): ATK +atk, cannot be healed by others. 自愈能力: an enemy dying within the talent - // grid (8 surrounding tiles) heals her hp_ratio × max HP. Elite module (CEN-Y): above hp_ratio HP, physical damage - // taken −damage_resistance. - // Alternate S1 攻击力强化·β型: ATK +atk (she stays a heal target). - chess_char_1_12_a: (bb, chess) => { - const t = talentBb(chess, 0); - const grid = talentGrid(chess, 0); - const tb = traitBb(chess); - return { - skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: { noHeal: true } }, - skills: { 'skcom_atk_up[2]': { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, 'skcom_atk_up[2]').atk) } } }, - talents: [{ install(battle, unit) { - battle.on('death', (ctx) => { - const e = ctx.unit; - if (e.side !== 'enemy' || ctx.reason !== 'killed' || !up(unit)) return; - const near = grid ? bodyInKeys(e, absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : cheb(unit, e) <= 1; - if (near) battle.heal(unit, unit, unit.s.maxHp * num(t.hp_ratio), { self: true, tags: ['talent'] }); - }, { owner: unit }); - if (tb.damage_resistance != null) { - onHitOn(battle, unit, ({ dmg }) => { if (dmg.type === 'phys' && unit.hpRatio > num(tb.hp_ratio)) dmg.mul *= 1 - num(tb.damage_resistance); }); - } - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_13 波登可 孢子扩散: a vial bursts on the current target into a projectile_delay_time s spore cloud (radius 0.9, - // PRTS 备注, 可对空): enemies inside are 停顿 and lose their abilities (silence) and take atk_scale × ATK arts per second. - // 园丁: every 【辅助】 operator on the field ATK +atk. Elite module (DEC-X): +sp_recovery_per_sec SP/s with an enemy in range. - // Alternate S1 花香疗法: ATK +atk, normal attacks heal the most injured ally in range instead (ATK per attack). A heal - // skill: DEFAULT fires it with an injured ally inside her initial range (research 03 §1.4) — also checked every tick, - // since she is only "about to attack" with an enemy in range. - chess_char_1_13_a: (bb, chess) => { - const t = talentBb(chess, 0); - const spBonus = num(moduleBb(chess).sp_recovery_per_sec); - const dur = num(bb.projectile_delay_time, 5); - const S1 = 'skchr_podego_1'; - return { - skills: { - [S1]: { kind: 'duration', heal: true, mods: { atkPct: num(skillBbOf(chess, S1).atk) }, attack: { dmgType: 'heal', heal: { mode: 'single' } } }, - }, - install(battle, unit) { - if (unit.skill?.id !== S1) return; - battle.on('tick', () => { - const sk = unit.skill; - if (!up(unit) || !unit.canAct || !sk.ready || sk.active || sk.opCooling || unit.s.flags.silence) return; - if (battle.injuredAlliesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit).length) sk.activate('DEFAULT'); - }, { owner: unit }); - }, - skill: { - kind: 'instant', - onStart({ battle, unit }) { - const tgt = enemiesInGrid(battle, unit, null, { n: 1 })[0]; - const x = tgt ? tgt.x : unit.x + unit.fwd[1], y = tgt ? tgt.y : unit.y + unit.fwd[0]; - const atk = unit.s.atk; - makeZone(battle, unit, { - x, y, radius: SPORE_RADIUS, duration: dur, skill: 'spores', onPulse(b) { - for (const e of b.foesInRadius(x, y, SPORE_RADIUS)) { - if (e.s.flags.untargetable) continue; - b.applyStatus(e, 'sluggish', { duration: 1.05, source: unit }); - b.applyStatus(e, 'silence', { duration: 1.05, source: unit }); - b.dealDamage(unit, e, { amount: atk * num(bb.atk_scale), type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] }); - } - }, - }); - }, - }, - talents: [{ install(battle, unit) { - const v = num(t.atk); - if (v) installAura(battle, unit, { key: 'talent:podego', value: v, select: (a) => a.kind === 'op' && a.def.profession === 'SUPPORT', mods: { atkPct: v } }); - spTimeBonus(battle, unit, spBonus, () => enemyInRange(battle, unit)); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_14 格雷伊 静电释放: ASPD +attack_speed, the talent 停顿 lasts talent_scale × longer. - // 静电场: attacks cause `sluggish` s of 停顿 to the attacked target. - // Alternate S1 战术咏唱·β型: ASPD +attack_speed (the talent 停顿 keeps its length). Elite module SPC-Y: cost only. - chess_char_1_14_a: (bb, chess) => { - const sl = num(talentBb(chess, 0).sluggish); - return { - skills: { 'skcom_magic_rage[2]': { kind: 'duration', mods: { aspd: num(skillBbOf(chess, 'skcom_magic_rage[2]').attack_speed) } } }, - trait: sl > 0 ? { onHitStatus: { key: 'sluggish', duration: sl } } : undefined, - skill: { - kind: 'duration', mods: { aspd: num(bb.attack_speed) }, - attack: sl > 0 ? { onHitStatus: { key: 'sluggish', duration: sl * num(bb.talent_scale, 1) } } : {}, - }, - }; - }, - - // 1_15 盟约·辅助干员 (hidden, band 优等生): its kit is tier6.js `pithst` (迭代元素 — one implementation only). - - // --------------------------------------------------------------------------------------------------------------- - // 1_16 锡人 (hidden tier-1 entry of chess_char_2_19): see tinmanKit. - chess_char_1_16_a: tinmanKit, - - // --------------------------------------------------------------------------------------------------------------- - // 1_17 深靛 光影迷宫: attack interval ×base_attack_time, talent chance ×talent_scale, bound enemies in range take - // indigo_s_2[damage].atk_scale × ATK arts every indigo_s_2[damage].interval s. - // 柔光缚目: attacks bind the target `duration` s with `prob`; bound enemies are never chosen as targets. - // Alternate S1 灯塔守卫者: for `duration` s the skill range (4-1), attack interval ×(1 + base_attack_time) (PRTS: - // "大幅度缩短(-80%)" ⇒ ×0.2 — a flat −0.8 s on 3 s would be less than S2's "略微缩短 ×0.7"), every attack hits for - // attack@atk_scale × ATK arts. Elite module MSC-X (store 4) is the mystic profile of the module trait (TUNE.mystic). - chess_char_1_17_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const dScale = num(bb['indigo_s_2[damage].atk_scale']); - const dIv = num(bb['indigo_s_2[damage].interval'], 0.5); - const bound = (e) => !!e.s.flags.bind; - const r1 = skillRec(chess, 'skchr_indigo_1'); - const b1 = r1?.bb ?? {}; - return { - skills: { - skchr_indigo_1: { - kind: 'duration', mods: { batPct: Math.max(-0.95, Math.min(0, num(b1.base_attack_time))) }, - targeting: { rangeGrid: r1?.rangeGrid ?? null }, - attack: { atkScale: num(b1['attack@atk_scale'], 1) }, - }, - }, - trait: { - // with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she holds her - // fire (the mystic trait stores the energy meanwhile) - canAttack(battle, u) { - const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e)); - if (!ok) u.trait.hadTarget = false; - return ok; - }, - afterHit(battle, u, target) { - if (!target || !target.alive || target.side !== 'enemy') return; - const p = num(t.prob) * (u.skill?.active ? num(bb.talent_scale, 1) : 1); - if (p > 0 && battle.rng.chance(Math.min(1, p))) battle.applyStatus(target, 'bind', { duration: num(t.duration), source: u }); - }, - }, - skill: { - kind: 'duration', mods: { batPct: batMod(bb.base_attack_time, chess, def?.skill?.description ?? chess?.skill?.desc) }, - onStart({ unit }) { unit.mem.indigoAcc = 0; }, - onTick({ battle, unit, dt }) { - if (!(dScale > 0) || !(dIv > 0)) return; - unit.mem.indigoAcc = (unit.mem.indigoAcc ?? 0) + dt; - while (unit.mem.indigoAcc >= dIv - 1e-9) { - unit.mem.indigoAcc -= dIv; - for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) { - if (bound(e)) battle.dealDamage(unit, e, { amount: unit.s.atk * dScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot'] }); - } - } - }, - }, - talents: [{ install(battle, unit) { - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !ctx.targets.some(bound)) return; - const prof = ctx.profile || unit.profile; - const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof); - for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e); - for (let i = cands.length - 1; i >= 0; i--) if (bound(cands[i])) cands.splice(i, 1); - sortEnemyTargets(battle, unit, cands, prof.priority); - ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length)); - }, { owner: unit }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_18 宴 落地斩·破门 (passive): at each deployment lose hp_ratio of current HP, then for `duration` s ATK +atk and - // attacks deal arts damage. 认真模式: 坚忍 ASPD up to +min_attack_speed, reached at min_hp_ratio HP (linear). - // Elite (module talent): below hp_ratio HP, 庇护 −damage_resistance physical/arts damage taken. - // Alternate S1 分神: stops attacking, block count 0 (her blocked enemies walk on), DEF +def, recovers - // hp_recovery_per_sec_by_max_hp_ratio × max HP per second (the 生命回复速度 attribute: works under her 武者 no-heal). - chess_char_1_18_a: (bb, chess) => { - const t = talentBb(chess, 0); - const buffKey = 'utage:s2'; - const s1 = skillBbOf(chess, 'skchr_utage_1'); - return { - skills: { - skchr_utage_1: { - kind: 'duration', - mods: { defPct: num(s1.def), hpRegenRatio: num(s1.hp_recovery_per_sec_by_max_hp_ratio), blockCnt: -99 }, - attack: { noAttack: true }, - onStart({ battle, unit }) { battle.releaseBlocked(unit); }, - }, - }, - skill: { - kind: 'passive', - onStart({ battle, unit }) { - const loss = unit.hp * num(bb.hp_ratio); - if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); - battle.addBuff(unit, { key: buffKey, duration: num(bb.duration), mods: { atkPct: num(bb.atk) }, tags: ['skill'], visible: true }); - battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'breach' }); - }, - }, - talents: [{ install(battle, unit) { - onHitBy(battle, unit, ({ dmg }) => { if (dmg.isAttack && dmg.type === 'phys' && unit.findBuff(buffKey)) dmg.type = 'arts'; }); - const maxAs = num(t.min_attack_speed), minHp = num(t.min_hp_ratio); - if (maxAs > 0 && minHp < 1) { - battle.on('tick', () => { - if (!up(unit)) return; - const v = maxAs * Math.max(0, Math.min(1, (1 - unit.hpRatio) / (1 - minHp))); - const cur = unit.findBuff('utage:serious'); - if (cur && Math.abs((cur.data?.v ?? 0) - v) < 0.5) return; - if (v < 0.5) { if (cur) battle.removeBuff(unit, 'utage:serious'); return; } - battle.addBuff(unit, { key: 'utage:serious', mods: { aspd: v }, data: { v }, tags: ['talent'] }); - }, { owner: unit }); - } - // 庇护 (ba.protect): "受到的物理和法术伤害降低相应比例" — physical and arts damage only - if (t.damage_resistance != null && t.hp_ratio != null) { - onHitOn(battle, unit, ({ dmg }) => { - if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < num(t.hp_ratio)) dmg.mul *= 1 - num(t.damage_resistance); - }); - } - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_19 野鬃 夹枪冲锋: wider range (skill grid), ATK +atk, "攻击会把目标往攻击方向中等力度地推开" (attack@force 1 = 中力) by - // the official 力度 − 重量 distance (Battle.push: weight 0 → 2.14 tiles, 1 → 1.7, 2 → 0.44, 3 → 0.12, ≥ 4 → none; user - // playtest #6 item 14). A directional push (方向力, PRTS 推与拉): the client's S2 attack ability (charpack - // char_496_wildmn, anim Skill_2) carries buff wildmn_s_2[force] of template knockback[dir] — buff_template_data: - // Knockback {_useSourceDirection: true, _decreaseForceLevelWhenNotInDirection: 2} — i.e. along her deploy direction, - // radial at 受力等级 −2 for a target > 45° off it or < 0.25 tile away (特殊修正); the radial pushes are - // knockback[relative] (琳琅诗怀雅 S3, 山 S3, 莫斯提马 S3). Her text uses the 推击手 wording "往攻击方向". So an enemy she - // grabs behind her centre (the hand-over after a push freed her block) is not thrown 1.7 tiles towards the objective - // (player feedback D2, "往攻击方向相反方向推"; it was a radial push before). - // 一致向前: after deploying (normal flag 0: first deployment only; elite flag 1: every deployment) every undeployed - // 【近卫】 operator of the player costs `value` less DP to deploy (≤ max_stack_cnt per operator until it deploys). - // Alternate S1 骑枪刺击 (PASSIVE, ON_DEPLOY): for `duration` s after every deployment ASPD +attack_speed. - // Elite module CHG-X (+2 DP per kill) is the charger profile of the module trait (TUNE.charger). - chess_char_1_19_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const force = num(bb['attack@force'], 1); - const r1 = skillRec(chess, 'skchr_wildmn_1'); - return { - skills: { - skchr_wildmn_1: { - kind: 'passive', - onStart({ battle, unit }) { - const d = num(r1?.duration); - if (d > 0) battle.addBuff(unit, { key: 'wildmn:s1', duration: d, mods: { aspd: num(r1?.bb?.attack_speed) }, tags: ['skill'], visible: true }); - }, - }, - }, - skill: { - kind: 'duration', mods: { atkPct: num(bb.atk) }, - targeting: { rangeGrid: def?.skill?.rangeGrid ?? null }, - attack: { - onHit({ battle, unit, target }) { - if (!target || !target.alive || target.side !== 'enemy') return; - battle.push(target, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] } }); - }, - }, - }, - talents: [{ install(battle, unit) { - const cut = Math.abs(num(t.value, -1)), cap = num(t.max_stack_cnt, 1), every = num(t.flag) === 1; - let deploys = 0; - battle.on('deploy', ({ unit: u }) => { - if (u === unit) { - deploys++; - if (!every && deploys > 1) return; - for (const a of battle.allyUnits) { - if (a === unit || a.kind !== 'op' || a.ownerId !== unit.ownerId || a.alive || a.removed || a.def.profession !== 'WARRIOR') continue; - const done = a.mem.wildmnCut ?? 0; - const d = Math.min(cut, cap - done, a.base.cost); - if (d > 0) { a.base.cost -= d; a.mem.wildmnCut = done + d; } - } - } else if (u.mem.wildmnCut > 0) { - // the discount is consumed by this deployment - u.base.cost += u.mem.wildmnCut; - u.mem.wildmnCut = 0; - } - }, { owner: unit }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 1_20 雷蛇 反击电弧 (hurt SP): attack interval ×(1 + base_attack_time) (PRTS "攻击间隔增大(+70%)": a RATIO for this skill, - // 1.2 → 2.04 s — not +0.7 s), ATK +atk, arts attacks on up to attack@max_target enemies, - // attack@buff_prob to stun attack@stun s; afterwards 雷蛇 is stunned `stun` s. - // 技能策略 → DEFAULT (PR #12; DESIGN §21.29): 反击电弧 is an offensive skill (ATK +125 %, arts hits on up to 3 enemies, - // stun); the official 下半 TANK row (TAKE_DAMAGE for every MANUAL 重装 skill) made it — and 深巡's S2 — wait for a hit - // (GitHub issue #4). The owner's deliberate deviation from that row (2026-10-03, community feedback) gives both - // 哨戒铁卫 S2s the basic strategy; the data says DEFAULT too (rawRule keeps the official TAKE_DAMAGE). - // 战术防御: when attacked, +sp SP to herself and to one random ally in the talent grid. Elite 雷抗: RES +magic_resistance. - // Elite module (SPT-X): stealth of enemies inside the range is cancelled. - // Alternate S1 充能防御 (AUTO, hurt SP, "技能自动开启" — an AUTO skill takes no 技能策略: SP_FULL, so the hit that fills SP - // sets it off): PRTS "技能拥有8s的持续时间": for bb.duration s DEF +def, and the next damage instance taken is blocked - // (cancelled — a blocked hit "导致第一天赋无法触发": no 战术防御 SP, no hurt SP). - chess_char_1_20_a: (bb, chess) => { - const t = talentBb(chess, 0); - const grid = talentGrid(chess, 0); - const S1 = 'skchr_liskam_1'; - const b1 = skillBbOf(chess, S1); - const blockKey = 'liskam:block'; - return { - skills: { - [S1]: { - kind: 'duration', duration: num(b1.duration, 8), mods: { defPct: num(b1.def) }, trigger: 'SP_FULL', - onStart({ battle, unit, skill }) { - battle.addBuff(unit, { key: blockKey, duration: skill.duration + 0.05, tags: ['skill'], visible: true }); - battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); - }, - onEnd({ battle, unit }) { battle.removeBuff(unit, blockKey); }, - }, - }, - install(battle, unit) { - if (moduleOn(chess) && chess.isGolden) installReveal(battle, unit); - if (unit.skill?.id !== S1) return; - battle.on('hit', (ctx) => { - if (ctx.target !== unit || ctx.dmg.cancel || !(ctx.dmg.amount > 0) || !unit.findBuff(blockKey)) return; - ctx.dmg.cancel = true; - battle.removeBuff(unit, blockKey); - battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit, priority: 50 }); - }, - skill: { - trigger: 'DEFAULT', - kind: 'duration', mods: { atkPct: num(bb.atk), batPct: Math.max(0, num(bb.base_attack_time)) }, - targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) }, - attack: { - dmgType: 'arts', - onHit({ battle, unit, target }) { - if (target && target.alive && battle.rng.chance(num(bb['attack@buff_prob']))) battle.applyStatus(target, 'stun', { duration: num(bb['attack@stun']), source: unit }); - }, - }, - onEnd({ battle, unit, reason }) { - if (reason !== 'death' && unit.alive && num(bb.stun) > 0) battle.applyStatus(unit, 'stun', { duration: num(bb.stun), source: unit }); - }, - }, - talents: [{ install(battle, unit) { - const sp = num(t.sp); - if (sp > 0) { - // PRTS 备注 "仅伤害量不为0且能够触发受击回复的伤害才能触发此天赋": any such damage, not only an enemy attack - // (a zone tick, the 源石溶剂 drain — player report D1 audit) - onDamagedOn(battle, unit, (ctx) => { - if (!hurtSpDamage(ctx) || !up(unit)) return; - giveSp(unit, sp); - const mates = alliesInGridOf(battle, unit, grid ?? [[1, 0], [0, -1], [0, 1], [-1, 0]]).filter((a) => a !== unit && a.skill && !a.skill.noSkill); - const m = battle.rng.pick(mates); - if (m) giveSp(m, sp); - }); - } - statBuff(battle, unit, 'talent:liskam', { resFlat: num(talentBb(chess, 1).magic_resistance) }); - } }], - }; - }, -}; +// server/sim/content/kits/tier1.js — re-export shim (0.2.0 refactor, R4): the tier-1 kits live one per file in ops/, +// registered by index.js; their shared helpers and notes are in shared/tier1.js (README.md). This module keeps the +// old imports working until the shims are removed after the 0.2.0 refactor wave. +import { TIER_KITS } from './index.js'; +export * from './shared/tier1.js'; +export { tinmanKit } from './ops/chess_char_1_16-tinman.js'; +export default TIER_KITS[0]; diff --git a/server/sim/content/kits/tier2.js b/server/sim/content/kits/tier2.js index a5434da6..76dc5d71 100644 --- a/server/sim/content/kits/tier2.js +++ b/server/sim/content/kits/tier2.js @@ -1,864 +1,6 @@ -// server/sim/content/kits/tier2.js — Tier 2 operator kits. -// -// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). Shared helpers live in tier1.js (named -// exports). Numbers come from the skill / talent / trait blackboards (elite module upgrades are in the elite record's -// trait.bb and hidden index −1 talents). fx kinds: see the tier1.js header. -// -// Covered (normal + elite): 2_01 送葬人 2_02 赫默 2_03 崖心(H) 2_04 小满 2_05 哈洛德 2_06 莎草 2_07 幽灵鲨 2_08 泡泡 -// 2_09 休谟斯 2_10 洛洛 2_11 风丸 2_12 砾 2_13 蒂比 2_14 调香师 2_15 协律(H) 2_16 拉普兰德 2_17 折桠 2_18 灰毫 2_19 锡人. -// -// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skill in `skills` -// ({ [skillId]: SkillSpec }, official Lv4 / Lv7 blackboards — the kit function receives the SELECTED skill's `bb`, so -// every spec is built from `bb`; the trigger rule comes from that skill's data). Talent / install hooks that belong to -// the default skill only (target locks, counters, hit triggers) check onDefaultSkill(chess). Module choices need no -// code here: `chess` is the loadout-resolved record (trait.bb / talents of the selected module, or none). +// server/sim/content/kits/tier2.js — re-export shim (0.2.0 refactor, R4): the tier-2 kits live one per file in ops/, +// registered by index.js; their shared helpers and notes are in shared/tier2.js (README.md). This module keeps the +// old imports working until the shims are removed after the 0.2.0 refactor wave. +import { TIER_KITS } from './index.js'; -import { COLS, ELEMENT_GAUGE_MAX } from '../../constants.js'; -import { canTargetEnemy } from '../../targeting.js'; -import { bodyInKeys } from '../../body.js'; -import { - num, talentBb, moduleBb, traitBb, up, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid, - alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, instantKind, - tinmanKit, batMod, -} from './tier1.js'; -import { releaseSkillSummon } from '../tokens.js'; - -/** Largest element gauge of a unit (every element — 侵蚀 included: the bosses' attacks fill it on operators). */ -const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis, a.elem.erosion ?? 0); - -/** - * Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the - * selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default. - */ -function onDefaultSkill(chess) { - const d = (chess?.skills ?? []).find((s) => s && s.isDefault); - return !d || !chess?.skill || d.skillId === chess.skill.skillId; -} - -/** - * 起飞 of 蒂比's skills (gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人"): the skill's flags - * `liftoff` (no ground enemy blocked — Battle._blockerFor; 对地规避 — targeting.js evadesGround: no ground enemy selects - * her, while what selects nobody still lands — 无视无法选择 abilities, direct picks, flying units' blasts, 无来源 damage, the - * ticks of a debuff already on her) and `blockFly` (blocks flyers at the air radius). She stays a ground unit on her tile (PRTS 行动方式 "起飞的 - * 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰 still counts her; 地面干员 bonds / items read her position). - */ -const LIFTOFF_FLAGS = Object.freeze({ blockFly: true, liftoff: true }); -/** 蒂比 take-off: the ground enemies she blocked walk on. */ -function tippiTakeOff({ battle, unit }) { - battle.releaseBlocked(unit); - battle.fx('takeoff', { x: unit.x, y: unit.y, id: unit.id }); -} -/** 蒂比 landing when the airborne skill ends: the flyers she held are released. */ -function tippiLand({ battle, unit }) { - battle.releaseBlocked(unit); -} - -export default { - // --------------------------------------------------------------------------------------------------------------- - // 2_01 送葬人 最终旅程: normal attacks become double hits, base attack time +base_attack_time s (−0.5 on 2.3 s - // "少量缩短"). 终结改装: ignore def_penetrate_fixed DEF. (Trait: every enemy in range, ×atk_scale on its front row — - // reaperrange profile.) - // - // S1 铳口收束 (alt): ATK +atk; "攻击时对攻击范围内的所有敌人应用特性加成" — every enemy in range takes the trait's - // front-row multiplier (profile frontScale: trait atk_scale 1.5, elite module 1.6, elite without module 1.5). - chess_char_2_01_a: (bb, chess) => ({ - skill: { kind: 'duration', mods: { batPct: batMod(bb.base_attack_time, chess) }, attack: { hits: 2 } }, - skills: { - skchr_excu_1: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: { dmgMul: (battle, u) => num(u.profile?.frontScale, 1.5) } }, - }, - talents: [{ install(battle, unit) { statBuff(battle, unit, 'talent:excu', { defIgnoreFlat: num(talentBb(chess, 0).def_penetrate_fixed) }); } }], - }), - - // --------------------------------------------------------------------------------------------------------------- - // 2_02 赫默 医疗无人机 (AUTO, heal trigger): "获得一个医疗无人机 / 最多可库存1个无人机;无人机投入战场后治疗周围友军, - // 10秒后自动销毁" — the 医疗探机 is a hand piece the player places (user playtest #6; PRTS 卫戍协议/帮助): each cast gives - // one (stock ≤ cnt) and the placed piece takes the field on its own tile (tokens.js releaseSkillSummon: not at the - // battle start — user playtest #4; not placed ⇒ no drone); it heals around itself and self-destructs after 10 s (token - // kit). 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on ground units - // ×heal_scale (module 'none': no bonus). - // S1 治疗强化·γ型 (alt): ATK +atk for its duration (no drone). - chess_char_2_02_a: (bb, chess) => { - const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? [])[0] ?? 'token_10000_silent_healrb'; - const cnt = Math.max(1, Math.floor(num(bb.cnt, 1))); - const t = talentBb(chess, 0); - const tb = traitBb(chess); - return { - skill: { - kind: 'instant', heal: true, - onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokId, { cap: cnt }); }, - }, - skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } }, - talents: [{ install(battle, unit) { - const v = num(t.attack_speed); - if (v) installAura(battle, unit, { key: 'talent:silent', value: v, select: (a) => a.kind === 'op' && a.def.profession === 'MEDIC', mods: { aspd: v } }); - if (num(tb.heal_scale, 1) !== 1) { - battle.on('heal', (ctx) => { if (ctx.source === unit && ctx.target.ground) ctx.amount *= num(tb.heal_scale, 1); }, { owner: unit }); - } - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中力; Battle.pullToFront: - // the official 力度 − 重量 pull — weight ≤ 1 all the way in front of her, 2 a third of the way, 3 barely, ≥ 4 not at all), - // take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking. - // Elite module (HOK-X, trait value/dist): dragged enemies take `value` arts per `dist` tiles travelled. - chess_char_2_03_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - const force = num(bb.force, 1); - return { - skill: { - kind: 'instant', - onStart({ battle, unit }) { - const foes = enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n: Math.max(1, Math.floor(num(bb.max_target, 1))) }); - for (const e of foes) { - const sx = e.x, sy = e.y; - const moved = battle.pullToFront(e, unit, force); - battle.fx('pull', { x: e.x, y: e.y, id: e.id, fromX: sx, fromY: sy }); - if (moved > 0 && num(tb.value) > 0) { - battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(0.01, num(tb.dist, 1)), type: 'arts', isSkill: true, canDodge: false, tags: ['drag'] }); - } - if (!e.alive) continue; - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'true', isSkill: true, tags: ['skill'] }); - if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit }); - } - }, - }, - talents: [{ install(battle, unit) { - if (num(t.atk) || num(t.def)) toggleBuff(battle, unit, 'slchan:hunter', () => unit.blocking.length === 0, { atkPct: num(t.atk), defPct: num(t.def) }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_04 小满 乡音沉沉: stops attacking and puts max_target enemies in range to sleep `sleep` s; afterwards ASPD - // +attack_speed and attacks max_target enemies. 好好听话: ASPD +attack_speed; vs 【野生动物】 (tag `infection`, - // "野生的被感染生物") the trait 停顿 lasts +sluggish_addition s. Elite module (DEC-X): +sp_recovery_per_sec SP/s with - // an enemy in range. - // S1 竹笛飞声 (alt, 2 charges): the next attack deals atk_scale × ATK arts and hits one more enemy (+1 target). - chess_char_2_04_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - const n = Math.max(1, Math.floor(num(bb.max_target, 1))); - const sleep = num(bb.sleep); - return { - trait: { - onHitStatus: null, - canAttack: (battle, u) => !(u.skill?.active && battle.time < (u.mem.grainQuietUntil ?? -1)), - afterHit(battle, u, target) { - if (!target || !target.alive || target.side !== 'enemy') return; - const beast = (target.def?.tags || []).includes('infection'); - battle.applyStatus(target, 'sluggish', { duration: num(tb.sluggish, 0.8) + (beast ? num(t.sluggish_addition) : 0), source: u }); - }, - }, - skill: { - kind: 'duration', mods: { aspd: num(bb.attack_speed) }, - targeting: { maxTargets: n, rangeGrid: def?.skill?.rangeGrid ?? null }, - onStart({ battle, unit }) { - unit.mem.grainQuietUntil = battle.time + sleep; - for (const e of enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n })) { - if (battle.applyStatus(e, 'sleep', { duration: sleep, source: unit })) battle.fx('sleep', { x: e.x, y: e.y, id: e.id }); - } - }, - }, - skills: { - skchr_grabds_1: { kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 1), dmgType: 'arts' }, mods: { maxTargets: 1 } }, - }, - talents: [{ install(battle, unit) { - statBuff(battle, unit, 'talent:grabds', { aspd: num(t.attack_speed) }); - spTimeBonus(battle, unit, num(moduleBb(chess).sp_recovery_per_sec), () => enemyInRange(battle, unit)); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_05 哈洛德 重症优先: ASPD +attack_speed, heals the ally with the heaviest element damage first; on targets whose - // gauge is over half, the element recovery is trait_scale × (the wandermedic trait: ep_heal_ratio × ATK). - // 我即军营: allies in range whose gauge is over half take −ep_damage_resistance 元素损伤 — checked on the element hit - // itself (`elementHit`, a 元素损伤 multiplier: PRTS 元素 "…后续可应用元素损伤倍率提升/降低等效果"; not `elemTakenMul`, - // which is 元素伤害 / 元素脆弱); several 哈洛德 do not stack — the strongest cut applies. - // S1 治疗强化·γ型 (alt): ATK +atk; the 重症优先 target order belongs to S2 only. - chess_char_2_05_a: (bb, chess) => { - const t = talentBb(chess, 0); - const s2 = onDefaultSkill(chess); - const half = ELEMENT_GAUGE_MAX / 2; - const ts = num(bb.trait_scale, 1); - return { - skill: { - kind: 'duration', heal: true, mods: { aspd: num(bb.attack_speed) }, - onHit({ battle, unit, target }) { - if (!target || !unit.mem.haroldHalf?.has(target.id) || !(ts > 1)) return; - battle.reduceElement(target, unit.s.atk * (unit.profile.heal?.elementHealRatio ?? 0) * (ts - 1)); - }, - }, - skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } }, - talents: [{ install(battle, unit) { - if (s2) battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !unit.skill?.active) return; - const cands = battle.injuredAlliesInKeys(unit.rangeKeys, unit, true); - cands.sort((a, b) => elemLoad(b) - elemLoad(a) || a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq); - if (cands.length) ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length)); - unit.mem.haroldHalf = new Set(ctx.targets.filter((a) => elemLoad(a) > half).map((a) => a.id)); - }, { owner: unit }); - const r = Math.max(0, Math.min(1, num(t.ep_damage_resistance))); - if (r > 0) { - battle.on('elementHit', (ctx) => { - const a = ctx.target, d = ctx.dmg; - if (!up(unit) || !a || a.side !== 'ally' || !d || d.type !== 'element' || !(elemLoad(a) > half)) return; - if (!(unit.rangeKeySet || new Set(unit.rangeKeys || [])).has(a.tileR * COLS + a.tileC)) return; - const prev = d.haroldCut || 0; // the strongest 我即军营 of the hit only - if (r <= prev) return; - d.mul *= (1 - r) / (1 - prev); - d.haroldCut = r; - }, { owner: unit, priority: 20 }); - } - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_06 莎草 巧思乍现 (AUTO, 2 charges): the next heal is heal_scale × ATK (chain jumps included) and the talent - // barrier is shield_scale_skill × stronger. 博览古卷: every heal gives the target a barrier of attack@scale × ATK for - // attack@shield_duration s. (Chain falloff incl. the elite module comes from the chainhealer profile.) - // S2 临考发挥 (alt): on start lock the ally in range (not herself, no 禁疗; any profession, summons included) with the - // highest max HP; while it runs that ally is the only main heal target, base attack time +base_attack_time s (−1.1 - // on 2.85), ATK +atk, chain jumps +attack@chain.extra_value. PRTS: "无法可选单位时无法开启技能" — with no lockable - // ally the skill never activates (the talent install guards the unit's skill.activate: no skillStart, no - // 萨尔贡 / 特质 "开启技能时" gains, SP kept); the skill stops at once when the locked ally leaves the field. - chess_char_2_06_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const sc = num(t['attack@scale']), sd = num(t['attack@shield_duration'], 8), boost = num(bb.shield_scale_skill, 1); - const extra = Math.max(0, Math.floor(num(bb['attack@chain.extra_value']))); - const lockOf = (battle, unit) => alliesInGridOf(battle, unit) - .filter((a) => a !== unit && !a.s.flags.noHeal && !a.profile?.noHeal) - .sort((a, b) => b.s.maxHp - a.s.maxHp || a.deploySeq - b.deploySeq)[0] ?? null; - const release = (battle, unit) => { - if (unit.mem.papyrsHeal) { unit.profile.heal = unit.mem.papyrsHeal; unit.mem.papyrsHeal = null; } - if (unit.mem.papyrsHook) battle.off(unit.mem.papyrsHook); - unit.mem.papyrsHook = null; - unit.mem.papyrsLock = null; - unit.mem.papyrsAbort = false; - }; - return { - skill: { kind: instantKind(def), heal: true, attack: { healScale: num(bb.heal_scale, 1) } }, - skills: { - skchr_papyrs_2: { - kind: 'duration', heal: true, mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) }, - onStart({ battle, unit, skill }) { - release(battle, unit); - const lock = lockOf(battle, unit); - // (only reachable when a runtime bypasses the activate guard below, e.g. a copied skill: undo on the next tick) - if (!lock) { unit.mem.papyrsAbort = true; return; } - unit.mem.papyrsLock = lock; - const h = unit.profile.heal; - if (h && extra > 0) { unit.mem.papyrsHeal = h; unit.profile.heal = { ...h, count: Math.max(1, h.count || 3) + extra }; } - unit.mem.papyrsHook = battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !skill.active) return; - const L = unit.mem.papyrsLock; - if (L && up(L) && bodyInKeys(L, unit.rangeKeySet)) ctx.targets = [L]; - }, { owner: unit, priority: 10 }); - battle.fx('buff', { x: lock.x, y: lock.y, id: lock.id, kind: 'lock' }); - }, - onTick({ unit, skill }) { - if (unit.mem.papyrsAbort) { - // "无法可选单位时无法开启技能": undo the cast, keep the SP - unit.mem.papyrsAbort = false; - skill.end('noTarget'); - skill.addCharge(1); - return; - } - const L = unit.mem.papyrsLock; - if (!L || !up(L)) skill.end('target'); - }, - onEnd({ battle, unit }) { release(battle, unit); }, - }, - }, - talents: [{ install(battle, unit) { - const sk = unit.skill; - if (sk && sk.id === 'skchr_papyrs_2' && !Object.prototype.hasOwnProperty.call(sk, 'activate')) { - // "无法可选单位时无法开启技能": every activation path (DEFAULT heal trigger, carried skill, content) is refused - // while no ally can be locked — the charge stays, nothing starts (non-enumerable: never serialised) - const activate = sk.activate; - Object.defineProperty(sk, 'activate', { - configurable: true, writable: true, enumerable: false, - value(reason, opts) { return lockOf(battle, unit) ? activate.call(this, reason, opts) : false; }, - }); - } - if (!(sc > 0)) return; - battle.on('heal', (ctx) => { - if (ctx.source !== unit || ctx.target === unit && ctx.opts?.regen || !(ctx.amount > 0)) return; - const bySkill = !!(unit.skill?.active && unit.skill.pending); - battle.addBuff(ctx.target, { key: 'papyrs:shield', shield: unit.s.atk * sc * (bySkill ? boost : 1), duration: sd, source: unit, visible: true, tags: ['talent'] }); - }, { owner: unit, priority: -10 }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_07 幽灵鲨 肉斩骨断: ATK +atk and HP never drops below 1 while active; stunned `stun` s afterwards. - // Talent: max HP +max_hp (elite: + hp_recovery_per_sec_by_max_hp_ratio × max HP per s). - // Elite module (CEN-X, trait atk_scale): vs enemies it blocks ATK ×atk_scale. - // S1 攻击力强化·γ型 (alt): ATK +atk for its duration. - chess_char_2_07_a: (bb, chess) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - return { - skill: { - kind: 'duration', mods: { atkPct: num(bb.atk) }, - onStart({ battle, unit }) { - if (unit.mem.undying) battle.off(unit.mem.undying); - unit.mem.undying = battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 }); - }, - onEnd({ battle, unit, reason }) { - if (unit.mem.undying) battle.off(unit.mem.undying); - unit.mem.undying = null; - if (reason !== 'death' && unit.alive && num(bb.stun) > 0) battle.applyStatus(unit, 'stun', { duration: num(bb.stun), source: unit }); - }, - }, - skills: { 'skcom_atk_up[3]': { kind: 'duration', mods: { atkPct: num(bb.atk) } } }, - talents: [{ install(battle, unit) { - statBuff(battle, unit, 'talent:ghost', { hpPct: num(t.max_hp), hpRegenRatio: num(t.hp_recovery_per_sec_by_max_hp_ratio) }); - if (num(tb.atk_scale, 1) !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.blockedBy) dmg.amount *= num(tb.atk_scale, 1); }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_08 泡泡 “挨打”: stops attacking; DEF +def, taunt +taunt_level, each attack received returns atk_scale × DEF phys - // damage to the attacker. 尖刺盾: attackers get ATK −atk for `duration` s. Elite module (PRO-X): DEF +def while blocking. - // S1 防御力强化·β型 (alt, TAKE_DAMAGE from data): DEF +def for its duration; the counter belongs to “挨打” only. - chess_char_2_08_a: (bb, chess) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - const s2 = onDefaultSkill(chess); - return { - skill: { kind: 'duration', mods: { defPct: num(bb.def), taunt: num(bb.taunt_level) }, attack: { noAttack: true } }, - skills: { 'skcom_def_up[2]': { kind: 'duration', mods: { defPct: num(bb.def) } } }, - talents: [{ install(battle, unit) { - onDamagedOn(battle, unit, (ctx) => { - if (!byEnemyAttack(ctx) || !unit.alive) return; - const src = ctx.source; - if (s2 && unit.skill?.active && src.alive) { - battle.dealDamage(unit, src, { amount: unit.s.def * num(bb.atk_scale), type: 'phys', isSkill: true, canDodge: false, tags: ['counter'] }); - battle.fx('counter', { x: src.x, y: src.y, id: unit.id }); - } - if (num(t.atk) < 0 && src.alive) battle.addBuff(src, { key: 'bubble:spike', duration: num(t.duration, 5), mods: { atkPct: num(t.atk) }, refresh: 'extend', source: unit }); - }); - if (num(tb.def)) toggleBuff(battle, unit, 'bubble:guard', () => unit.blocking.length > 0, { defPct: num(tb.def) }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_09 休谟斯 高效处理: block +block_cnt; above peak_1.hp_ratio HP ATK +peak_1.atk (精力充沛), above peak_2.hp_ratio - // +peak_2.atk. 再回收: healing beyond max HP becomes a barrier, at most max_hp_ratio × max HP. - // S1 固废切割 (alt, attack SP): the next attack hits at atk_scale × ATK (every enemy it reaches) and heals her `value` - // HP once (a self heal: her trait bars only healing by others; overflow feeds 再回收). - chess_char_2_09_a: (bb, chess) => { - const t = talentBb(chess, 0); - const peaks = []; - for (const [k, v] of Object.entries(bb)) { - const m = k.match(/\[(peak_\d+)\]\.peak_performance\.(atk|hp_ratio)$/); - if (!m) continue; - let p = peaks.find((x) => x.id === m[1]); - if (!p) peaks.push((p = { id: m[1], atk: 0, hp: 1 })); - if (m[2] === 'atk') p.atk = num(v); else p.hp = num(v, 1); - } - peaks.sort((a, b) => b.hp - a.hp); - const key = 'humus:peak'; - const peak = ({ battle, unit }) => { - const lvl = peaks.find((p) => unit.hpRatio > p.hp)?.atk ?? 0; - const cur = unit.findBuff(key); - if ((cur?.data?.v ?? 0) === lvl) return; - if (lvl > 0) battle.addBuff(unit, { key, mods: { atkPct: lvl }, data: { v: lvl }, visible: true, tags: ['skill'] }); - else battle.removeBuff(unit, key); - }; - return { - skill: { - kind: 'duration', mods: { blockCnt: num(bb.block_cnt) }, - onStart: peak, onTick: peak, - onEnd({ battle, unit }) { battle.removeBuff(unit, key); }, - }, - skills: { - skchr_humus_1: { - kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) }, - onAttack({ battle, unit }) { - if (num(bb.value) > 0 && unit.alive) battle.heal(unit, unit, num(bb.value), { self: true, tags: ['skill'] }); - }, - }, - }, - talents: [{ install(battle, unit) { - const ratio = num(t.max_hp_ratio); - if (!(ratio > 0)) return; - battle.on('heal', (ctx) => { - if (ctx.target !== unit || !(ctx.amount > 0)) return; - const over = ctx.amount - (unit.s.maxHp - unit.hp); - if (!(over > 0)) return; - const cur = unit.findBuff('humus:recycle'); - const have = cur ? cur.shield : 0; - const val = Math.min(unit.s.maxHp * ratio, have + over); - if (val > have + 1e-6) battle.addBuff(unit, { key: 'humus:recycle', shield: val, source: unit, visible: true, tags: ['talent'] }); - }, { owner: unit, priority: -50 }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_10 洛洛 自负此轭: ASPD +attack_speed; the drones lock on their target until it dies or the skill ends. - // 过载 (term ba.overdrive "技能进行到一半时触发额外效果"): the second half of the duration — ATK +atk and the funnel cap - // ×scale; when the skill ends she is stunned as long as the overload lasted. 立于磐石: ATK +atk per `interval` s on the - // field, up to max_stack_cnt stacks. - // S1 战术咏唱·γ型 (alt): ASPD +attack_speed; the target lock belongs to 自负此轭 only. - chess_char_2_10_a: (bb, chess) => { - const t = talentBb(chess, 0); - const s2 = onDefaultSkill(chess); - const endOverload = (battle, unit) => { - if (unit.mem.rockFunnel) { unit.profile.funnel = unit.mem.rockFunnel; unit.mem.rockFunnel = null; } - battle.removeBuff(unit, 'rockr:overload'); - }; - return { - skill: { - kind: 'duration', mods: { aspd: num(bb.attack_speed) }, - onStart({ battle, unit, skill }) { - unit.mem.rockOverAt = battle.time + skill.duration / 2; - unit.mem.rockOver = false; - unit.mem.rockLock = null; - }, - onTick({ battle, unit }) { - if (unit.mem.rockOver || battle.time + 1e-9 < unit.mem.rockOverAt) return; - unit.mem.rockOver = true; - const f = unit.profile.funnel; - if (f) { unit.mem.rockFunnel = f; unit.profile.funnel = { ...f, max: f.max * num(bb.scale, 1) }; } - battle.addBuff(unit, { key: 'rockr:overload', mods: { atkPct: num(bb.atk) }, visible: true, tags: ['skill'] }); - battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); - }, - onEnd({ battle, unit, reason }) { - const over = unit.mem.rockOver ? battle.time - unit.mem.rockOverAt : 0; - endOverload(battle, unit); - unit.mem.rockOver = false; - unit.mem.rockLock = null; - if (over > 0 && reason !== 'death' && unit.alive) battle.applyStatus(unit, 'stun', { duration: over, source: unit }); - }, - }, - skills: { 'skcom_magic_rage[3]': { kind: 'duration', mods: { aspd: num(bb.attack_speed) } } }, - talents: [{ install(battle, unit) { - if (s2) battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !unit.skill?.active) return; - const L = unit.mem.rockLock; - if (L && L.alive && bodyInKeys(L, unit.rangeKeySet) && canTargetEnemy(unit, L, ctx.profile || unit.profile)) ctx.targets = [L]; - else unit.mem.rockLock = ctx.targets[0] ?? null; - }, { owner: unit }); - const iv = num(t.interval), max = Math.floor(num(t.max_stack_cnt)), v = num(t.atk); - if (!(iv > 0) || !(max > 0) || !v) return; - battle.on('tick', () => { - if (!up(unit)) return; - const n = Math.min(max, Math.floor((battle.time - unit.deployedAt + 1e-9) / iv)); - const cur = unit.findBuff('rockr:rock'); - if ((cur ? cur.stacks : 0) === n) return; - if (n > 0) battle.addBuff(unit, { key: 'rockr:rock', mods: { atkPct: v }, stacks: n, maxStacks: max, tags: ['talent'] }); - else battle.removeBuff(unit, 'rockr:rock'); - }, { owner: unit }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_11 风丸 纸艺·双影: lose hp_ratio of current HP, ATK +atk, summon the <替身> (overrideTokenKey) on a free melee tile - // around her for the skill's duration [ASSUMED lifetime]. 折纸生花: whenever a <替身> appears (the summon, and the - // dollkeeper substitution) enemies on the 8 surrounding tiles take damage_scale × its ATK arts — air units too [ASSUMED: - // no 对空 note on PRTS; the <替身> itself "可对空"]. - // Trait substitution: she fights with the <替身>'s stats (the engine already swaps in its HP; the kit swaps ATK/DEF - // with flat deltas so %-buffs still apply). Elite module (PUM-X, trait atk): while substituted, ATK +atk. - // S1 纸艺·迅击 (alt, attack SP): the next attack hits at atk_scale × ATK and she loses hp_ratio of her MAX HP (PRTS: - // ten casts knock her out — the loss can be fatal and so sets off the <替身> substitution). - chess_char_2_11_a: (bb, chess) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? []).find((x) => /shadow|doll/.test(String(x))) ?? 'token_10022_kazema_shadow'; - const ds = num(t.damage_scale); - const origami = (battle, src, atk) => { - battle.fx('aoe', { x: src.x, y: src.y, radius: 1.5, id: src.id, skill: 'origami' }); - if (!(ds > 0)) return; - for (const e of battle.enemies) { - if (!e.alive || e.hidden || e.s.flags.untargetable || cheb(src, e) > 1) continue; - battle.dealDamage(src, e, { amount: atk * ds, type: 'arts', isSkill: true, canDodge: false, tags: ['talent'] }); - } - }; - return { - skill: { - kind: 'duration', mods: { atkPct: num(bb.atk) }, - onStart({ battle, unit }) { - const loss = unit.hp * num(bb.hp_ratio); - if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); - const tile = freeTileAround(battle, unit, (r, c) => battle.grid.canStand(r, c)); - if (!tile) return; - const doll = battle.spawnToken(unit, tokId, tile[0], tile[1], {}); - if (!doll) return; - unit.mem.doll = doll; - battle.fx('summon', { x: doll.x, y: doll.y, id: doll.id, token: tokId }); - origami(battle, doll, doll.s.atk); - }, - onEnd({ battle, unit }) { - const d = unit.mem.doll; - if (d && d.alive) battle.retreat(d, { reason: 'expired', permanent: true }); - unit.mem.doll = null; - }, - }, - skills: { - skchr_kazema_1: { - kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) }, - onAttack({ battle, unit }) { - const loss = unit.s.maxHp * num(bb.hp_ratio); - if (loss > 0 && unit.alive) battle.loseHp(unit, loss, { source: unit, tags: ['skill'] }); - }, - }, - }, - talents: [{ install(battle, unit) { - const bonus = num(tb.atk); - battle.on('tick', () => { - const sub = up(unit) && !!unit.findBuff('trait:substitute'); - if (sub && !unit.mem.kzSub) { - unit.mem.kzSub = true; - const ts = battle.tokenDef(tokId, unit)?.stats ?? null; // the owner's module variant (DESIGN §16) - const mods = {}; - if (num(ts?.atk) > 0) mods.atkFlat = num(ts.atk) - unit.base.atk; - if (ts && Number.isFinite(ts.def)) mods.defFlat = ts.def - unit.base.def; - if (bonus > 0) mods.atkPct = bonus; - battle.addBuff(unit, { key: 'kazema:doll', mods, tags: ['trait'] }); - origami(battle, unit, unit.s.atk); - } else if (!sub && unit.mem.kzSub) { - unit.mem.kzSub = false; - battle.removeBuff(unit, 'kazema:doll'); - } - }, { owner: unit }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_12 砾 鼠群 (passive): at each deployment a barrier of hp_ratio × max HP that decays to 0 over `duration` s - // (capacity stepped once per second; a damaged barrier shrinks in proportion — PRTS). - // 快速部署: own deployment cost +cost (−1). Elite 小个子支援: every unit whose initial deployment cost ≤ cond.cost - // DEF +def while 砾 is on the field. (Elite module withdraw refund: no manual retreat in battle ⇒ no effect.) - // S1 影袭 (alt, passive): at each deployment DEF +def, decaying linearly to 0 over `duration` s, updated once per - // second (PRTS: "防御力加成每秒更新一次"). - chess_char_2_12_a: (bb, chess) => { - const t = talentBb(chess, 0); - return { - skill: { - kind: 'passive', - onStart({ battle, unit }) { - const total = unit.s.maxHp * num(bb.hp_ratio); - const dur = num(bb.duration, 10); - if (!(total > 0) || !(dur > 0)) return; - let k = 0; - battle.addBuff(unit, { - key: 'gravel:rats', shield: total, duration: dur, interval: 1, visible: true, tags: ['skill'], - // PRTS: "屏障最大容量每秒更新一次;受到伤害后,剩余容量根据屏障最大容量等比变化" — the capacity steps down - // once per second and what is left of the barrier scales with it (never a plain cap) - onTick: ({ unit: u, buff }) => { - const prev = Math.max(0, dur - k) / dur; - k++; - const next = Math.max(0, dur - k) / dur; - if (prev > 0 && buff.shield > 0) { buff.shield *= next / prev; u.markDirty(); } - }, - }); - battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); - }, - }, - skills: { - skchr_gravel_1: { - kind: 'passive', - onStart({ battle, unit }) { - const v = num(bb.def), dur = num(bb.duration); - if (!(v > 0) || !(dur > 0)) return; - let k = 0; - battle.addBuff(unit, { - key: 'gravel:shadow', duration: dur, mods: { defPct: v }, interval: 1, visible: true, tags: ['skill'], - onTick: ({ unit: u, buff }) => { - k++; - buff.mods = { defPct: v * Math.max(0, dur - k) / dur }; - u.markDirty(); - }, - }); - battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'def' }); - }, - }, - }, - talents: [{ install(battle, unit) { - const c = num(t.cost); - if (c) unit.base.cost = Math.max(0, unit.base.cost + c); - const d = num(t.def), lim = t['cond.cost']; - if (d && lim != null) installAura(battle, unit, { key: 'talent:gravel', value: d, select: (a) => num(a.def?.stats?.cost, Infinity) <= num(lim), mods: { defPct: d } }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_13 蒂比 紧急赶场通知 (AUTO): "受到攻击后触发" is officially "受到伤害前触发" (PRTS 修正 原因 6; 备注 "在受到伤害前自动 - // 触发技能…若本次伤害为物理或法术,再将本次伤害闪避") — any incoming damage instance (an attack, a zone tick, the 源石溶剂 - // drain; never a 流失, which has no `hit`) sets it off (the kit is the only trigger: the engine rule is disabled) and a - // physical/arts one is dodged; takes off for the duration: skill range, ATK +atk, attacks become 3 shots, blocks flying - // (not ground) enemies. Trait "起飞后能够阻挡2个飞行敌人": flying enemies are blocked only while airborne. 片场工作指南 - // ("若最近N秒内未受伤害" — 修正 原文 攻击): if no damage for stack_time s, the next physical/arts damage is dodged (prob); - // every damage instance restarts that timer (dodged or not; a 流失 does not — PRTS 备注). - // S1 专业喷绘技巧 (alt, DEFAULT trigger from data): takes off at once for its duration — skill range, ATK +atk, blocks - // flyers only; no triple shot, and incoming attacks never set it off. - // Both take off as 起飞 (LIFTOFF_FLAGS): she blocks no ground enemy and none selects her (对地规避), so a ground enemy's - // selected damage refused on the way never reaches her `hit` and neither sets off S2 nor restarts 片场工作指南. - chess_char_2_13_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const s2 = onDefaultSkill(chess); - return { - trait: { blockFly: false }, - skill: { - kind: 'duration', trigger: { rule: 'CUSTOM_RANGE', grid: [] }, // never by the engine: the hit handler below - mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS, - targeting: { rangeGrid: def?.skill?.rangeGrid ?? null }, - attack: { hits: 3 }, - onStart: tippiTakeOff, - onEnd: tippiLand, - }, - skills: { - skchr_tippi_1: { - kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS, - targeting: { rangeGrid: def?.skill?.rangeGrid ?? null }, - onStart: tippiTakeOff, - onEnd: tippiLand, - }, - }, - talents: [{ install(battle, unit) { - battle.removeBuff(unit, 'trait:skywalker'); // the profession grants blockFly permanently; here only airborne - const st = num(t.stack_time), p = num(t.prob, 1); - let last = -Infinity; - battle.on('deploy', ({ unit: u }) => { if (u === unit) last = battle.time - st; }, { owner: unit }); - onHitOn(battle, unit, (ctx) => { - const { dmg } = ctx; - if (!dmg || dmg.cancel || !up(unit)) return; - const dodgeable = dmg.type === 'phys' || dmg.type === 'arts'; - const sk = unit.skill; - let dodged = false; - if (s2 && sk && sk.ready && !sk.active && unit.canAct && !unit.s.flags.silence && sk.activate('TAKE_DAMAGE')) dodged = dodgeable; - else if (dodgeable && st > 0 && battle.time - last + 1e-9 >= st && battle.rng.chance(p)) dodged = true; - last = battle.time; - if (dodged) { dmg.cancel = true; battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); } - }, 5); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_14 调香师 精调: ASPD +attack_speed (−50), ATK +atk. 熏衣草: every ally on the field recovers - // atk_to_hp_recovery_ratio × ATK HP per second. Elite module (RIN-Y, hidden attack@max_target): heals 4 allies. - // S1 治疗强化·β型 (alt): ATK +atk for its duration. - chess_char_2_14_a: (bb, chess) => { - const t = talentBb(chess, 0); - const cnt = Math.floor(num(moduleBb(chess)['attack@max_target'])); - return { - trait: cnt > 0 ? { heal: { mode: 'multi', count: cnt } } : undefined, - skill: { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } }, - skills: { 'skcom_heal_up[2]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } }, - talents: [{ install(battle, unit) { - const r = num(t.atk_to_hp_recovery_ratio); - if (!(r > 0)) return; - battle.every(1, () => { - if (!up(unit)) return; - const amt = unit.s.atk * r; - for (const a of battle.alliesFor(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] }); - }, { owner: unit }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_15 协律 (hidden) 震爆调谐: ATK +atk; each attack sets off a sonic boom at every other operator in range: - // attack@aoe_atk_scale × ATK arts within attack@range_radius. 律脉同构: ATK +atk with another operator in range. - // Elite module (BLA-X): damage grows with distance, up to +damage_scale at max_dist tiles. - chess_char_2_15_a: (bb, chess) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - const rad = num(bb['attack@range_radius'], 0.9), sc = num(bb['attack@aoe_atk_scale']); - return { - skill: { - kind: 'duration', mods: { atkPct: num(bb.atk) }, - onAttack({ battle, unit }) { - if (!(sc > 0)) return; - for (const a of alliesInGridOf(battle, unit)) { - if (a === unit || a.kind !== 'op') continue; - battle.fx('sonic', { x: a.x, y: a.y, radius: rad, id: unit.id }); - for (const e of battle.foesInRadius(a.x, a.y, rad)) { - if (!e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, isSplash: true, tags: ['sonic'] }); - } - } - }, - }, - talents: [{ install(battle, unit) { - const v = num(t.atk); - if (v) toggleBuff(battle, unit, 'akkord:sync', () => alliesInGridOf(battle, unit).some((a) => a !== unit && a.kind === 'op'), { atkPct: v }); - if (tb.damage_scale != null) { - const mn = num(tb.min_dist), mx = num(tb.max_dist, 4), ds = num(tb.damage_scale); - onHitBy(battle, unit, ({ target, dmg }) => { - if (!dmg.isAttack) return; - const d = Math.hypot(target.x - unit.x, target.y - unit.y); - dmg.amount *= 1 + ds * (mx > mn ? Math.max(0, Math.min(1, (d - mn) / (mx - mn))) : 1); - }); - } - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_16 拉普兰德 狼魂 (AUTO, attack SP): ATK +atk, arts damage, one extra target, ranged attacks no longer reduced. - // 精神摧毁: attacks disable the target's abilities (silence) for `duration` s. Elite module (LOR-X, trait - // atk_scale_m): attacks add atk_scale_m × ATK arts damage. - // S1 日晷 (alt, attack SP, 持续时间无限 ⇒ toggle until she falls): ATK +atk and a `prob` chance to block (抵挡: the - // damage instance is negated) each physical damage an enemy deals her. - chess_char_2_16_a: (bb, chess) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - const trait = {}; - if (num(t.duration) > 0) trait.onHitStatus = { key: 'silence', duration: num(t.duration) }; - if (num(tb.atk_scale_m) > 0) { - trait.afterHit = (battle, u, target) => { - // PRTS 特性备注 "造成预计算的法术附加伤害": 附加伤害 (tag addition — no 叙拉古 6 roll) - if (target && target.alive && target.side === 'enemy') battle.dealDamage(u, target, { amount: u.s.atk * num(tb.atk_scale_m), type: 'arts', tags: ['module', 'addition'] }); - }; - } - return { - trait, - skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, targeting: { maxTargets: 2 }, attack: { dmgType: 'arts', dmgMul: () => 1 } }, - skills: { - skchr_whitew_1: { - kind: 'toggle', mods: { atkPct: num(bb.atk) }, - onStart({ battle, unit, skill }) { - if (unit.mem.sundial) battle.off(unit.mem.sundial); - const p = num(bb.prob); - unit.mem.sundial = p > 0 ? onHitOn(battle, unit, ({ source, credit, dmg }) => { // 抵挡 is target-side: a 无来源 burst counts via its credit - const src = source || credit; - if (!skill.active || dmg.cancel || dmg.type !== 'phys' || !src || src.side !== 'enemy') return; - if (battle.rng.chance(p)) { dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); } - }, 5) : null; - }, - onEnd({ battle, unit }) { - if (unit.mem.sundial) battle.off(unit.mem.sundial); - unit.mem.sundial = null; - }, - }, - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_17 折桠 生存决心: on start, ground enemies around her (skill grid) 战栗 (tremble: "被阻挡后无法进行普通攻击") for - // not_combat s; ATK +atk, DEF +def, hits every blocked enemy. 简易包扎: heals hp_ratio × max HP when the skill ends. - // Elite module (UNY-X, trait damage_scale): damage from enemies she blocks ×damage_scale. - // S1 绝境抵抗 (alt, TAKE_DAMAGE / hurt SP from data): DEF +def and 抵抗 (−one_minus_status_resistance: control - // statuses last half as long) for its duration; 简易包扎 heals at its end too. - chess_char_2_17_a: (bb, chess, def) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - return { - skill: { - kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, attack: { hitAllBlocked: true }, - onStart({ battle, unit }) { - const grid = def?.skill?.rangeGrid; - const foes = grid ? enemiesInGrid(battle, unit, grid, { canHitFly: false, groundOnly: true }) : battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !e.isFlying); - battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'resolve' }); - for (const e of foes) battle.applyStatus(e, 'tremble', { duration: num(bb.not_combat), source: unit }); - }, - }, - skills: { - skchr_branch_1: { - kind: 'duration', mods: { defPct: num(bb.def) }, - onStart({ battle, unit, skill }) { - const v = Math.min(0.95, Math.max(0, -num(bb.one_minus_status_resistance))); - if (v > 0) battle.applyStatus(unit, 'resist', { duration: skill.timeLeft, value: v, source: unit }); - }, - }, - }, - talents: [{ install(battle, unit) { - const hr = num(t.hp_ratio); - if (hr > 0) { - battle.on('skillEnd', ({ unit: u, reason }) => { - if (u === unit && reason !== 'death' && unit.alive) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true, tags: ['talent'] }); - }, { owner: unit }); - } - if (tb.damage_scale != null) onHitOn(battle, unit, ({ source, dmg }) => { if (source && source.blockedBy === unit) dmg.mul *= num(tb.damage_scale, 1); }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_18 灰毫 攻击力强化·γ型 (DEFAULT, like S2: the owner's deliberate deviation from the 重装 TAKE_DAMAGE row, data - // TRIGGER_DEVIATIONS, DESIGN §21.29): ATK +atk. 炮术研习: ATK +atk, or +ashlok_t_1.atk when the `cnt` orthogonal - // tiles around her are all ground (LOW). Elite module (FOR-X, trait atk_scale): vs blocked enemies ATK ×atk_scale. - // S2 专注轰击 (alt): block count 0 (noBlock: releases what she holds), only ranged (splash) attacks, base attack time - // +base_attack_time s (−0.4 / −0.5 on 2.8), ATK +atk. - chess_char_2_18_a: (bb, chess) => { - const t = talentBb(chess, 0); - const tb = traitBb(chess); - return { - skill: { kind: 'duration', mods: { atkPct: num(bb.atk) } }, - skills: { - skchr_ashlok_2: { - kind: 'duration', mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) }, flags: { noBlock: true }, - attack: { fortress: false, _fortressMelee: false }, - }, - }, - talents: [{ install(battle, unit) { - battle.on('deploy', ({ unit: u }) => { - if (u !== unit) return; - const r = unit.tileR, c = unit.tileC; - const low = [[1, 0], [-1, 0], [0, 1], [0, -1]].filter(([dr, dc]) => battle.grid.isLow(r + dr, c + dc)).length; - const ground = low >= num(t.cnt, 4); - const v = ground ? num(t['ashlok_t_1.atk'], num(t.atk)) : num(t.atk); - if (v) battle.addBuff(unit, { key: 'talent:ashlok', mods: { atkPct: v }, data: { ground }, tags: ['talent'] }); - }, { owner: unit }); - if (num(tb.atk_scale, 1) !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.blockedBy) dmg.amount *= num(tb.atk_scale, 1); }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 2_19 锡人 “大拉里”: see tinmanKit (tier1.js). - // S1 “老科利” (alt, attack SP, needs a target): throws an alchemy unit at the target (ground first); for - // projectile_delay_time s ground enemies within projectile_range tiles are 虚弱 (weaken −atk) and take atk_scale × - // ATK arts per second (ATK cached at the cast; damage over time, so the elite 凋敝魂灵 ×skill@damage_scale applies; - // the zone counts for the module's SP bonus and outlives her). - chess_char_2_19_a: (bb, chess, def) => { - const k = tinmanKit(bb, chess, def); - const dur = num(bb.projectile_delay_time, 8), radius = num(bb.projectile_range, 1), dmgScale = num(bb.atk_scale); - const weak = Math.min(1, Math.max(0, -num(bb.atk))); - const wither = num(talentBb(chess, 1)['skill@damage_scale'], 1); - const STEP = 0.25; // weaken refresh cadence (damage every 1 s) - return { - ...k, - skills: { - ...(k.skills || {}), - skchr_tinman_1: { - kind: instantKind(def), - onStart({ battle, unit }) { - const tgt = enemiesInGrid(battle, unit, null, { n: 1, groundOnly: true })[0] ?? enemiesInGrid(battle, unit, null, { n: 1 })[0]; - const x = tgt ? tgt.x : unit.x + unit.fwd[1], y = tgt ? tgt.y : unit.y + unit.fwd[0]; - const atk = unit.s.atk; - const n = Math.max(1, Math.round(dur / STEP)), per = Math.max(1, Math.round(1 / STEP)); - let i = 0; - unit.mem.tinZones = (unit.mem.tinZones ?? 0) + 1; - battle.fx('zone', { x, y, radius, dur, id: unit.id, skill: 'tinman1' }); - battle.every(STEP, (b, sc) => { - const pulse = i % per === 0; - for (const e of b.foesInRadius(x, y, radius)) { - if (e.isFlying || e.s.flags.untargetable) continue; - if (weak > 0) b.applyStatus(e, 'weaken', { duration: STEP + 0.05, value: weak, source: unit }); - if (wither > 1) b.addBuff(e, { key: 'tinman:wither', duration: STEP + 0.05, data: { mul: wither }, source: unit }); - if (pulse && dmgScale > 0) b.dealDamage(unit, e, { amount: atk * dmgScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] }); - } - if (++i >= n) { sc.cancel(); unit.mem.tinZones = Math.max(0, (unit.mem.tinZones ?? 1) - 1); } - }, { immediate: true }); - }, - }, - }, - }; - }, -}; +export default TIER_KITS[1]; diff --git a/server/sim/content/kits/tier3.js b/server/sim/content/kits/tier3.js index d8c315e2..2a2ac5b5 100644 --- a/server/sim/content/kits/tier3.js +++ b/server/sim/content/kits/tier3.js @@ -1,1801 +1,6 @@ -// server/sim/content/kits/tier3.js — hand-authored kits for the 21 tier-3 chess (19 visible + 见行者/巫恋 hidden). -// -// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). `bb` is the skill blackboard at the -// chess's level (normal Lv4 `_a` / elite Lv7 `_b`), `chess` the data/chess.json record (talents incl. module upgrades, -// trait incl. module trait bb), `def` the normalised def. Every number comes from a blackboard; the few values that -// only exist in the text ("三连击", "至多5个", "初始两只", "至多3只") are parsed from the description with fallbacks. -// Profession defaults (server/sim/professions.js) are kept and extended: fastshot fly bonus, instructor, reaperrange, -// hunter ammo, funnel ramp, phalanx guard, merchant drain, tactician reinforcement, underminer module weaken. -// Summons: the match hands 伺夜's 狼群 and 巫恋's 诅咒娃娃 to the player as placeable board pieces (tokens.js kits); -// 伺夜's kit adopts his pack piece instead of adding a second pack, 巫恋's S2 brings her placed doll onto its tile -// (tokens.js releaseSkillSummon), and every summon tile avoids the home tile of an ally that has not (re)deployed yet -// (freeTile). Offensive skills whose own range differs from the attack -// range trigger on that skill range (CUSTOM_RANGE: 松果 shortened range, 见行者 push row — research 03 §1.4). -// -// Client VFX (battle.fx kinds used here, all `{x, y, id, …extra}`): 'aoe' (radius, dmgType), 'strike' (special single -// hit), 'volley' (multi-target shot), 'push', 'pull', 'summon' (token), 'coin' (n), 'dp' (n), 'revive', 'buff', -// 'shield', 'shieldBreak', 'camouflage', 'explode'. -// -// Operator loadouts (DESIGN §16): the kit is built for the SELECTED skill (`bb`, `chess.skill`, `def.skill`) and module -// (talents / trait bb). `skill` is the default skill's spec; `skills: { [skillId]: SkillSpec }` holds every other -// selectable skill of the visible chess, each built from its OWN record at the chess's level (skillData: Lv4 normal / -// Lv7 elite blackboard, range, charges) — the loader picks the selected one. Talents / trait / install are shared: the -// parts that belong to one skill only (琳琅诗怀雅 bombs, 菲莱 counters, 雪猎 special bullets…) check the selected skill id. -// Triggers come from the data record of each skill (skills[i].trigger: tools/build-data.mjs resolveTrigger — the official -// 技能策略 incl. SKILL_RANGE for a MANUAL skill's own 技能范围 and the class rows for every MANUAL skill), except -// 薄绿 S1 (the 阵法术师 row SEARCH = "在初始攻击范围内存在敌人时" ⇒ the engine's DEFAULT for a phalanx, checked every -// tick) and the kits' own automatic casts (雪猎 special bullets). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment), -// 斯卡蒂 DRE-X (× vs blocked), 瑕光 GUA-X (heal × under 50 %), 伺夜 TAC-Y (×165 % trait, pack-blocked enemies taunt +1), -// 空弦 MAR-X (fly ×, profession layer). No in-battle effect here: 忍冬 SOL-Y "首次部署时部署费用-4" (the initial -// deployment is free) and the 集成战略-only ISW-A modules of 琳琅诗怀雅 / 空弦 (their stats and trait cost still apply). +// server/sim/content/kits/tier3.js — re-export shim (0.2.0 refactor, R4): the tier-3 kits live one per file in ops/, +// registered by index.js; their shared helpers and notes are in shared/tier3.js (README.md). This module keeps the +// old imports working until the shims are removed after the 0.2.0 refactor wave. +import { TIER_KITS } from './index.js'; -import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js'; -import { COLS, PUSH_DIRECTIONAL_MIN_DIST } from '../../constants.js'; -import { bodyDist, bodyInKeys, bodyOnTile } from '../../body.js'; -import { normalizeChess, normalizeSkill } from '../../simdata.js'; -import { tacticalPoint as sharedTacticalPoint, releaseSkillSummon } from '../tokens.js'; - -// --------------------------------------------------------------------------------------------------------------- -// helpers - -const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); -const defOf = (chess, def) => def ?? normalizeChess(chess); -const talentBb = (d, i) => d?.talents?.[i]?.bb ?? {}; -const traitBb = (d) => d?.traitBb ?? {}; -/** Module-only talent (data: `hidden && fromModule`, e.g. 空弦 精锐 "范围内存在地面敌人时攻击速度+8"). */ -const moduleTalentBb = (chess) => (chess?.talents ?? []).find((t) => t && t.hidden && t.fromModule)?.bb ?? null; -/** Id of the SELECTED skill the kit is built for (DESIGN §16: `bb`, `chess.skill` and `def.skill` belong to it). */ -const selectedId = (chess, d) => d?.skill?.id ?? chess?.skill?.skillId ?? null; -/** - * Normalised record (simdata normalizeSkill shape: bb, rangeGrid, maxCharges, duration, description, trigger) of the - * selectable skill `id` at this chess's level: the selected one is `def.skill` with the kit's `bb`, any other comes - * from `chess.skills[]` (DATA §2.2) — every spec of `skills` is built from its own numbers whichever skill is selected. - */ -function skillData(chess, d, bb, id) { - if (id != null && selectedId(chess, d) === id && d?.skill) return { ...d.skill, bb: bb ?? d.skill.bb ?? {} }; - const rec = (chess?.skills ?? []).find((s) => s && s.skillId === id); - return (rec && normalizeSkill({ skill: rec })) || { id, bb: {}, rangeGrid: null, maxCharges: 1, duration: 0, description: '' }; -} -/** `{ [skillId]: SkillSpec }` of the non-default selectable skills: `builders[id](skillData(id))` each. */ -function altSkills(chess, d, bb, builders) { - const out = {}; - for (const [id, build] of Object.entries(builders)) out[id] = build(skillData(chess, d, bb, id)); - return out; -} -/** instant, or charges when the skill stores several (可充能N次). */ -const instantKindOf = (s) => ((s?.maxCharges ?? 1) > 1 ? 'charges' : 'instant'); -/** Timed stat skill (迅捷打击 / 攻击力强化 / 防御力强化 …): bb atk / def / max_hp / attack_speed on the operator. */ -const statSkill = (s) => { - const b = s.bb || {}; - const mods = {}; - if (num(b.atk)) mods.atkPct = num(b.atk); - if (num(b.def)) mods.defPct = num(b.def); - if (num(b.max_hp)) mods.hpPct = num(b.max_hp); - if (num(b.attack_speed)) mods.aspd = num(b.attack_speed); - return { kind: 'duration', mods }; -}; -/** The unit's own profile damage multiplier on `target` (fastshot fly bonus …) for damage a kit deals itself. */ -function profileMul(battle, unit, target) { - const f = unit.profile?.dmgMul; - const m = typeof f === 'function' ? f(battle, unit, target) : f; - return typeof m === 'number' && Number.isFinite(m) ? m : 1; -} -/** Targetable enemies within `r` tiles of (x, y), nearest first (spawn order breaks ties), excluding `skip`. */ -function enemiesAround(battle, unit, x, y, r, skip = null) { - const out = battle.foesInRadius(x, y, r).filter((e) => !(skip && skip.has(e)) && canTargetEnemy(unit, e, { canHitFly: true })); - const d = (e) => bodyDist(e, x, y); - return out.sort((a, b) => d(a) - d(b) || (a.spawnSeq ?? a.id) - (b.spawnSeq ?? b.id)); -} -/** - * "范围内存在地面敌人时攻击速度+N" (能天使 MAR-Y trait, 空弦 MAR-Y talent): ASPD buff `key` while at least `cnt` ground - * enemies stand in the current range. - */ -function groundAspd(key, aspd, cnt = 1) { - return (battle, unit) => { - let on = false; - battle.every(0.1, () => { - const n = alive(unit) ? battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).filter((e) => !e.isFlying).length : 0; - const want = n >= Math.max(1, cnt); - if (want === on && (!want || unit.findBuff(key))) return; - on = want; - if (want) battle.addBuff(unit, { key, mods: { aspd } }); - else battle.removeBuff(unit, key); - }, { owner: unit }); - }; -} -/** 周围 (around a target) for 空弦's scatter / bounce arrows [ASSUMED radius, tiles: not in the data]. */ -const AROUND_R = 1.5; -const alive = (u) => !!(u && u.alive && u.deployed); -const tileKeyOf = (u) => (u.side === 'ally' ? u.tileR * COLS + u.tileC : Math.round(u.y) * COLS + Math.round(u.x)); -/** On a tile of `set`: an ally by its tile, an enemy by its body (every tile a huge enemy occupies — sim/body.js). */ -const onTiles = (x, set) => (x.side === 'ally' ? set.has(tileKeyOf(x)) : bodyInKeys(x, set)); -const gridKeys = (grid, u, ext = 0) => absoluteRangeKeys(grid || [[0, 0]], u.tileR, u.tileC, u.dir, ext); -const fx = (battle, kind, u, extra = {}) => battle.fx(kind, { x: u.x, y: u.y, id: u.id, ...extra }); -const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null); -const NINE = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]; -/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */ -const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]); -/** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */ -const COLLIDE = 0.6; -/** 忍冬 S3's 迷彩 (until her next cast): its own buff key, never merged with another unit status. */ -const CAMOU_KEY = 'vulpis:camou'; -const CN_NUM = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 }; -/** Number captured by `re` (arabic or a single chinese numeral) in `text`, else `fallback`. */ -function textNum(text, re, fallback) { - const m = String(text ?? '').match(re); - if (!m) return fallback; - return /^\d+(\.\d+)?$/.test(m[1]) ? +m[1] : (CN_NUM[m[1]] ?? fallback); -} -/** - * A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out - * operator lying there, not the home tile of an ally that has not deployed yet / waits to redeploy — the initial - * deployment runs one unit after another: a summon placed while it runs must not steal a later board unit's tile). - */ -function freeTile(battle, r, c) { - return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c); -} -/** Walkable ground tile a melee summon can stand on. */ -const groundTile = (battle, r, c) => battle.grid.groundPassable(r, c) && battle.grid.canStand(r, c); -/** - * Tactical point (战术点) of a tactician: `prefer` (the board piece's tile, i.e. the player's choice) when usable — - * free, standable ground (never 深水区: grid.canStand) inside her initial range ("只能部署在召唤者攻击范围内"; the prep - * already keeps the piece there, PlayerState._legal) —, else the shared tactical point (tokens.js tacticalPoint = - * Battle.findTacticalPoint: a free walkable tile of its initial range on an enemy ground path first, then the nearest). - */ -function tacticalPoint(battle, unit, prefer = null) { - const inRange = (r, c) => (unit.baseRangeKeys || unit.rangeKeys || []).includes(r * COLS + c); - if (prefer && freeTile(battle, prefer[0], prefer[1]) && groundTile(battle, prefer[0], prefer[1]) && inRange(prefer[0], prefer[1])) return prefer; - return sharedTacticalPoint(battle, unit); -} -/** Tokens `tokenId` summoned by / placed for `owner` (board pieces included). */ -const tokensOf = (battle, owner, tokenId) => battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === tokenId && t.ownerUnit === owner && !t.mem.isClone); - -/** Non-elite, non-leader enemy (海霓 "非精英和领袖敌人"). */ -const isNormalEnemy = (e) => !e.isBoss && String(e.def?.rank ?? 'NORMAL').toUpperCase() === 'NORMAL'; -/** Abyssal Hunters (character_table groupId `abyssal`, not in data/chess.json): 深海掠食者 targets. */ -const ABYSSAL = new Set(['char_263_skadi', 'char_143_ghost', 'char_218_cuttle', 'char_474_glady', 'char_1023_ghost2', 'char_4145_ulpia']); - -/** Targetable enemies on `keys`, sorted by the unit's priority (at most `n` when n > 0). */ -function enemiesOn(battle, unit, keys, n = 0, profile = null) { - const list = battle.enemiesInKeys(keys, unit, profile ?? { canHitFly: true }); - sortEnemyTargets(battle, unit, list, (profile ?? unit.profile)?.priority ?? null); - return n > 0 && list.length > n ? list.slice(0, n) : list; -} - -/** - * Tile aura: every `interval` s (0 ⇒ call the returned update() yourself, e.g. from a skill onTick) buffs the - * units of `side` standing on `tiles()` (filter/mods per unit, mods null ⇒ skipped) and drops the buff from units - * that left. update.clear() removes everything this aura applied. Same-key auras of several sources do not stack. - */ -function aura(battle, unit, o) { - let cur = new Set(); - const iv = o.interval ?? 0.2; - const dur = Math.max(iv * 2, 0.1) + 0.05; - const drop = (x) => { const b = x.findBuff(o.key); if (b && b.source === unit) battle.removeBuff(x, b); }; - const update = () => { - const next = new Set(); - if (alive(unit) && (!o.active || o.active())) { - const keys = o.tiles(); - const set = keys instanceof Set ? keys : new Set(keys || []); - const list = o.side === 'ally' ? battle.allyUnits : battle.enemies; - for (const x of list) { - if (!x.alive || !x.deployed || x.hidden || x.kind === 'device') continue; - if (o.side === 'ally' && !battle.allySelectable(x, unit)) continue; // never a 孤立 unit (炎佑) - if (!onTiles(x, set) || (o.filter && !o.filter(x))) continue; - const mods = typeof o.mods === 'function' ? o.mods(x) : o.mods; - if (!mods) continue; - battle.addBuff(x, { key: o.key, duration: dur, refresh: 'extend', mods, source: unit, tags: ['aura'] }); - next.add(x); - } - } - for (const x of cur) if (!next.has(x)) drop(x); - cur = next; - }; - update.clear = () => { for (const x of cur) drop(x); cur = new Set(); }; - // immediate: installed at construction ⇒ first runs right after the initial deployment (t = 0), not 1 interval later - if (iv > 0) battle.every(iv, update, { owner: unit, immediate: true }); - return update; -} - -/** Buff present exactly while `cond()` holds (checked every tick). */ -function whileTrue(battle, unit, key, cond, mods) { - battle.on('tick', () => { - const on = alive(unit) && cond(); - const has = !!unit.findBuff(key); - if (on && !has) battle.addBuff(unit, { key, mods: typeof mods === 'function' ? mods() : mods }); - else if (!on && has) battle.removeBuff(unit, key); - }, { owner: unit }); -} - -/** Team-wide talents never stack: only the lowest-id living carrier of `tag` of this player applies them. */ -function isLeader(battle, unit, tag) { - let best = null; - for (const a of battle.allyUnits) if (alive(a) && a.ownerId === unit.ownerId && a.mem[tag] && (!best || a.id < best.id)) best = a; - return best === unit; -} - -/** SP gain that respects "no SP while a timed skill runs". */ -function giveSp(u, n, reason = 'talent') { - const sk = u?.skill; - if (!sk || sk.noSkill || !(n > 0) || (sk.active && sk.isTimed)) return 0; - return sk.gainSp(n, reason); -} - -/** - * Funnel ramp tracked per target (耶拉's 2 drones each ramp on their own lock; 1 drone = profession behaviour). Two - * drones locked on the same enemy land in the same tick: they ramp in parallel (one step per volley, same scale). - */ -function funnelMap(battle, unit, target) { - const f = unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; - const m = unit.trait.funnelMap || (unit.trait.funnelMap = new Map()); - const prev = m.get(target.id); - if (prev && prev.t === battle.time) return prev.s; - const s = prev == null ? f.init : Math.min(f.max, prev.s + f.delta); - m.set(target.id, { s, t: battle.time }); - return s; -} -/** Drones return when their lock is dropped: forget ramps of enemies no longer attacked. */ -function installFunnelPrune(battle, unit) { - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !unit.trait.funnelMap) return; - const ids = new Set(ctx.targets.map((t) => t.id)); - for (const k of [...unit.trait.funnelMap.keys()]) if (!ids.has(k)) unit.trait.funnelMap.delete(k); - }, { owner: unit, priority: -100 }); -} - -/** Merchant trait with a callback on every successful DP payment (琳琅诗怀雅 大买家). Same rules as professions.js. */ -function merchantInstall(onPay) { - return (battle, unit) => { - const iv = unit.profile.merchantInterval ?? 3; - const cost = unit.profile.merchantCost ?? 3; - battle.every(iv, () => { - if (!alive(unit)) return; - const pl = battle.getPlayer(unit.ownerId); - if (!pl) return; - if (pl.dp >= cost) { - battle.addDp(unit.ownerId, -cost); - onPay(battle, unit, cost); - battle.emit('merchantPay', { unit, cost }); - } else battle.retreat(unit, { reason: 'merchant' }); - }, { owner: unit }); - }; -} - -// --------------------------------------------------------------------------------------------------------------- -// kits - -const KITS = { - // ---- 3_01 能天使 · 速射手 — S3 过载模式: 5 连射 + 攻击间隔缩短; 快速弹匣 ASPD; 天使的祝福 (self + one random ally) - // S1 冲锋模式: next attack = 3 shots × atk_scale; S2 扫射模式: 4 shots × attack@atk_scale per attack for 15 s; - // 精锐 module MAR-Y: ASPD + while a ground enemy is in range (MAR-X fly × = profession layer) - chess_char_3_01_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0), t1 = talentBb(d, 1); - const tb = traitBb(d); - const bless = { atkPct: num(t1.atk), hpPct: num(t1.max_hp) }; - const shots = (v) => Math.max(1, Math.floor(num(v, 1))); - const kit = { - skill: { - kind: 'duration', - mods: { batPct: num(bb.base_attack_time) }, - attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: Math.max(1, Math.floor(num(bb['attack@times'], 1))) }, - }, - skills: altSkills(chess, d, bb, { - skchr_angel_1: (s) => ({ kind: instantKindOf(s), attack: { atkScale: num(s.bb.atk_scale, 1), hits: shots(s.bb.times) } }), - skchr_angel_2: (s) => ({ kind: 'duration', attack: { atkScale: num(s.bb['attack@atk_scale'], 1), hits: shots(s.bb['attack@times']) } }), - }), - talents: [ - { install(battle, unit) { battle.addBuff(unit, { key: 'talent:angel_mag', mods: { aspd: num(t0.attack_speed) }, persist: true, allowDead: true }); } }, - { install(battle, unit) { - battle.addBuff(unit, { key: 'talent:angel_bless', mods: { ...bless }, persist: true, allowDead: true }); - battle.on('deploy', (ctx) => { - if (ctx.unit !== unit) return; - // after the whole board is deployed (the initial deployment goes one unit after another) - battle.after(0, () => { - if (!alive(unit)) return; - const cands = battle.allies(unit.ownerId).filter((a) => a !== unit && a.kind === 'op' && !a.findBuff('talent:angel_bless_ally')); - const pick = battle.rng.pick(cands); - if (!pick) return; - battle.addBuff(pick, { key: 'talent:angel_bless_ally', mods: { ...bless }, persist: true, source: unit, visible: true }); - fx(battle, 'buff', pick, { src: unit.id, talent: 'angel_bless' }); - }, { owner: unit }); - }, { owner: unit }); - } }, - ], - }; - // MAR-Y trait "范围内存在地面敌人时攻击速度+8" (bb attack_speed, cnt = ground enemies needed) - if (num(tb.attack_speed) > 0) kit.install = groundAspd('trait:angel_ground', num(tb.attack_speed), Math.floor(num(tb.cnt, 1))); - return kit; - }, - - // ---- 3_02 断崖 · 领主 — S2 浮游刃启动: arts, wider range, each attack hits every enemy blocked by allies around her - chess_char_3_02_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const tb = traitBb(d); - const tGrid = copyGrid(d.talents?.[0]?.rangeGrid) ?? NINE; - const extra = num(bb.atk_scale, 1); - const moduleArts = num(tb.atk_scale_m, 0); - const skillGrid = copyGrid(d.skill?.rangeGrid); - const kit = { - skill: { - kind: 'duration', - attack: { dmgType: 'arts', atkScale: num(bb['attack@atk_scale'], 1) }, - onAttack({ battle, unit }) { - // "周围8格友方单位阻挡的所有敌人": the 8 tiles around her (her own tile is not one of them — the talent - // says "自身与周围8格" when it means both) - const seen = new Set(); - for (const a of battle.allyUnits) { - if (a === unit || !alive(a) || a.kind === 'device' || !a.blocking.length) continue; - if (Math.abs(a.tileR - unit.tileR) > 1 || Math.abs(a.tileC - unit.tileC) > 1) continue; - for (const e of a.blocking) { - if (!e.alive || seen.has(e)) continue; - seen.add(e); - battle.dealDamage(unit, e, { amount: unit.s.atk * extra, type: 'arts', isSkill: true, tags: ['skill', 'melee', 'ayerBlade'] }); - fx(battle, 'strike', e, { src: unit.id, skill: 'ayer_2' }); - } - } - }, - }, - // S1 多导向散射弹丸: next attack hits up to max_target enemies in range for atk_scale arts + 停顿 - skills: altSkills(chess, d, bb, { - skchr_ayer_1: (s) => { - const slug = num(s.bb.sluggish, 0); - return { - kind: instantKindOf(s), - targeting: { maxTargets: Math.max(1, Math.floor(num(s.bb.max_target, 1))) }, - attack: { - dmgType: 'arts', atkScale: num(s.bb.atk_scale, 1), - onEachHit({ battle, unit, target }) { - if (slug > 0 && target && target.alive && target.side === 'enemy') battle.applyStatus(target, 'sluggish', { duration: slug, source: unit }); - }, - }, - }; - }, - }), - talents: [{ install(battle, unit) { - aura(battle, unit, { key: 'talent:ayer_aspd', side: 'ally', tiles: () => gridKeys(tGrid, unit), filter: (a) => a.kind === 'op', mods: { aspd: num(t0.attack_speed) } }); - } }], - }; - if (skillGrid) kit.skill.targeting = { rangeGrid: skillGrid }; - // 精锐 module LOR-X: "攻击附带10%攻击力的法术伤害" - if (moduleArts > 0) { - kit.trait = { afterHit: (battle, unit, target) => { - if (target && target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * moduleArts, type: 'arts', tags: ['module'] }); - } }; - } - return kit; - }, - - // ---- 3_03 诗怀雅 · 教官 — S2 协同作战: ATK +, first talent ×talent_scale; 近距离作战指导 melee ATK aura - // S1 指挥调度: the talent covers the skill's range (x-1 normal / x-2 elite) at ×talent_scale while it runs - chess_char_3_03_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const tGrid = copyGrid(d.talents?.[0]?.rangeGrid) ?? NINE; - const scale = num(bb.talent_scale, 1); - // "第一天赋生效范围扩大" (talent_range_flag): the selected skill's range replaces the talent's while it is active - const skillTGrid = num(bb.talent_range_flag) > 0 ? copyGrid(d.skill?.rangeGrid) : null; - const refresh = ({ unit }) => unit.mem.swireAura?.(); - return { - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - onStart: refresh, - onEnd: refresh, - }, - skills: altSkills(chess, d, bb, { - skchr_swire_1: () => ({ kind: 'duration', onStart: refresh, onEnd: refresh }), - }), - talents: [{ install(battle, unit) { - unit.mem.swireAura = aura(battle, unit, { - key: 'talent:swire_guide', side: 'ally', tiles: () => gridKeys(skillTGrid && unit.skill?.active ? skillTGrid : tGrid, unit), - filter: (a) => String(a.def?.position ?? 'MELEE').toUpperCase() === 'MELEE', - mods: () => ({ atkPct: num(t0.atk) * (unit.skill?.active ? scale : 1) }), - }); - } }], - }; - }, - - // ---- 3_04 琳琅诗怀雅 · 行商 — S2 “见面礼” (passive): each attack spends a coin to drop a champagne bomb in range; - // 大买家: coin at skill start + coin & ATK stack per trait payment; 破财消灾: DP-paid revive (cost doubles) - // S1 仗义疏财 (passive, 2 coins): a coin heals the most injured ally (< 70 % HP) of the 8 surrounding tiles for - // attack@heal_scale × ATK — on her attack, or with no enemy to attack on her own attack timer (owner's decision - // 2026-10-04, against the official 「下一次攻击会为…」; installS1). S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin; - // closing it spends every coin on random ground enemies of range 2-4 in front and those she blocks (atk_scale phys + - // a small push, radial despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2: - // the RandomGold ability (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位, - // 弹道不可对空). It does not close itself when the purse is full (PRTS 卫戍协议/帮助 「通常不会自动关闭技能」; the skill - // text is 「可随时主动关闭」). Owner 2026-10-04: at the cap (金币上限为10) she shoots once an enemy is in the skill's - // attack range (the 1-tile range, not the coin-mark 2-4 grid). Below the cap, or with nobody there, it stays open. - // 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks). - chess_char_3_04_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0), t1 = talentBb(d, 1); - const sel = selectedId(chess, d); - const S1 = 'skchr_swire2_1', S2 = 'skchr_swire2_2', S3 = 'skchr_swire2_3'; - // "携带此技能时金币上限为N": the SELECTED skill's cap - const coinMax = num(bb.sp, 3); - const coinCost = Math.abs(num(bb['attack@sp'], -1)) || 1; - const modTal = moduleTalentBb(chess); - const scale = num(bb.atk_scale, 1); - const slug = num(bb.sluggish, 2); - const tokenId = chess?.skill?.overrideTokenKey ?? (d.tokens || []).find((t) => /gdtrap/.test(String(t))) ?? 'token_10031_swire2_gdtrap'; - const skillGrid = copyGrid(d.skill?.rangeGrid); - const addCoins = (battle, unit, n) => { - const before = unit.mem.coins ?? 0; - unit.mem.coins = Math.min(coinMax, before + n); - if (unit.mem.coins > before) fx(battle, 'coin', unit, { n: unit.mem.coins }); - }; - const bombKit = (owner, switchT) => ({ - skill: null, - trait: { noAttack: true }, - install(battle, bomb) { - battle.on('tick', () => { - if (!bomb.alive || !bomb.deployed) return; - for (const e of battle.enemies) { - if (!e.alive || e.hidden || e.isFlying || e.s.flags.untargetable) continue; - if (!bodyOnTile(e, bomb.tileR, bomb.tileC)) continue; - // first enemy touching it; after switchT s on the field the bomb deals its damage one extra time - const hits = battle.time - bomb.deployedAt >= switchT - 1e-9 ? 2 : 1; - for (let i = 0; i < hits && e.alive; i++) { - battle.dealDamage(owner, e, { amount: owner.s.atk * scale, type: 'phys', isSkill: true, canDodge: false, tags: ['skill', 'trap'] }); - } - if (e.alive) battle.applyStatus(e, 'sluggish', { duration: slug, source: owner }); - // `consumed`: the bomb is used up by its own blast — clients play its impact sound, not a death sound - fx(battle, 'explode', bomb, { src: owner.id, target: e.id, hits, consumed: true }); - battle.retreat(bomb, { reason: 'expired', permanent: true }); - break; - } - }, { owner: bomb }); - }, - }); - const healRatio = textNum(d.skill?.description, /血量不足(\d+)%/, 70) / 100; - /** - * 仗义疏财 — official text 「消耗一枚金币,下一次攻击会为周围八格内血量不足70%的一名友方单位恢复相当于攻击力40%的生命」: the heal rides on an - * attack, so with no enemy around she never healed (community report #5 「琳琅诗怀雅1技能不会主动奶身边受伤的干员」). Owner's - * decision 2026-10-04 (a deliberate deviation, like §21.29's 重装 casts): she heals an injured ally beside her whether - * she attacks or not. Same target (the lowest HP ratio below 70 % of the 8 surrounding tiles, no 禁疗 / 孤立 unit, no - * device), coin and heal_scale × ATK. Cadence [ASSUMED]: at most one heal per attack cycle (her attack interval, ASPD - * included) — while she attacks, on the attack as before; when her last attack attempt found no target, on her own - * timer (the 'tick' hook runs after the attacks, so an attack due in the same tick takes it). - */ - const installS1 = (battle, unit) => { - const hs = num(bb['attack@heal_scale'], num(bb.heal_scale, 0)); - let nextAt = -Infinity; - const healTarget = () => { - let best = null; - for (const a of battle.allyUnits) { - if (a === unit || !alive(a) || a.hidden || a.kind === 'device' || a.hpRatio >= healRatio) continue; - if (a.s.flags.noHeal || a.profile?.noHeal) continue; // 禁疗 / 孤立: never a heal target - if (Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC)) !== 1) continue; // 周围八格 - if (!best || a.hpRatio < best.hpRatio || (a.hpRatio === best.hpRatio && a.deploySeq < best.deploySeq)) best = a; - } - return best; - }; - const tryHeal = () => { - if (!alive(unit) || (unit.mem.coins ?? 0) < coinCost || !(hs > 0) || battle.time < nextAt - 1e-9) return; - const best = healTarget(); - if (!best) return; - unit.mem.coins -= coinCost; - nextAt = battle.time + unit.s.interval; - battle.heal(unit, best, unit.s.atk * hs); - fx(battle, 'coin', unit, { n: unit.mem.coins, heal: best.id, skill: 'swire2_1' }); - }; - battle.on('attack', (ctx) => { if (ctx.attacker === unit) tryHeal(); }, { owner: unit, priority: -10 }); - battle.on('tick', () => { if (unit.deployed && unit.canAct && !unit.trait?.hadTarget) tryHeal(); }, { owner: unit }); - }; - const installS3 = (battle, unit) => { // 千金一掷: "击倒敌人时获得一枚金币" - battle.on('kill', (ctx) => { - if (ctx.killer === unit && ctx.victim.side === 'enemy' && unit.skill?.active) addCoins(battle, unit, 1); - }, { owner: unit }); - }; - return { - skill: { kind: 'passive' }, - skills: altSkills(chess, d, bb, { - [S1]: () => ({ kind: 'passive' }), // (coins → heals: installS1) - [S3]: (s) => { - const cash = num(s.bb.atk_scale, 1), force = num(s.bb.force, 0); - // the 【金币标记】 targets: ground enemies on range 2-4 in front of her (range_table "2-4") and every unit she blocks - const coinMarks = (battle, unit) => { - const list = enemiesOn(battle, unit, gridKeys(SWIRE2_COIN_GRID, unit), 0, { ...unit.profile, canHitFly: false }); - for (const e of unit.blocking || []) if (e.alive && !e.isFlying && !list.includes(e)) list.push(e); - return list; - }; - const purseCap = Math.max(1, Math.floor(num(s.bb.sp, 10))); - return { - kind: 'toggle', - attack: { hits: 2 }, - // Owner 2026-10-04: at the cap, shoot once a ground enemy is in the skill attack range (data rangeGrid, - // the same 1-tile range as her attack). The coin marks (前方 2-4) are who the burst pays, not the trigger. - // Nobody in that range: stay open and keep the coins. Below the cap: stay open. - onTick({ battle, unit, skill }) { - if (!skill.active || (unit.mem.coins ?? 0) + 1e-9 < purseCap) return; - const keys = gridKeys(skillGrid ?? unit.rangeGrid, unit); - if (enemiesOn(battle, unit, keys, 0, { ...unit.profile, canHitFly: false }).length) skill.end('manual'); - }, - onEnd({ battle, unit, reason }) { - if (reason !== 'manual' || !unit.alive) return; - const n = unit.mem.coins ?? 0; - unit.mem.coins = 0; - let spent = 0; - // PRTS 备注: closing it "立即对前方范围2-4内的地面敌方单位与自身阻挡的所有单位施加【金币标记】", the coins going to - // marked units at random — 弹道(不可对空): air units (FLY, 近地悬浮, 浮空) are never paid. Until 0.1.1 the - // coins went to her attack range (1-1) instead of range 2-4. - const marked = coinMarks(battle, unit); - for (let i = 0; i < n; i++) { - const e = battle.rng.pick(marked.filter((x) => x.alive && !x.isFlying)); - if (!e) break; - spent++; - battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] }); - // "将目标小力地向前推开" — PRTS 备注 "金币弹道…推开效果为径向推动", client knockback[relative]: a radial push - // away from her centre (not along her direction, so no 45° / 0.25-tile 特殊修正), official 力度 − 重量 - // distance (Battle.push) - if (e.alive) battle.push(e, force, { from: unit }); - } - fx(battle, 'coin', unit, { n: 0, spent, skill: 'swire2_3' }); - }, - }; - }, - }), - trait: { install: merchantInstall((battle, unit) => { - // MER-Y "每次特性消耗费用时攻击力+4%,最多可以叠加5次" (any time, not only during the skill) - if (modTal && num(modTal.atk) > 0) { - battle.addBuff(unit, { key: 'trait:swire2_module', refresh: 'stack', stacks: 1, maxStacks: Math.max(1, num(modTal.max_stack_cnt, 5)), mods: { atkPct: num(modTal.atk) } }); - } - if (!unit.skill?.active) return; // "技能期间" - addCoins(battle, unit, num(t0.trait_sp, 1)); - battle.addBuff(unit, { key: 'talent:swire2_buyer', refresh: 'stack', stacks: 1, maxStacks: Math.max(1, num(t0.max_stack_cnt, 8)), mods: { atkPct: num(t0.atk) } }); - }) }, - install(battle, unit) { - if (sel === S1) { installS1(battle, unit); return; } - if (sel === S3) { installS3(battle, unit); return; } - if (sel !== S2 && sel != null) return; - const switchT = num(battle.tokenDef(tokenId, unit)?.skill?.bb?.duration_switch, 3); - battle.on('attack', (ctx) => { - if (ctx.attacker !== unit || !alive(unit) || (unit.mem.coins ?? 0) < coinCost) return; - const tiles = []; - for (const k of gridKeys(skillGrid ?? unit.rangeGrid, unit)) { - const r = (k / COLS) | 0, c = k % COLS; - // (never on the home tile of a dead operator: it could not redeploy until an enemy triggers the bomb) - if (freeTile(battle, r, c) && groundTile(battle, r, c)) tiles.push([r, c]); - } - const tile = battle.rng.pick(tiles); - if (!tile) return; - const bomb = battle.spawnToken(unit, tokenId, tile[0], tile[1], { untargetable: true, kit: bombKit(unit, switchT) }); - if (!bomb) return; - unit.mem.coins -= coinCost; - fx(battle, 'summon', bomb, { src: unit.id, token: tokenId, coins: unit.mem.coins }); - }, { owner: unit, priority: -10 }); - }, - talents: [ - { install(battle, unit) { // 大买家: "开启技能时获得1枚金币" (a passive starts at every deployment, S3 when cast) - battle.on('deploy', (ctx) => { - if (ctx.unit !== unit) return; - unit.mem.coins = 0; - if (unit.skill?.kind === 'passive') addCoins(battle, unit, num(t0.sp, 1)); - }, { owner: unit }); - battle.on('skillStart', (ctx) => { if (ctx.unit === unit && unit.skill?.kind !== 'passive') addCoins(battle, unit, num(t0.sp, 1)); }, { owner: unit }); - } }, - { install(battle, unit) { // 破财消灾 - battle.on('fatal', (ctx) => { - if (ctx.unit !== unit || ctx.prevented) return; - const n = unit.mem.saveCount ?? 0; - const cost = Math.abs(num(t1.cost, -5)) * Math.pow(num(t1.cost_multi, 2), n); - const pl = battle.getPlayer(unit.ownerId); - if (!pl || pl.dp + 1e-9 < cost) return; - battle.addDp(unit.ownerId, -cost); - unit.mem.saveCount = n + 1; - ctx.prevented = true; - unit.hp = Math.max(1, unit.s.maxHp * num(t1.hp_ratio, 0.7)); - fx(battle, 'revive', unit, { cost }); - }, { owner: unit }); - } }, - ], - }; - }, - - // ---- 3_05 斯卡蒂 · 无畏者 — S3 涌潮悲歌: ATK/DEF/HP +; 深海掠食者 (Abyssal Hunters ATK); 迅捷出击 (redeploy −10 s) - // 精锐 module DRE-Y: once per deployment, a lethal hit instead restores full HP with max HP −60 % and ASPD +30 - // S1 迅捷打击·γ型: ATK/ASPD +; S2 跃浪击 (passive): ATK + for `duration` s after every deployment; - // 精锐 module DRE-X: ×atk_scale on blocked enemies - chess_char_3_05_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0), t1 = talentBb(d, 1); - const tb = traitBb(d); - const kit = { - skill: { kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def), hpPct: num(bb.max_hp) } }, - skills: altSkills(chess, d, bb, { - 'skcom_quickattack[3]': statSkill, - skchr_skadi_2: (s) => ({ - kind: 'passive', - onStart({ battle, unit }) { // "部署后N秒内攻击力+X" (a passive starts at every deployment) - battle.addBuff(unit, { key: 'skill:skadi_wave', duration: num(s.bb.duration, 20), mods: { atkPct: num(s.bb.atk) }, visible: true }); - }, - }), - }), - talents: [ - { install(battle, unit) { // "编入队伍时": every Abyssal Hunter of this player, for the whole battle - for (const a of battle.allyUnits) { - if (a.kind === 'op' && a.ownerId === unit.ownerId && ABYSSAL.has(a.def?.charId)) { - battle.addBuff(a, { key: 'talent:skadi_predator', mods: { atkPct: num(t0.atk) }, persist: true, allowDead: true, source: unit }); - } - } - } }, - { install(battle, unit) { - if (unit.mem.skadiSwift) return; // base stats are per unit; never apply twice - unit.mem.skadiSwift = true; - unit.base.respawnTime = Math.max(0, unit.base.respawnTime + num(t1.respawn_time)); - } }, - ], - }; - if (tb.value != null || tb.hp_ratio != null) { - kit.install = (battle, unit) => { - battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.mem.tideUsed = false; }, { owner: unit }); - battle.on('fatal', (ctx) => { - if (ctx.unit !== unit || ctx.prevented || unit.mem.tideUsed) return; - unit.mem.tideUsed = true; - ctx.prevented = true; - const hpMul = tb.value != null ? 1 - num(tb.value) : num(tb.max_hp, 0.4); - battle.addBuff(unit, { key: 'trait:skadi_tide', mods: { hpMul: Math.max(0.05, hpMul), aspd: num(tb.attack_speed) }, visible: true }); - unit.hp = Math.max(1, unit.s.maxHp * num(tb.hp_ratio, 1)); - fx(battle, 'revive', unit, { module: true }); - }, { owner: unit, priority: -50 }); - }; - } - // DRE-X "攻击被阻挡的敌人时攻击力提升至115%" (blocked by anyone) - if (num(tb.atk_scale) > 0) kit.trait = { dmgMul: (battle, unit, target) => (target && target.blockedBy ? num(tb.atk_scale, 1) : 1) }; - return kit; - }, - - // ---- 3_06 菲莱 · 本源铁卫 — S2 冥河诅咒: stops attacking, HP +; when attacked blasts the ground enemies of the 3×3 - // around her (PRTS 备注: range x-4; arts + ep_damage_ratio × ATK apoptosis, aoe_cd); ATK + once hit by element - // damage; 神河谕使: 元素损伤 taken −damage_resistance, +SP on apoptosis - // 精锐 module PRP-X "阻挡敌人时,自身造成的元素损伤提升15%": EVERY element fill she deals (the S2 blast, a 灼燃维式重锤 - // she carries …) ×ep_damage_scale while she blocks — an `elementHit` multiplier, like 余's - // S1 灵河护佑 (TAKE_DAMAGE): HP +, clears her element gauges and gives a shield_value 损伤屏障 (absorbs element - // damage — gauge fills — until spent or the skill ends) - // 神河谕使 and the barrier act on the element hit before it lands (PRTS 备注: "减伤与技力回复效果于伤害计算前处理;即使 - // 受到0点的凋亡损伤依然可以回复技力", "损伤屏障于伤害计算前、第一天赋后处理"): the talent's cut and SP first (priority 20), - // then the barrier absorbs what is left (`dmg.amount × dmg.mul`). The cut is a 元素损伤 multiplier (PRTS 元素 "受到的 - // 元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01),后续可应用元素损伤倍率提升/降低等效果"), not `elemTakenMul` (元素伤害 / 元素脆弱). - chess_char_3_06_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const tb = traitBb(d); - const sel = selectedId(chess, d); - const S1 = 'skchr_philae_1', S2 = 'skchr_philae_2'; - const isS2 = sel === S2 || sel == null; - const scale = num(bb.atk_scale, 1); - const ep = num(bb.ep_damage_ratio, 0); - const cd = num(bb.aoe_cd, 2); - const epScale = num(tb.ep_damage_scale, 1); - const cut = Math.max(0, Math.min(1, num(t0.damage_resistance))); - // 损伤屏障 of S1: absorbs the gauge gain an element hit would add (after 神河谕使) - const installBarrier = (battle, unit) => { - battle.on('elementHit', (ctx) => { - if (ctx.target !== unit || !(unit.mem.philaeBarrier > 0) || !unit.skill?.active) return; - const dmg = ctx.dmg; - const eff = num(dmg.amount) * num(dmg.mul, 1); - if (!(eff > 0)) return; - const take = Math.min(unit.mem.philaeBarrier, eff); - unit.mem.philaeBarrier -= take; - if (take >= eff - 1e-9) dmg.cancel = true; - else dmg.amount *= (eff - take) / eff; - if (!(unit.mem.philaeBarrier > 0)) fx(battle, 'shieldBreak', unit, { element: true }); - }, { owner: unit }); - }; - return { - skill: { - kind: 'duration', - mods: { hpPct: num(bb.max_hp) }, - attack: { noAttack: true }, - onStart({ unit }) { unit.mem.philaeCd = -Infinity; }, - onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:philae_rage'); }, - }, - skills: altSkills(chess, d, bb, { - [S1]: (s) => ({ - kind: 'duration', - mods: { hpPct: num(s.bb.max_hp) }, - onStart({ battle, unit }) { - battle.reduceElement(unit, 1e12); // "立刻清除自身的元素损伤" (a gauge locked by a running burst stays) - unit.mem.philaeBarrier = num(s.bb.shield_value, 0); - fx(battle, 'shield', unit, { element: true, n: unit.mem.philaeBarrier }); - }, - onEnd({ unit }) { unit.mem.philaeBarrier = 0; }, - }), - }), - talents: [{ install(battle, unit) { - // 神河谕使 (every skill): 元素损伤 taken ×(1 − damage_resistance); "受到凋亡损伤时回复2点技力" — even for 0 damage - battle.on('elementHit', (ctx) => { - const dmg = ctx.dmg; - if (ctx.target !== unit || !dmg || dmg.type !== 'element') return; - if (dmg.element === 'apoptosis' && !unit.skill?.active) giveSp(unit, num(t0.sp), 'talent'); - if (cut > 0) dmg.mul *= 1 - cut; - }, { owner: unit, priority: 20 }); - } }], - install(battle, unit) { - if (sel === S1) installBarrier(battle, unit); - // module PRP-X: her element damage ×ep_damage_scale while she blocks - if (epScale > 1) { - battle.on('elementHit', (ctx) => { - if (ctx.source === unit && ctx.dmg?.type === 'element' && ctx.target?.side === 'enemy' && unit.blocking.length) ctx.dmg.mul *= epScale; - }, { owner: unit }); - } - battle.on('damaged', (ctx) => { - if (ctx.target !== unit || !unit.alive) return; - const sk = unit.skill; - if (ctx.type === 'element') { - if (isS2 && sk && sk.active && !unit.findBuff('skill:philae_rage')) battle.addBuff(unit, { key: 'skill:philae_rage', mods: { atkPct: num(bb.atk) }, visible: true }); - return; - } - if (!isS2 || !sk || !sk.active || !ctx.dmg?.isAttack || !ctx.source || ctx.source.side !== 'enemy') return; - if (battle.time < (unit.mem.philaeCd ?? -Infinity)) return; - unit.mem.philaeCd = battle.time + cd; - // "周围的地面敌人" = range x-4, the 3×3 tiles around her (PRTS 备注) - for (const e of battle.unitsInGrid(unit, NINE, { side: 'enemy' })) { - if (e.isFlying) continue; - battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'counter'] }); - if (ep > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'apoptosis', amount: unit.s.atk * ep, tags: ['skill'] }); - } - fx(battle, 'aoe', unit, { radius: 1.5, tiles: 'box', dmgType: 'arts', skill: 'philae_2' }); // box: the 3×3 tiles - }, { owner: unit }); - }, - }; - }, - - // ---- 3_07 见行者 · 推击手 (hidden) — S2 惊爆射击: push every enemy in the skill range forward + stun (longer when - // slammed into a wall, collided enemies stunned too; air units too [ASSUMED: "范围内所有敌人", no 对空 note on - // PRTS] — a 失衡免疫 enemy or a 静态刚体 (the drones) is not pushed but still stunned); 技巧射击: ignore DEF vs heavy enemies; - // 精锐 module PUS-X: redeployed on a ranged tile ⇒ half the deployment cost back - chess_char_3_07_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const tb = traitBb(d); - const skillGrid = copyGrid(d.skill?.rangeGrid); - const force = num(bb.force, 1); - const sDirect = num(bb['forcer_s_2[hit_directly].stun'], num(bb.stun, 1)); - const sWall = num(bb.stun, sDirect); - const sBrush = num(bb['forcer_s_2[brush].stun'], sDirect); - const kit = { - skill: { - kind: 'instant', - onStart({ battle, unit }) { - const keys = new Set(gridKeys(skillGrid ?? unit.rangeGrid, unit)); - const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && onTiles(e, keys)); - const pushed = new Set(victims); - for (const e of victims) { - // "往身前方向…推开": a directional push whose angle is fixed — PRTS 备注 "不会因为角度过大而改变推动的方向或削减力度" - // (only the angle: a target nearer than 0.25 tile still turns radial at 受力等级 −2) — by the official - // 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance); - // stopped short of it ⇒ it hit a wall - const near = Math.hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST; - const expect = battle.pushDistance(e, near ? force - 2 : force, { effect: true }); - const moved = battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] }, fixedAngle: true, effect: true }); - const wall = expect > 0 && moved + 0.05 < expect; - battle.applyStatus(e, 'stun', { duration: wall ? sWall : sDirect, source: unit }); - } - for (const e of victims) { - for (const o of battle.enemiesInRadius(e.x, e.y, COLLIDE)) { - if (pushed.has(o)) continue; - pushed.add(o); - battle.applyStatus(o, 'stun', { duration: sBrush, source: unit }); - } - } - fx(battle, 'push', unit, { skill: 'forcer_2', n: victims.length }); - }, - // "立即将范围内所有敌人…": the data rule SKILL_RANGE fires it once an enemy is inside the skill range (PRTS 技能 - // 策略 "仅在技能范围内存在敌人(无视其不可选中)时释放技能"), not only when one stands on her 2-tile attack range - }, - talents: [{ install(battle, unit) { - battle.on('hit', (ctx) => { - if (ctx.source === unit && ctx.target.side === 'enemy' && ctx.target.s.massLevel >= num(t0.value, 3)) ctx.dmg.defIgnoreFlat += num(t0.def_penetrate_fixed); - }, { owner: unit }); - } }], - }; - if (num(tb.value) > 0) { - kit.install = (battle, unit) => { - battle.on('deploy', (ctx) => { - // ranged position = not a ground tile (a high tile, or a platform that elevates it: unit.ground false) - if (ctx.unit !== unit || ctx.initial || unit.ground) return; - battle.addDp(unit.ownerId, unit.base.cost * num(tb.value)); - fx(battle, 'dp', unit, { n: unit.base.cost * num(tb.value) }); - }, { owner: unit }); - }; - } - return kit; - }, - - // ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each attack strikes every enemy on her range (the trait's 群体法术伤害) and - // pushes each one towards her, end-of-skill burst on - // every enemy in range; 地质学者: DEF aura (skill off) / less likely targeted (skill on); - // 精锐 module PLX-X: keeps part of the guard (DEF/RES) while the skill runs - // S1 风语: wider range (skill grid), attacks every enemy on it at attack@atk_scale. Auto-cast: the data - // rule SEARCH is the 阵法术师 row (PRTS 卫戍协议/帮助 "不受基础策略影响,在初始攻击范围内存在敌人时释放技能"; it - // covers every MANUAL skill of the class — user playtest #6) = an enemy inside her INITIAL range — the engine's - // DEFAULT rule, checked every tick for a unit that does not attack while its skill is off — not any enemy on the - // field: she would burn the skill on enemies that just spawned. - chess_char_3_08_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const tb = traitBb(d); - const tGrid = copyGrid(d.talents?.[0]?.rangeGrid) ?? [[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]; - const pullForce = num(bb['attack@force'], num(bb.force, 0)); - const endScale = num(bb.atk_scale, 0); - const keepDef = num(tb['soil_e_002[buff].def'], 0), keepRes = num(tb['soil_e_002[buff].magic_resistance'], 0); - // every skill: PLX-X keeps part of the guard, 地质学者 taunt −1 while it runs, DEF aura only while it does not - const guardOn = ({ battle, unit }) => { - if (keepDef || keepRes) battle.addBuff(unit, { key: 'trait:mint_keep', mods: { defPct: keepDef, resFlat: keepRes } }); - battle.addBuff(unit, { key: 'talent:mint_taunt', mods: { taunt: num(t0.taunt_level, -1) } }); - unit.mem.mintAura?.(); - }; - const guardOff = ({ battle, unit }) => { - battle.removeBuff(unit, 'trait:mint_keep'); - battle.removeBuff(unit, 'talent:mint_taunt'); - unit.mem.mintAura?.(); - }; - return { - skills: altSkills(chess, d, bb, { - skchr_mint_1: (s) => { - const grid = copyGrid(s.rangeGrid); - return { - kind: 'duration', - trigger: { rule: 'DEFAULT' }, - attack: { atkScale: num(s.bb['attack@atk_scale'], num(s.bb.atk_scale, 1)) }, - ...(grid ? { targeting: { rangeGrid: grid } } : {}), - onStart: guardOn, - onEnd: guardOff, - }; - }, - }), - skill: { - kind: 'duration', - attack: { - atkScale: num(bb['attack@atk_scale'], 1), - onHit({ battle, unit, target }) { - if (!target || !target.alive || target.side !== 'enemy') return; - // PRTS 备注: "此技能的“拖拽”机制实际为反方向(指向薄绿方向)的推开" — a radial push towards her by the - // official 力度 − 重量 push distance (小力 vs weight 1: 0.44 tiles), never past her (Battle.push inward). Her - // attack reaches air units (阵法术师 "攻击时可对空"), but the drones of the mode are 静态刚体 (PRTS 特殊机制): hit, - // never dragged (player report after 0.1.0, "飞机可以被薄绿的技能拉走") - if (battle.push(target, pullForce, { from: unit, inward: true }) > 0) fx(battle, 'pull', target, { src: unit.id }); - }, - }, - onStart: guardOn, - onEnd(ctx) { - const { battle, unit, reason } = ctx; - if (reason !== 'death' && unit.alive && endScale > 0) { - for (const e of enemiesOn(battle, unit, unit.rangeKeys)) battle.dealDamage(unit, e, { amount: unit.s.atk * endScale, type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); - fx(battle, 'aoe', unit, { radius: 2, dmgType: 'arts', skill: 'mint_2' }); - } - guardOff(ctx); - }, - }, - talents: [{ install(battle, unit) { - unit.mem.mintAura = aura(battle, unit, { - key: 'talent:mint_geo', side: 'ally', tiles: () => gridKeys(tGrid, unit), active: () => !unit.skill?.active, mods: { defPct: num(t0.def) }, - }); - } }], - }; - }, - - // ---- 3_09 海霓 · 削弱者 — S2 阻滞性显色剂: ATK +, 2 targets, slows non-elite enemies in range, their deaths raise the - // talent up to max_talent_up; 测绘器材的奇用: non-elite enemies in range are fragile - chess_char_3_09_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const slow = num(bb['attack@move_speed'], 0); - const up = num(bb['attack@talent_up'], 0), upMax = num(bb['attack@max_talent_up'], 1); - const mt = Math.floor(num(bb['attack@max_target'], 1)); - const kit = { - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - onStart({ battle, unit }) { - unit.mem.hainiMul = 1; - if (!unit.mem.hainiSlow) { - unit.mem.hainiSlow = aura(battle, unit, { key: 'skill:haini_slow', side: 'enemy', interval: 0, tiles: () => unit.rangeKeySet, filter: isNormalEnemy, mods: { moveMul: Math.max(0, 1 + slow) } }); - } - unit.mem.hainiSlow(); - }, - onTick({ unit }) { unit.mem.hainiSlow?.(); }, - onEnd({ unit }) { unit.mem.hainiSlow?.clear(); unit.mem.hainiMul = 1; }, - }, - // S1 迷惑性洋流图: next attack ×atk_scale on max_target enemies at once - skills: altSkills(chess, d, bb, { - skchr_haini_1: (s) => ({ - kind: instantKindOf(s), - targeting: { maxTargets: Math.max(1, Math.floor(num(s.bb.max_target, 1))) }, - attack: { atkScale: num(s.bb.atk_scale, 1) }, - }), - }), - talents: [{ install(battle, unit) { - unit.mem.hainiMul = 1; - aura(battle, unit, { - key: 'talent:haini_fragile', side: 'enemy', tiles: () => unit.rangeKeySet, filter: isNormalEnemy, - mods: () => ({ dmgTakenMul: 1 + (num(t0.damage_scale, 1) - 1) * (unit.mem.hainiMul ?? 1) }), - }); - battle.on('kill', (ctx) => { - const v = ctx.victim; - if (!unit.skill?.active || !alive(unit) || v.side !== 'enemy' || !isNormalEnemy(v)) return; - if (!unit.rangeKeySet || !onTiles(v, unit.rangeKeySet)) return; - unit.mem.hainiMul = Math.min(upMax, (unit.mem.hainiMul ?? 1) + up); - }, { owner: unit }); - } }], - }; - if (mt > 1) kit.skill.targeting = { maxTargets: mt }; - return kit; - }, - - // ---- 3_10 松果 · 散射手 — S2 电能过载: ATK + growing with each use (a→d), shorter range; 便携电源: SP regen for 60 s - chess_char_3_10_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const sel = selectedId(chess, d); - const steps = Object.keys(bb).filter((k) => /\[[a-z]\]\.atk$/.test(k)).sort().map((k) => num(bb[k])); - if (!steps.length) steps.push(num(bb.atk)); - const skillGrid = copyGrid(d.skill?.rangeGrid); - const kit = { - skill: { - kind: 'duration', - onStart({ battle, unit, skill }) { - const atk = steps[Math.min(steps.length - 1, Math.max(0, skill.activations - 1))]; - battle.addBuff(unit, { key: 'skill:pinecn_atk', mods: { atkPct: atk } }); - }, - onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:pinecn_atk'); }, - }, - // S1 RMA长钉 (charges): "立即以165%的攻击力进行一次攻击,无视敌人180的防御力" — an extra shot fired at once (all - // enemies in range, front-row × of the trait), on top of the normal attack it was cast before - skills: altSkills(chess, d, bb, { - skchr_pinecn_1: (s) => ({ - kind: instantKindOf(s), - // (the DEF ignore rides on the shots' tag: they land after the instant skill has ended) - attack: { atkScale: num(s.bb.atk_scale, 1), tags: ['skill', 'pinecnSpike'] }, - onStart({ battle, unit }) { battle.forceAttack(unit); }, - }), - }), - talents: [{ install(battle, unit) { - battle.on('deploy', (ctx) => { - if (ctx.unit === unit) battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } }); - }, { owner: unit }); - } }], - }; - if (sel === 'skchr_pinecn_1') { - const pen = num(bb.def_penetrate_fixed, 0); - kit.install = (battle, unit) => battle.on('hit', (ctx) => { - if (ctx.source === unit && pen > 0 && ctx.dmg.tags?.includes('pinecnSpike')) ctx.dmg.defIgnoreFlat += pen; - }, { owner: unit }); - } - if (skillGrid) { - kit.skill.targeting = { rangeGrid: skillGrid }; - // "攻击范围缩短": offensive skills fire with an enemy inside the SKILL range (research 03 §1.4) — with the - // initial (wider) range she would open the skill on enemies the shortened range cannot reach - kit.skill.trigger = { rule: 'CUSTOM_RANGE', grid: skillGrid }; - } - return kit; - }, - - // ---- 3_11 雪猎 · 猎手 — S2 风雪连弩: instant special-bullet double shot (higher vs non-moving targets), charges; - // 裂云一击: the skill also sends 裂云兽 (ATK% phys + cold); 精锐 module HUN-X: reload from empty adds extra rounds - // S1 强力击·β型: next attack ×atk_scale (a normal shot: needs a round); 裂云兽 hits the enemy it is cast on - chess_char_3_11_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const tb = traitBb(d); - const sel = selectedId(chess, d); - const S2 = 'skchr_snhunt_2'; - const s1 = num(bb.atk_scale_1, 1), s2 = num(bb.atk_scale_2, s1); - const charges = Math.max(1, Math.floor(num(d.skill?.maxCharges, num(bb.ct, 1)))); - const shots = textNum(d.skill?.description, /(\d+|[一二两三四五])连击/, 2); - const still = (e) => !!(e.blockedBy || e.s.flags.stun || e.s.flags.noMove || !e.moving); - const extra = num(tb.extra_add, 0); - return { - skill: { - kind: charges > 1 ? 'charges' : 'instant', - onStart({ battle, unit }) { - const t = enemiesOn(battle, unit, unit.rangeKeys, 1, unit.profile)[0] ?? null; - unit.mem.snTarget = t; - if (!t) return; - battle.addProjectile({ - from: unit, target: t, speed: 16, visual: 'arrow', source: unit, - onHit: ({ target }) => { - if (!target || !target.alive) return; - const sc = still(target) ? s2 : s1; - for (let i = 0; i < shots && target.alive; i++) battle.dealDamage(unit, target, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, tags: ['skill', 'snhunt'] }); - }, - }); - fx(battle, 'volley', unit, { target: t.id, skill: 'snhunt_2' }); - }, - }, - skills: altSkills(chess, d, bb, { - skchr_snhunt_1: (s) => ({ kind: instantKindOf(s), attack: { atkScale: num(s.bb.atk_scale, 1) } }), - }), - talents: [{ install(battle, unit) { - battle.on('skillStart', (ctx) => { - if (ctx.unit !== unit) return; - // S2 aims its special bullets itself (mem.snTarget); another skill is cast on the enemy about to be shot - const t = sel === S2 || sel == null ? unit.mem.snTarget : (enemiesOn(battle, unit, unit.rangeKeys, 1, unit.profile)[0] ?? null); - if (!t || !t.alive) return; - battle.dealDamage(unit, t, { amount: unit.s.atk * num(t0.atk_scale, 1), type: 'phys', isSkill: true, tags: ['talent', 'cloudbeast'] }); - if (t.alive) battle.applyStatus(t, 'cold', { duration: num(t0.cold, 3), source: unit }); - fx(battle, 'strike', t, { src: unit.id, talent: 'snhunt_beast' }); - }, { owner: unit }); - } }], - install(battle, unit) { - // special bullets (S2 only): the skill also fires while the magazine is empty (the attack loop cannot run then) - if (sel === S2 || sel == null) { - battle.on('tick', () => { - const sk = unit.skill; - if (!alive(unit) || !unit.canAct || !sk || !sk.ready || sk.opCooling || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return; - if (battle.enemiesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit, unit.profile).length) sk.activate('DEFAULT'); - }, { owner: unit }); - } - if (extra > 0) { - battle.on('tick', () => { - const a = unit.trait.ammo ?? 0; - if (unit.mem.prevAmmo === 0 && a === 1) unit.trait.ammo = Math.min(Math.max(1, unit.profile.ammoMax ?? 8), a + extra); - unit.mem.prevAmmo = unit.trait.ammo; - }, { owner: unit }); - } - }, - }; - }, - - // ---- 3_12 瑕光 · 守护者 — S3 先贤化身: ATK/DEF +, bonus arts per hit, heals another nearby ally per attack; - // 剑盾骑士: hurt-SP skills of the team also gain SP on attack; 仁慈: attacks sleeping enemies (first, ×atk_scale); - // 精锐 module GUA-Y: damage taken −15 % - // S1 光芒涌动 (自动触发 ⇒ DEFAULT, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself - // included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile and every enemy she - // blocks to sleep (PRTS 备注; for the skill's duration: no own value in the data) and heals every ally of the - // skill range by ATK × ratio each second; - // 精锐 module GUA-X: her heals on allies under hp_ratio HP × heal_scale - chess_char_3_12_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0), t1 = talentBb(d, 1); - const tb = traitBb(d); - const bonus = num(bb['attack@blemsh_s_3_extra_dmg[magic].atk_scale'], 0); - const heal = num(bb.heal_scale, 0); - const healGrid = copyGrid(d.skill?.rangeGrid) ?? NINE; - const mercy = num(t1.atk_scale, 1); - const sleepersInRange = (battle, unit) => { - const set = unit.rangeKeySet; - return set ? battle.enemies.filter((e) => e.alive && !e.hidden && e.s.flags.sleep && !e.s.flags.untargetable && !e.isFlying && onTiles(e, set)) : []; - }; - const kit = { - trait: { hitSleep: true }, // 仁慈: 自身可以攻击…沉睡的目标 (沉睡 = 无敌 for everyone else) - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, - heal: false, - attack: { onHit({ battle, unit, target }) { - if (bonus > 0 && target && target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * bonus, type: 'arts', isSkill: true, tags: ['skill', 'blemshBonus'] }); - } }, - onAttack({ battle, unit }) { - if (!(heal > 0)) return; - const ally = battle.injuredAlliesInKeys(new Set(gridKeys(healGrid, unit)), unit).find((a) => a !== unit); - if (ally) battle.heal(unit, ally, unit.s.atk * heal); - }, - }, - skills: altSkills(chess, d, bb, { - skchr_blemsh_1: (s) => { - const grid = copyGrid(s.rangeGrid) ?? NINE; - const hs = num(s.bb.heal_scale, 0); - return { - kind: instantKindOf(s), - heal: false, - attack: { - atkScale: num(s.bb.atk_scale, 1), - onHit({ battle, unit }) { // "恢复周围一名友方单位" - const ally = hs > 0 ? battle.injuredAlliesInKeys(new Set(gridKeys(grid, unit)), unit)[0] : null; - if (ally) battle.heal(unit, ally, unit.s.atk * hs); - }, - }, - }; - }, - skchr_blemsh_2: (s) => { - const grid = copyGrid(s.rangeGrid) ?? NINE; - const ratio = num(s.bb['attack@atk_to_hp_recovery_ratio'], num(s.bb.atk_to_hp_recovery_ratio, 0)); - return { - kind: 'duration', - heal: false, - mods: { atkPct: num(s.bb.atk) }, - onStart({ battle, unit, skill }) { - const dur = skill.timeLeft > 0 ? skill.timeLeft : Math.max(0.1, num(s.duration, 10)); - let n = 0; - // PRTS 备注: "技能生效对象实际为“自身这格内的所有地面敌人及自身阻挡的敌人”,即使阻挡的是飞行敌人" — a blocked - // enemy stands at the block radius, outside her tile (Battle._checkBlock) - for (const e of battle.enemies) { - if (!e.alive || e.hidden) continue; - if (e.blockedBy !== unit && (e.isFlying || !bodyOnTile(e, unit.tileR, unit.tileC))) continue; - if (battle.applyStatus(e, 'sleep', { duration: dur, source: unit })) n++; - } - fx(battle, 'aoe', unit, { radius: 0.5, skill: 'blemsh_2', status: 'sleep', n }); - unit.mem.blemshRegen = 0; - }, - onTick({ battle, unit, dt }) { // "周围的所有友方单位每秒恢复相当于攻击力N%的生命值" - if (!(ratio > 0)) return; - unit.mem.blemshRegen = (unit.mem.blemshRegen ?? 0) + dt; - while (unit.mem.blemshRegen >= 1 - 1e-9) { - unit.mem.blemshRegen -= 1; - for (const a of battle.injuredAlliesInKeys(new Set(gridKeys(grid, unit)), unit)) battle.heal(unit, a, unit.s.atk * ratio); - } - }, - }; - }, - }), - talents: [ - { install(battle, unit) { - unit.mem.blemshKnight = true; - battle.on('attack', (ctx) => { - const a = ctx.attacker; - if (!a || a.kind !== 'op' || a.ownerId !== unit.ownerId || a.skill?.spType !== 'hurt') return; - if (!alive(unit) || !isLeader(battle, unit, 'blemshKnight')) return; - giveSp(a, num(t0.sp, 1), 'talent'); - }, { owner: unit }); - } }, - { install(battle, unit) { - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit) return; - const s = sleepersInRange(battle, unit); - if (s.length) ctx.targets = [s[0], ...ctx.targets.filter((t) => t !== s[0])].slice(0, Math.max(1, ctx.targets.length)); - }, { owner: unit }); - battle.on('hit', (ctx) => { - if (ctx.source === unit && ctx.target.side === 'enemy' && ctx.target.s.flags.sleep) ctx.dmg.mul *= mercy; - }, { owner: unit }); - // (a lone sleeper is found by the engine's own target search: `trait.hitSleep` makes sleepers targetable - // and damageable for her only) - } }, - ], - }; - if (num(tb.damage_resistance) > 0) { - kit.install = (battle, unit) => battle.addBuff(unit, { key: 'trait:blemsh_guard', mods: { dmgTakenMul: 1 - num(tb.damage_resistance) }, persist: true, allowDead: true }); - } else if (num(tb.heal_scale) > 0) { - // GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%" (the HP before the heal) - kit.install = (battle, unit) => battle.on('heal', (ctx) => { - if (ctx.source === unit && ctx.target && ctx.target.hpRatio < num(tb.hp_ratio, 0.5)) ctx.amount *= num(tb.heal_scale, 1); - }, { owner: unit }); - } - return kit; - }, - - // ---- 3_13 至简 · 驭械术师 — S2 神工意匠: next attack ATK% arts twice (charges); 忽有所悟: prob of ATK ×atk_scale per attack - chess_char_3_13_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const charges = Math.max(1, Math.floor(num(d.skill?.maxCharges, num(bb.ct, 1)))); - const hits = textNum(d.skill?.description, /连续攻击(\d+|[一二两三四五])次/, 2); - const insight = (battle) => (battle.rng.chance(num(t0.prob, 0)) ? num(t0.atk_scale, 1) : 1); - return { - trait: { dmgMul: (battle, unit, target) => funnelMap(battle, unit, target) * insight(battle) }, - skill: { - kind: charges > 1 ? 'charges' : 'instant', - attack: { atkScale: num(bb.atk_scale, 1), hits, dmgType: 'arts', dmgMul: (battle) => insight(battle) }, - }, - // S1 迅捷打击·γ型: ATK / ASPD + (the drones' ramp and 忽有所悟 stay on every attack through the trait) - skills: altSkills(chess, d, bb, { 'skcom_quickattack[3]': statSkill }), - install: installFunnelPrune, - }; - }, - - // ---- 3_14 初雪 · 削弱者 — S2 自然震慑: DEF/RES shred aura on every enemy in range; 虚弱化: low-HP enemies fragile; - // 双响: 2 targets - chess_char_3_14_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0), t1 = talentBb(d, 1); - const mr = num(bb.magic_resistance, 0); - const shred = { defPct: num(bb.def) }; - if (mr) { - if (Math.abs(mr) < 1) shred.resMul = Math.max(0, 1 + mr); - else shred.resFlat = mr; - } - const mt = Math.floor(num(t1['attack@max_target'], 1)); - const kit = { - skill: { - kind: 'duration', - onStart({ battle, unit }) { - if (!unit.mem.slbellAura) unit.mem.slbellAura = aura(battle, unit, { key: 'skill:slbell_shred', side: 'enemy', interval: 0, tiles: () => unit.rangeKeySet, mods: shred }); - unit.mem.slbellAura(); - }, - onTick({ unit }) { unit.mem.slbellAura?.(); }, - onEnd({ unit }) { unit.mem.slbellAura?.clear(); }, - }, - // S1 传音回响: 2 targets, every enemy in range ASPD + attack_speed (negative) while it runs - skills: altSkills(chess, d, bb, { - skchr_slbell_1: (s) => { - const slow = { aspd: num(s.bb.attack_speed) }; - return { - kind: 'duration', - targeting: { maxTargets: Math.max(1, Math.floor(num(s.bb['attack@max_target'], num(s.bb.max_target, 1)))) }, - onStart({ battle, unit }) { - if (!unit.mem.slbellSlow) unit.mem.slbellSlow = aura(battle, unit, { key: 'skill:slbell_aspd', side: 'enemy', interval: 0, tiles: () => unit.rangeKeySet, mods: slow }); - unit.mem.slbellSlow(); - }, - onTick({ unit }) { unit.mem.slbellSlow?.(); }, - onEnd({ unit }) { unit.mem.slbellSlow?.clear(); }, - }; - }, - }), - talents: [{ install(battle, unit) { - aura(battle, unit, { - key: 'talent:weak_fragile', side: 'enemy', tiles: () => unit.rangeKeySet, filter: (e) => e.hpRatio < num(t0.hp_ratio, 0.4), - mods: { dmgTakenMul: num(t0.damage_scale, 1) }, interval: 0.1, - }); - } }], - }; - if (mt > 1) kit.trait = { maxTargets: mt }; - return kit; - }, - - // ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃 "获得一个诅咒娃娃(最多可库存1个)": the doll is a hand piece the player - // places (user playtest #6; PRTS 卫戍协议/帮助); each cast gives one and the placed piece takes the field on its - // own tile (tokens.js releaseSkillSummon: not at the battle start; not placed ⇒ no doll); its token kit keeps the - // 3×3 ATK/DEF aura (the token skill's bb) for 15 s; 溃败暗示: low-HP enemies fragile - chess_char_3_15_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const tokenId = chess?.skill?.overrideTokenKey ?? (d.tokens || []).find((t) => /doll/.test(String(t))) ?? 'token_10006_vodfox_doll'; - return { - skill: { - kind: 'instant', - onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokenId); }, - }, - talents: [{ install(battle, unit) { - aura(battle, unit, { - key: 'talent:weak_fragile', side: 'enemy', tiles: () => unit.rangeKeySet, filter: (e) => e.hpRatio < num(t0.hp_ratio, 0.4), - mods: { dmgTakenMul: num(t0.damage_scale, 1) }, interval: 0.1, - }); - } }], - }; - }, - - // ---- 3_16 蛇屠箱 · 铁卫 — S2 壳状防御: stops attacking, block +1, DEF +, regen; 防御专精: DEF +; - // 精锐 module PRO-X: DEF + while blocking - chess_char_3_16_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const tb = traitBb(d); - const kit = { - skill: { - kind: 'duration', - mods: { defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }, - attack: { noAttack: true }, - }, - // S1 防御力强化·β型 (TAKE_DAMAGE): DEF + (she keeps attacking) - skills: altSkills(chess, d, bb, { 'skcom_def_up[2]': statSkill }), - talents: [{ install(battle, unit) { battle.addBuff(unit, { key: 'talent:snakek_def', mods: { defPct: num(t0.def) }, persist: true, allowDead: true }); } }], - }; - if (num(tb.def) > 0) kit.install = (battle, unit) => whileTrue(battle, unit, 'trait:snakek_block', () => unit.blocking.length > 0, { defPct: num(tb.def) }); - return kit; - }, - - // ---- 3_17 流星 · 速射手 — S2 碎甲击·扩散: instant ATK% phys on ≤5 enemies in range + DEF shred for `duration` s; - // 空射专精: ×atk_scale vs flying (stacks with the module fly bonus) - chess_char_3_17_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const n = textNum(d.skill?.description, /至多(\d+)个/, 5); - const flyMul = (unit, t) => (t.isFlying ? (unit.profile.flyScale ?? 1) * num(t0.atk_scale, 1) : 1); - return { - trait: { dmgMul: (battle, unit, target) => flyMul(unit, target) }, - skill: { - kind: 'instant', - onStart({ battle, unit }) { - const list = enemiesOn(battle, unit, unit.rangeKeys, n, unit.profile); - for (const e of list) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1) * flyMul(unit, e), type: 'phys', isSkill: true, tags: ['skill', 'burst'] }); - if (e.alive) battle.addBuff(e, { key: 'skill:shotst_shred', duration: num(bb.duration, 5), refresh: 'extend', mods: { defPct: num(bb.def) }, source: unit, visible: true }); - } - if (list.length) fx(battle, 'volley', unit, { targets: list.map((e) => e.id), skill: 'shotst_2' }); - }, - }, - // S1 碎甲击: next attack ×atk_scale (fly × of the trait kept) and the target's DEF − for `duration` s - skills: altSkills(chess, d, bb, { - skchr_shotst_1: (s) => ({ - kind: instantKindOf(s), - attack: { - atkScale: num(s.bb.atk_scale, 1), - onHit({ battle, unit, target }) { - if (target && target.alive && target.side === 'enemy') battle.addBuff(target, { key: 'skill:shotst_shred', duration: num(s.bb.duration, 5), refresh: 'extend', mods: { defPct: num(s.bb.def) }, source: unit, visible: true }); - }, - }, - }), - }), - }; - }, - - // ---- 3_18 忍冬 · 尖兵 — S3 隐狐之艺: +DP, range +1, ATK +, decaying ASPD, hits all blocked, 0.2 s stun per hit, - // after a kill during the skill 迷彩 from its end until the next cast ("进入迷彩状态,直至下一次开启技能") — the - // `camou` status under its own key (ba.camou "不阻挡时不成为敌方普通攻击的目标": only the enemy she blocks attacks - // her, as with 隐匿; it used to be the generic `stealth` = 隐匿 under the key `stealth`, shared with 伪装服's timed - // 隐匿, which the unending one then made permanent, and it counted as 隐匿 for 叙拉古 / 家族徽章 — user playtest #6 - // "叙拉古阵营隐身不会结束": the bond's 隐匿 ends on time, this 迷彩 officially lasts); 追凶: bonus arts on marked - // enemies; 蓄势: DP regen + out-of-combat - // regen; 精锐 module SOL-X: ATK/DEF + while blocking - // S1 小施惩戒 (charges): next attack + extra_damage_ratio × ATK arts and +cost DP; S2 坠刃拷问 (charges, the data rule - // SKILL_RANGE: fires once an enemy is inside its own range 3-12, charges 3 s apart): +cost DP, ≤ max_target enemies of that range take atk_scale arts and - // 停顿 — the ones already 停顿 are also stunned. (SOL-Y "首次部署时部署费用-4": the initial deployment is free.) - chess_char_3_18_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0), t1 = talentBb(d, 1); - const tb = traitBb(d); - const skillGrid = copyGrid(d.skill?.rangeGrid); - const aspd0 = num(bb.attack_speed, 0); - const kit = { - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - attack: { hitAllBlocked: true, onHitStatus: { key: 'stun', duration: num(bb['attack@stun'], 0.2) } }, - onStart({ battle, unit, skill }) { - battle.removeBuff(unit, CAMOU_KEY); - unit.mem.vulpisKill = false; - const dp = num(bb.cost, 0); - if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); } - battle.addBuff(unit, { key: 'skill:vulpis_aspd', mods: { aspd: aspd0 } }); - unit.mem.vulpisDur = skill.timeLeft; - }, - onTick({ battle, unit, skill }) { - const b = unit.findBuff('skill:vulpis_aspd'); - const dur = unit.mem.vulpisDur || skill.duration || 1; - if (b) { b.mods = { aspd: aspd0 * Math.max(0, skill.timeLeft) / dur }; unit.markDirty(); } - }, - onEnd({ battle, unit, reason }) { - battle.removeBuff(unit, 'skill:vulpis_aspd'); - if (reason !== 'death' && unit.alive && unit.mem.vulpisKill) { - battle.addBuff(unit, { key: CAMOU_KEY, flags: { camou: true }, status: 'camou', source: unit }); - fx(battle, 'camouflage', unit); - } - }, - }, - skills: altSkills(chess, d, bb, { - skchr_vulpis_1: (s) => ({ - kind: instantKindOf(s), - attack: { onHit({ battle, unit, target }) { - if (target && target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * num(s.bb.extra_damage_ratio), type: 'arts', isSkill: true, tags: ['skill', 'vulpisPunish'] }); - const dp = num(s.bb.cost, 0); - if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); } - } }, - }), - skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空"); data trigger SKILL_RANGE (its 3-12 range) - const grid = copyGrid(s.rangeGrid); - const n = Math.max(1, Math.floor(num(s.bb.max_target, 6))); - return { - kind: instantKindOf(s), - onStart({ battle, unit }) { - const dp = num(s.bb.cost, 0); - if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); } - const list = enemiesOn(battle, unit, gridKeys(grid ?? unit.rangeGrid, unit), n); - for (const e of list) { - const was = !!e.findBuff('sluggish'); // "若敌人已经处于停顿状态" (before this cast) - battle.dealDamage(unit, e, { amount: unit.s.atk * num(s.bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'vulpisTorture'] }); - if (!e.alive) continue; - battle.applyStatus(e, 'sluggish', { duration: num(s.bb.sluggish, 0), source: unit }); - if (was && e.alive) battle.applyStatus(e, 'stun', { duration: num(s.bb.stun, 0), source: unit }); - } - if (list.length) fx(battle, 'aoe', unit, { radius: 1.5, dmgType: 'arts', skill: 'vulpis_2', n: list.length }); - }, - }; - }, - }), - talents: [ - { install(battle, unit) { // 追凶 - const marks = new Map(); - // re-entrancy guard: content that turns a hit into HP loss credited to her (频次 enemies: every instance - // = 1 HP) would otherwise feed the bonus back into itself until the hook-depth limit - let busy = false; - battle.on('damaged', (ctx) => { - const e = ctx.target; - if (busy || ctx.source !== unit || e.side !== 'enemy' || ctx.type === 'element' || ctx.dmg?.tags?.includes('vulpisHunt')) return; - const t = marks.get(e.id); - if (t == null) { marks.set(e.id, battle.time); return; } - if (battle.time - t <= num(t0.interval, 10) + 1e-9 && e.alive) { - busy = true; - // PRTS 备注 "伤害类型为法术附加伤害": tag addition (no 叙拉古 6 roll) - try { battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.atk_scale), type: 'arts', canDodge: false, tags: ['talent', 'vulpisHunt', 'addition'] }); } finally { busy = false; } - } - }, { owner: unit }); - battle.on('kill', (ctx) => { if (ctx.killer === unit && unit.skill?.active) unit.mem.vulpisKill = true; }, { owner: unit }); - } }, - { install(battle, unit) { // 蓄势 - unit.mem.vulpisDp = true; - const bonus = num(t1.delta_cost_increase_time, 1) - 1; - const quiet = num(t1.interval, 4); - const regen = num(t1['vulpis_t_2[heal][interval].hp_recovery_per_sec_by_max_hp_ratio'], 0); - battle.on('tick', (ctx) => { - if (!alive(unit)) return; - if (bonus > 0 && isLeader(battle, unit, 'vulpisDp')) battle.addDp(unit.ownerId, battle.flags.dpPerSec * ctx.dt * bonus); - const on = battle.time - unit.lastHitAt >= quiet && regen > 0; - const has = !!unit.findBuff('talent:vulpis_regen'); - if (on && !has) battle.addBuff(unit, { key: 'talent:vulpis_regen', mods: { hpRegenRatio: regen } }); - else if (!on && has) battle.removeBuff(unit, 'talent:vulpis_regen'); - }, { owner: unit }); - } }, - ], - }; - if (skillGrid) kit.skill.targeting = { rangeGrid: skillGrid }; - if (num(tb.atk) > 0 || num(tb.def) > 0) { - kit.install = (battle, unit) => whileTrue(battle, unit, 'trait:vulpis_block', () => unit.blocking.length > 0, { atkPct: num(tb.atk), defPct: num(tb.def) }); - } - return kit; - }, - - // ---- 3_19 伺夜 · 战术家 — the tactical reinforcement is the wolf pack (狼群领袖: 2 wolves, +1 every 25 s up to 3, each - // wolf = +1 block and one more bite, a wolf is lost instead of the pack dying); 狼群天性: DEF ignore vs pack-blocked - // enemies; S3 领袖的尊严: DP over time, 三连击, bonus arts vs pack-blocked enemies; 精锐 module: pack takes less - // damage from the enemies it blocks (token module talent). A 狼群 piece placed in the prep phase is the pack. - // S1 领袖的呼唤 (ALWAYS): +cost DP and one more “狼影” (≤ the talent's maximum); S2 领袖的馈赠: +cost DP, the pack - // recovers hp_ratio of its max HP and its next attack hits ×atk_scale — a kill by that attack gives +cost DP. - // 精锐 module TAC-Y: trait ×165 % (profession layer) and "援军阻挡的敌人更容易受到我方的攻击": the pack's token module - // talent taunt_level (+1) goes to the enemies it blocks — the enemy-side 嘲讽等级 our operators target first - // (targeting.js; research 05: "更容易受到我方的攻击" = enemy taunt.taunt_level), never to the pack itself. - chess_char_3_19_a: (bb, chess, def) => { - const d = defOf(chess, def); - const sel = selectedId(chess, d); - const t0 = d.talents?.[0] ?? {}, t1 = talentBb(d, 1); - const wolfId = t0.tokenKey ?? (d.tokens || []).find((t) => /wolf/.test(String(t))) ?? 'token_10028_vigil_wolf'; - const initial = textNum(t0.description, /初始(\d+|[一二两三四五])只/, 2); - const maxWolves = textNum(t0.description, /至多(\d+|[一二两三四五])只/, 3); - const hits = textNum(d.skill?.description, /(\d+|[一二两三四五])连击/, 3); - const bonus = num(bb['attack@vigil_s_3.atk_scale'], 0); - const pen = num(t1.def_penetrate_fixed, 0); - const wolfOf = (u) => (u.trait.reinforcement && u.trait.reinforcement.alive ? u.trait.reinforcement : null); - const wolfKit = (vigil) => ({ - skill: null, - trait: { hitsFn: (b, w) => Math.max(1, w.mem.wolves || 1) }, - install(battle, w) { - const tal = w.def.talents || []; - const wb = tal[0]?.bb ?? {}; - const per = num(wb.block_cnt, num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], 1)); - const grow = num(wb.interval, num(wb['vigil_wolf_t_1_enhance[trigger].interval'], 25)); - // (mem.shadows mirrors the count for content reading tokens.js wolfShadows()) - const apply = () => { w.mem.shadows = w.mem.wolves; battle.addBuff(w, { key: 'token:wolves', mods: { blockCnt: per * w.mem.wolves }, allowDead: true }); }; - w.mem.wolves = Math.max(1, Math.min(maxWolves, initial)); - apply(); - w.mem.addWolf = () => { - if (!w.alive || w.mem.wolves >= maxWolves) return false; - w.mem.wolves++; - apply(); - fx(battle, 'summon', w, { src: vigil.id, wolves: w.mem.wolves }); - return true; - }; - battle.every(grow, () => w.mem.addWolf(), { owner: w }); - battle.on('fatal', (ctx) => { - if (ctx.unit !== w || ctx.prevented || w.mem.wolves <= 1) return; - w.mem.wolves--; - apply(); - ctx.prevented = true; - w.hp = w.s.maxHp; - fx(battle, 'revive', w, { wolves: w.mem.wolves }); - }, { owner: w }); - const mod = tal.find((t) => t && t.bb && t.bb.damage_scale != null); - if (mod) { - battle.on('hit', (ctx) => { - if (ctx.target === w && ctx.source && ctx.source.blockedBy === w) ctx.dmg.mul *= num(mod.bb.damage_scale, 1); - }, { owner: w }); - } - }, - }); - // TAC-Y token module talent (taunt_level) of the pack — whichever kit runs it (this one or a tokens.js board piece): - // the enemies it blocks carry that taunt level while they stay blocked (short refreshed buff: nothing lingers when - // the pack or 伺夜 leaves) - const packTaunt = (w) => { - if (w.mem.packTaunt == null) { - const t = (w.def?.talents || []).find((x) => x && x.bb && x.bb.taunt_level != null); - w.mem.packTaunt = t ? num(t.bb.taunt_level) : 0; - } - return w.mem.packTaunt; - }; - const installPackMark = (battle, unit) => { - let cur = new Set(); - const levelOf = (w) => (w && alive(w) && alive(unit) ? packTaunt(w) : 0); - const mark = (e, w, lvl) => battle.addBuff(e, { key: 'token:pack_mark', duration: 0.25, refresh: 'extend', mods: { taunt: lvl }, source: w, visible: true }); - // at once when the pack blocks (the operators attacking this tick already see it), kept / dropped every 0.1 s - battle.on('blocked', (ctx) => { - const w = wolfOf(unit), lvl = ctx.blocker === w ? levelOf(w) : 0; - if (lvl && ctx.enemy.alive) { mark(ctx.enemy, w, lvl); cur.add(ctx.enemy); } - }, { owner: unit }); - battle.every(0.1, () => { - const w = wolfOf(unit); - const lvl = levelOf(w); - const next = new Set(); - if (lvl) { - for (const e of w.blocking) { - if (!e.alive || e.side !== 'enemy' || e.blockedBy !== w) continue; - mark(e, w, lvl); - next.add(e); - } - } - for (const e of cur) { - if (next.has(e)) continue; - const b = e.findBuff('token:pack_mark'); - if (b) battle.removeBuff(e, b); - } - cur = next; - }, { owner: unit, immediate: true }); - }; - /** One more “狼影” (S1): this kit's pack, or a 狼群 board piece run by content/tokens.js (same 'wolf:shadows' buff). */ - const addShadow = (battle, vigil, w) => { - if (typeof w.mem.addWolf === 'function') return w.mem.addWolf(); - const n = Math.max(1, w.mem.shadows ?? 1); - if (n >= maxWolves) return false; - const wb = w.def?.talents?.[0]?.bb ?? {}; - const per = num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], num(wb.block_cnt, 1)); - w.mem.shadows = n + 1; - battle.addBuff(w, { key: 'wolf:shadows', persist: true, allowDead: true, refresh: 'replace', mods: { blockCnt: w.mem.shadows * per } }); - fx(battle, 'summon', w, { src: vigil.id, wolves: w.mem.shadows }); - return true; - }; - const dpGain = (battle, unit, n) => { if (n > 0) { battle.addDp(unit.ownerId, n); fx(battle, 'dp', unit, { n }); } }; - // S2: the pack's empowered next attack (armed by the cast; ×scale on its hits; a kill pays once) - const installGift = (battle, unit) => { - battle.on('beforeAttack', (ctx) => { - const w = wolfOf(unit); - if (!w || ctx.attacker !== w || !w.mem.vigilGift) return; - w.mem.vigilGiftOn = w.mem.vigilGift; - w.mem.vigilGift = null; - }, { owner: unit, priority: -100 }); - battle.on('hit', (ctx) => { - const g = ctx.source?.mem?.vigilGiftOn; - if (g && ctx.dmg.isAttack && ctx.source === wolfOf(unit)) ctx.dmg.mul *= g.scale; - }, { owner: unit }); - battle.on('kill', (ctx) => { - const g = ctx.killer?.mem?.vigilGiftOn; - if (!g || g.paid || ctx.victim.side !== 'enemy' || ctx.killer !== unit.trait.reinforcement) return; - g.paid = true; - dpGain(battle, unit, g.dp); - }, { owner: unit }); - battle.on('attack', (ctx) => { if (ctx.attacker?.mem?.vigilGiftOn && ctx.attacker === unit.trait.reinforcement) ctx.attacker.mem.vigilGiftOn = null; }, { owner: unit }); - }; - return { - trait: { install(battle, unit) { - // The pack is the tactician's 援军. The match also hands the player the 狼群 token to place in the prep phase - // (= choosing the tactical point): that board piece (tokens.js kit, owner-coupled effects left to this kit) is - // the pack when present — deployed early on its own tile if 伺夜 deploys first — never a second pack. - const spawn = () => { - if (!alive(unit) || wolfOf(unit)) return; - const pieces = tokensOf(battle, unit, wolfId); - const live = pieces.find((t) => alive(t)); - if (live) { unit.trait.reinforcement = live; return; } - const waiting = pieces.find((t) => !t.alive && !t.removed); - if (waiting && battle.redeploy(waiting, { free: true })) { - unit.trait.reinforcement = waiting; - fx(battle, 'summon', waiting, { src: unit.id, token: wolfId }); - return; - } - const board = pieces.find((t) => t.uid != null); - const tile = tacticalPoint(battle, unit, board ? [board.homeR, board.homeC] : null); - if (!tile) return; - const w = battle.spawnToken(unit, wolfId, tile[0], tile[1], { kit: wolfKit(unit) }); - unit.trait.reinforcement = w; - if (!w) return; - fx(battle, 'summon', w, { src: unit.id, token: wolfId, wolves: w.mem.wolves }); - const again = Math.max(0, w.base.respawnTime || 0); - battle.on('death', (c) => { if (c.unit === w && c.reason === 'killed') battle.after(again, spawn, { owner: unit }); }, { owner: w }); - }; - battle.on('deploy', (c) => { if (c.unit === unit) spawn(); }, { owner: unit }); - installPackMark(battle, unit); - battle.on('death', (c) => { - if (c.unit !== unit) return; - const w = wolfOf(unit); - if (w) battle.retreat(w, { reason: 'expired', permanent: true }); - }, { owner: unit }); - } }, - skill: { - kind: 'duration', - attack: { hits }, - onStart({ unit }) { unit.mem.vigilAcc = 0; unit.mem.vigilDp = 0; }, - onTick({ battle, unit, dt }) { - const iv = num(bb.interval, 1.5), step = num(bb.cost, 1), cap = num(bb.value, 0); - if (!(iv > 0) || !(step > 0)) return; // (junk blackboard: never spin) - unit.mem.vigilAcc += dt; - while (unit.mem.vigilAcc >= iv - 1e-9 && unit.mem.vigilDp + step <= cap + 1e-9) { - unit.mem.vigilAcc -= iv; - unit.mem.vigilDp += step; - battle.addDp(unit.ownerId, step); - } - }, - onEnd({ battle, unit, reason }) { - // the whole `value` is granted over a full duration (精锐: 11 × 1.364 s ends a hair after 15 s) - const rest = num(bb.value, 0) - (unit.mem.vigilDp ?? 0); - if (reason === 'duration' && rest > 1e-9) { battle.addDp(unit.ownerId, rest); unit.mem.vigilDp += rest; } - fx(battle, 'dp', unit, { n: unit.mem.vigilDp ?? 0 }); - }, - }, - skills: altSkills(chess, d, bb, { - // (自动触发: an AUTO skill takes no 技能策略 — the 战术家 row is for MANUAL skills — and this DP skill fires as soon - // as it is ready, as before) - skchr_vigil_1: (s) => ({ - kind: 'instant', - trigger: 'SP_FULL', - onStart({ battle, unit }) { - dpGain(battle, unit, num(s.bb.cost, 0)); - const w = wolfOf(unit); - if (w) addShadow(battle, unit, w); - }, - }), - skchr_vigil_2: (s) => ({ - kind: 'instant', - onStart({ battle, unit }) { - dpGain(battle, unit, num(s.bb.cost, 0)); - const w = wolfOf(unit); - if (!w) return; - const hr = num(s.bb['vigil_wolf_s_2.hp_ratio'], 0); - if (hr > 0) battle.heal(w, w, w.s.maxHp * hr, { self: true }); - w.mem.vigilGift = { scale: num(s.bb['vigil_wolf_s_2.atk_scale'], 1), dp: num(s.bb['vigil_wolf_s_2.cost'], 0), paid: false }; - fx(battle, 'buff', w, { src: unit.id, skill: 'vigil_2' }); - }, - }), - }), - install(battle, unit) { if (sel === 'skchr_vigil_2') installGift(battle, unit); }, - talents: [ - { install() { /* 狼群领袖: the pack itself (trait.install / wolfKit) */ } }, - { install(battle, unit) { - battle.on('hit', (ctx) => { - const w = wolfOf(unit); - // "伺夜和狼群对其的攻击无视其175防御力": their attacks only (not item procs or other non-attack damage) - if (!w || pen <= 0 || !ctx.dmg.isAttack || (ctx.source !== unit && ctx.source !== w) || ctx.target.blockedBy !== w) return; - ctx.dmg.defIgnoreFlat += pen; - }, { owner: unit }); - battle.on('damaged', (ctx) => { - const w = wolfOf(unit); - if (!w || !(bonus > 0) || !unit.skill?.active || !ctx.dmg?.isAttack || (ctx.source !== unit && ctx.source !== w)) return; - const e = ctx.target; - if (e.side !== 'enemy' || !e.alive || e.blockedBy !== w) return; - // "狼群与伺夜攻击…时,额外造成…": the extra hit belongs to that attack's dealer (a bite's bonus is the pack's - // damage — never re-typed by 伺夜's 弱点伤害 特质), its size to 伺夜's ATK (same as tokens.js unmanaged mode) - battle.dealDamage(ctx.source, e, { amount: unit.s.atk * bonus, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'vigilBonus'] }); - }, { owner: unit }); - } }, - ], - }; - }, - - // ---- 3_20 耶拉 · 驭械术师 — S2 心随意动: +1 drone (2 locks; a lone enemy gets both), ATK +, cold procs; 低眉: ATK + - // (more with ≥cnt ground tiles in range) - chess_char_3_20_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0); - const extra = Math.floor(num(bb['attack@cnt'], 0)); - const prob = num(bb['attack@prob'], 0), cold = num(bb['attack@cold'], 0); - const kit = { - trait: { dmgMul: funnelMap }, - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - attack: { onHit({ battle, unit, target }) { - if (target && target.alive && cold > 0 && battle.rng.chance(prob)) battle.applyStatus(target, 'cold', { duration: cold, source: unit }); - } }, - }, - // S1 攻击力强化·γ型: ATK + (one drone, the trait ramp as usual) - skills: altSkills(chess, d, bb, { 'skcom_atk_up[3]': statSkill }), - install(battle, unit) { - installFunnelPrune(battle, unit); - // "浮游单元+1": every drone locks an enemy — with fewer enemies in range than drones, the spare drone joins a - // lock (both attack it, each hit rolls the cold proc; they ramp in parallel, see funnelMap) - if (extra > 0) { - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !unit.skill?.active || !ctx.targets.length) return; - const n = 1 + extra; - if (ctx.targets.length >= n) return; - const base = ctx.targets.slice(); - const out = base.slice(); - for (let i = 0; out.length < n; i++) out.push(base[i % base.length]); - ctx.targets = out; - }, { owner: unit, priority: -50 }); - } - }, - talents: [{ install(battle, unit) { - battle.on('deploy', (ctx) => { - if (ctx.unit !== unit) return; - let low = 0; - for (const k of unit.baseRangeKeys || []) { - const r = (k / COLS) | 0, c = k % COLS; - if (battle.grid.inRect(r, c) && battle.grid.isLow(r, c)) low++; - } - const atk = low >= num(t0.cnt, 2) ? num(t0['kjera_t_1[high].atk'], num(t0.atk)) : num(t0.atk); - battle.addBuff(unit, { key: 'talent:kjera_brow', mods: { atkPct: atk } }); - }, { owner: unit }); - } }], - }; - if (extra > 0) kit.skill.targeting = { maxTargets: 1 + extra }; - return kit; - }, - - // ---- 3_21 空弦 · 速射手 — S3 箭矢·暴风: ATK +, range +1, 3 hits × 2 targets; 兰登战术: attack-SP snipers +1 SP / 2.5 s; - // 铁弦: one-hit shield at deployment, +7 SP when it breaks; 精锐 module MAR-Y: ASPD + with ground enemies in range - // S1 箭矢·散逸: next attack ×atk_scale + up to max_target−1 other enemies around the target × atk_scale_2; - // S2 箭矢·追猎 (charges): an arrow fired at once hits its target `times` times, then bounces to the nearest enemy - // around not hit yet with one hit fewer each bounce (5, 4, 3, 2, 1). 精锐 MAR-X: fly × (profession layer); - // ISW-A (集成战略 only): no range bonus here. - chess_char_3_21_a: (bb, chess, def) => { - const d = defOf(chess, def); - const t0 = talentBb(d, 0), t1 = talentBb(d, 1); - const mod = moduleTalentBb(chess); - const skill = { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - attack: { hits: Math.max(1, Math.floor(num(bb['attack@times'], 1))) }, - }; - const tg = {}; - if (num(bb.ability_range_forward_extend) > 0) tg.rangeExtend = Math.round(bb.ability_range_forward_extend); - if (num(bb['attack@max_target']) > 1) tg.maxTargets = Math.floor(bb['attack@max_target']); - if (Object.keys(tg).length) skill.targeting = tg; - const physHit = (battle, unit, e, scale, tag) => battle.dealDamage(unit, e, { amount: unit.s.atk * scale * profileMul(battle, unit, e), type: 'phys', isSkill: true, tags: ['skill', tag] }); - const kit = { - skill, - skills: altSkills(chess, d, bb, { - skchr_archet_1: (s) => { - const more = Math.max(0, Math.floor(num(s.bb.max_target, 4)) - 1); - const sc2 = num(s.bb.atk_scale_2, 1); - return { - kind: instantKindOf(s), - attack: { - atkScale: num(s.bb.atk_scale, 1), - onHit({ battle, unit, target, x, y }) { - const cx = target ? target.x : x, cy = target ? target.y : y; - if (!Number.isFinite(cx) || !Number.isFinite(cy) || !(more > 0)) return; - const others = enemiesAround(battle, unit, cx, cy, AROUND_R, new Set([target])).slice(0, more); - for (const e of others) physHit(battle, unit, e, sc2, 'archetScatter'); - if (others.length) fx(battle, 'volley', unit, { targets: others.map((e) => e.id), skill: 'archet_1' }); - }, - }, - }; - }, - skchr_archet_2: (s) => { - const times = Math.max(1, Math.floor(num(s.bb.times, 5))); - const sc = num(s.bb.atk_scale, 1); - return { - kind: instantKindOf(s), - onStart({ battle, unit }) { - const first = enemiesOn(battle, unit, unit.rangeKeys, 1, unit.profile)[0]; - if (!first) return; - const seen = new Set(); - const fire = (from, target, n) => { - seen.add(target); - battle.addProjectile({ - from, target, speed: 16, visual: 'arrow', source: unit, hitDead: true, - onHit: ({ target: t, x, y }) => { - if (t && t.alive) for (let i = 0; i < n && t.alive; i++) physHit(battle, unit, t, sc, 'archetPursuit'); - if (n <= 1) return; - const next = enemiesAround(battle, unit, x, y, AROUND_R, seen)[0]; - if (next) fire({ x, y }, next, n - 1); - }, - }); - }; - fire(unit, first, times); - fx(battle, 'volley', unit, { target: first.id, skill: 'archet_2' }); - }, - }; - }, - }), - talents: [ - { install(battle, unit) { // 兰登战术 - unit.mem.archetTactics = true; - battle.every(num(t0.interval, 2.5), () => { - if (!alive(unit) || !isLeader(battle, unit, 'archetTactics')) return; - for (const a of battle.allies(unit.ownerId)) { - if (a.kind === 'op' && a.def?.profession === 'SNIPER' && a.skill?.spType === 'attack') giveSp(a, num(t0.sp, 1), 'talent'); - } - }, { owner: unit }); - } }, - { install(battle, unit) { // 铁弦 - battle.on('deploy', (ctx) => { - if (ctx.unit !== unit) return; - battle.addBuff(unit, { - key: 'talent:archet_shield', shieldHits: 1, visible: true, - onRemove: ({ unit: u, buff }) => { - if (buff.shieldHits > 0 || !u.alive) return; - giveSp(u, num(t1.sp), 'talent'); - fx(battle, 'shieldBreak', u); - }, - }); - fx(battle, 'shield', unit); - }, { owner: unit }); - } }, - ], - }; - if (mod && num(mod.attack_speed) > 0) kit.install = groundAspd('trait:archet_ground', num(mod.attack_speed)); - return kit; - }, -}; - -export default KITS; +export default TIER_KITS[2]; diff --git a/server/sim/content/kits/tier4.js b/server/sim/content/kits/tier4.js index d4c6c56c..36dd076d 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -1,2082 +1,6 @@ -// server/sim/content/kits/tier4.js — hand-authored kits for every tier-4 chess (26: 22 visible + 4 hidden). -// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). The same kit serves the elite -// (精锐 `_b`) id: `bb` is the Lv4 / Lv7 skill blackboard, talent/trait blackboards come from `def` (module talent and -// trait upgrades of elites are already merged into the data), `def.raw.module.active` tells whether the module runs. -// Every number comes from a blackboard (skill `bb`, `def.talents[i].bb`, `def.traitBb`, token data); literals below -// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius …). -// -// Notes shared by several kits: -// - `base_attack_time` in skill blackboards is a FLAT change of the base attack time in seconds (白面鸮 −1.8 on 2.85 s -// ⇒ 1.05 s; 水月 −0.7 on 3.5 s) and is converted to the engine's `batPct` (÷ base BAT). 焰尾's +0.7 (text: 攻击间隔 -// 缩短) is a multiplier (×0.7) and 信仰搅拌机's 0.6 is the counter interval ratio — both handled in their kits. -// - "技力光环 (同类效果取最高)" (莫斯提马, 白面鸮) share the buff key `aura:spRecovery`; a unit keeps the highest one. -// - Auras refresh short buffs every AURA s so they lapse within a few ticks after the source leaves. -// - Talents that name a faction use data fields: nationId (拉特兰 laterano, 卡西米尔 kazimierz, 谢拉格 kjerag), -// profession (术师 CASTER, 重装 TANK, 先锋 PIONEER), enemy tags (萨卡兹 sarkaz, 海怪 seamonster); 深海猎人 = research -// groupId `abyssal` (data/chess.json has no groupId: charIds from docs/research/03-operators.json). -// - "友方干员" effects touch operators only (summons/devices excluded); SP gifts skip units whose timed skill runs -// (AK: no SP gain during a skill — the engine's gainSp enforces it too; the kits skip such units when picking). -// - On-hit effects of 卡涅利安's attacks land on every enemy the attack strikes — the 阵法术师 trait strikes every enemy -// on her range at once (professions.js `allInRange` / `rangeAoe`, community report E3): S2's 停顿 / 束缚 via a -// `damaged` hook, the charged S3 mark via a `hit` hook (it applies before the damage, PRTS 备注). -// - Dodge from a skill that adds to other dodge sources (焰尾 S3) is rolled independently in a `hit` hook, so the -// sources combine as 1 − Π(1 − p) (the same rule the engine now applies to stacked dodge mods). -// - Operator loadouts (DESIGN §16): every selectable non-default skill of the 22 visible chess is authored in the kit's -// `skills` map (see alt() below); logic of one skill (counters, deferrals, auras…) only runs when it is selected, -// and module effects check the selected module (moduleIs / the module-merged trait & talent blackboards). -// `node tools/kit-coverage.mjs --tier 4 --strict`; tests: test/content/kits_alt_t4.test.js. +// server/sim/content/kits/tier4.js — re-export shim (0.2.0 refactor, R4): the tier-4 kits live one per file in ops/, +// registered by index.js; their shared helpers and notes are in shared/tier4.js (README.md). This module keeps the +// old imports working until the shims are removed after the 0.2.0 refactor wave. +import { TIER_KITS } from './index.js'; -import { COLS } from '../../constants.js'; -import { bodyDist, bodyInKeys } from '../../body.js'; -import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../targeting.js'; -import { normalizeChess } from '../../simdata.js'; -import { aggregateMods } from '../../buffs.js'; -import { CAT_SHIELD_KEY } from '../tokens.js'; -import { isHpLoss } from '../../damage.js'; -import { holdsUndying } from '../items/battle.js'; - -const TICK_EPS = 0.01; // minimal status duration (s) -const AURA = 0.2; // aura refresh period (s) -const AURA_DUR = 0.25; // aura buff lifetime (s): lapses ~1 tick after the source stops refreshing it -const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']); -const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i]))); -/** 卡涅利安 S3 食噬之印: the ATK bonus climbs in 1 s steps over 20 s (PRTS 备注; the blackboard has no ramp time). */ -const BILLRO_S3_RAMP = 20; -/** 卡涅利安 S3 charged mark: one buff per enemy, shared by every 卡涅利安 (PRTS 备注), ≤ 5 stacks (skill text). */ -const BILLRO_MARK = 'billro:mark'; -/** 阿罗玛's 非首次标记 (talent 起泡性能测试): one per enemy, shared by every 阿罗玛 (PRTS 备注). */ -const AROMA_MARK = 'aroma:bubbled'; -const BILLRO_MARK_MAX = 5; - -// --------------------------------------------------------------------------------------------------------------- -// helpers - -const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : d)); -const tbb = (def, i) => (def && def.talents && def.talents[i] && def.talents[i].bb) || {}; -/** Hidden module talent blackboard (name null, e.g. 水月 move_speed, 魔王 cnt/atk). */ -const moduleBb = (def) => (def?.talents || []).filter((t) => !t.name && t.bb && Object.keys(t.bb).length).reduce((o, t) => Object.assign(o, t.bb), {}); -const grid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null); -/** Flat BAT change (s) → batPct. */ -const batFlat = (def, v) => { const b = num(def?.stats?.bat, 1) || 1; return Math.max(-0.9, num(v, 0) / b); }; -const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x); -const nationOf = (u) => u?.def?.raw?.nationId ?? null; -const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId); -const enemyHasTag = (e, tag) => !!e && e.side === 'enemy' && Array.isArray(e.def?.tags) && e.def.tags.includes(tag); -const keySet = (unit) => unit.rangeKeySet || new Set(unit.rangeKeys || []); -/** Every living, visible enemy standing on the unit's range (ignores stealth/untargetable: auras, reveals). */ -function enemiesOnRange(battle, unit, keys = null) { - const set = keys || keySet(unit); - const out = []; - for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, set)) out.push(e); - return out; -} -/** Targetable enemies in the unit's current range (flyers included), best targets first. */ -function targetsInRange(battle, unit, n = 0, keys = null) { - const list = battle.enemiesInKeys(keys || unit.rangeKeys, unit, { canHitFly: true }); - sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null); - return n > 0 && list.length > n ? list.slice(0, n) : list; -} -/** Targetable enemies on the tiles of `g` (offsets) around the unit. */ -function targetsInGrid(battle, unit, g) { - return battle.unitsInGrid(unit, g, { side: 'enemy' }).filter((e) => canTargetEnemy(unit, e, { canHitFly: true })); -} -/** Run `fn` every `iv` s while the unit is alive and deployed. */ -const whileDeployed = (battle, unit, iv, fn) => battle.every(iv, () => { if (unit.alive && unit.deployed) fn(); }, { owner: unit }); -/** Short-lived aura buff. */ -const pulse = (battle, target, key, mods, extra = {}) => battle.addBuff(target, { key, mods, duration: AURA_DUR, ...extra }); -/** Keep (or remove) a permanent self buff. */ -function toggleBuff(battle, unit, key, on, mods) { - const cur = unit.findBuff(key); - if (on) { - if (!cur || JSON.stringify(cur.mods) !== JSON.stringify(mods)) battle.addBuff(unit, { key, mods }); - } else if (cur) battle.removeBuff(unit, key); -} -/** "技力光环(同类效果取最高)": shared key, highest value wins. */ -function spAura(battle, source, value, filter) { - whileDeployed(battle, source, AURA, () => { - for (const a of battle.alliesFor(source, source.ownerId)) { - if (!filter(a)) continue; - const cur = a.findBuff('aura:spRecovery'); - if (cur && cur.source !== source && cur.source?.alive && (cur.data?.v ?? 0) > value) continue; - battle.addBuff(a, { key: 'aura:spRecovery', mods: { spRecoveryFlat: value }, duration: AURA_DUR, source, data: { v: value } }); - } - }); -} -/** - * 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK (or max HP) on top of the target's own - * percentage multipliers (the flat mod is compensated, so an ATK+% skill does not scale it), the strongest source wins - * (the ATK key `inspire` is shared with tier6's 鼓舞); units flagged `mem.noInspire` (bards: 自身不受鼓舞影响) never - * receive it. Refreshed by an aura: lapses within AURA_DUR once the source stops. - */ -function inspire(battle, target, val, src, stat = 'atk') { - if (!(val > 0) || !target || !target.alive || target.mem?.noInspire) return; - const key = stat === 'hp' ? 'inspire:hp' : 'inspire'; - const cur = target.findBuff(key); - if (cur && cur.data && cur.data.src !== src.id && cur.data.val > val && cur.timeLeft > 1e-6) return; - const { add, mul } = aggregateMods(target.buffs.filter((b) => b.key !== key)); - const f = stat === 'hp' ? Math.max(0, 1 + (add.hpPct ?? 0)) * (mul.hpMul ?? 1) : Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1); - const flat = f > 1e-6 ? val / f : val; - battle.addBuff(target, { key, mods: stat === 'hp' ? { hpFlat: flat } : { atkFlat: flat }, duration: AURA_DUR, source: src, visible: true, data: { src: src.id, val } }); -} -/** Reveal stealthed enemies on the given tiles. */ -/** RES cut mods for a blackboard magic_resistance value (battle.applyStrongest): |v| < 1 = ×(1 + v), else flat v. */ -const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v }); -const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); }; -/** - * Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉; - * user playtest #6 item 14). Returns the tiles moved. - */ -function shove(battle, e, from, force) { - return e && e.alive ? battle.push(e, num(force, 0), { from }) : 0; -} -/** - * Free tile for a summon/device: in the rect, standable, not reserved (Battle.isReservedTile: empty, no knocked-out - * operator lying there) and not the home tile of any ally (dead ones redeploy there). - */ -function freeTile(battle, r, c, { ranged = false, ground = false } = {}) { - if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c)) return false; - if (battle.isReservedTile(r, c)) return false; - if (!battle.grid.canStand(r, c, { ranged })) return false; - if (ground && !battle.grid.groundPassable(r, c)) return false; - for (const u of battle.allyUnits) if (!u.removed && u.homeR === r && u.homeC === c && u.kind !== 'device') return false; - return true; -} -const skillActive = (u) => !!(u.skill && u.skill.active && u.skill.kind !== 'passive'); -/** HP at 0 (boss pool: pool HP) — a `damaged` hook sees the lethal hit before the kill, while the unit is still `alive`. */ -const downed = (u) => (u.bossPool ? !(u.bossPool.hp > 0) : !(u.hp > 0)); - -// ---- operator loadouts (DESIGN §16) ---------------------------------------------------------------------------- -// The kit function receives the def of the SELECTED skill / module: `bb` = that skill's blackboard (normal Lv4 / -// elite Lv7), `def.skill` its record, talents / trait already carry the selected module's changes. `skills` holds the -// hand-authored spec of every selectable non-default skill (only the selected one is built, with its own `bb`); -// install / talent logic that belongs to ONE skill is gated on that skill being the selected one. - -/** Id of the selected skill. */ -const selId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? null; -/** Is `id` the selected skill? */ -const isSel = (def, id) => selId(def) === id; -/** `skills` map of a kit: the spec of the selected skill when a builder exists for it (DESIGN §16 kit contract). */ -function alt(def, builders) { - const id = selId(def); - return id && typeof builders[id] === 'function' ? { [id]: builders[id]() } : {}; -} -/** Is module `id` the active one? (elites; `'none'` / normal chess ⇒ false) */ -const moduleIs = (def, id) => !!(def?.raw?.module?.active && def.raw.module.id === id); -/** Instant or charges kind from the data (maxChargeTime). */ -const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant'); -/** Enemy leaders / elites (精英或领袖敌人). */ -const isEliteEnemy = (e) => !!e && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS'); -/** No other ally operator / summon on the 4 (or 8) tiles around the unit. */ -function lonely(battle, unit, diag = false) { - for (let dr = -1; dr <= 1; dr++) { - for (let dc = -1; dc <= 1; dc++) { - if ((!dr && !dc) || (!diag && dr && dc)) continue; - const r = unit.tileR + dr, c = unit.tileC + dc; - if (!battle.grid.inBounds(r, c)) continue; - const o = battle.unitAt(r, c); - if (o && o !== unit && o.side === 'ally' && o.kind !== 'device') return false; - } - } - return true; -} -/** - * Permanent forward range extension (攻击距离 +n): every range, skill ranges included — except a skill whose range ignores - * 攻击距离 (`targeting.noRangeExtend`). SPT-Y's "攻击距离+1(不受技能攻击范围变化影响)" reads, as PRTS 修正 it, "(部分技能不受 - * 此影响)" (原因: 语序颠倒、表述模糊) — 信仰搅拌机 S3 备注 "此技能的攻击范围不受'攻击距离'属性影响". - */ -const rangeUp = (battle, unit, n = 1) => battle.addBuff(unit, { key: 'module:range', mods: { rangeExtend: n }, persist: true, allowDead: true }); -/** - * Module "攻击范围扩大" given as a new range (the selected module's data-only talent `rangeGrid`, e.g. SPC-X / RIN-X: - * the 3×3 caster range + ONE centre tile [0,3]). It replaces the unit's own range — so also the initial range of the - * DEFAULT trigger — while a skill with its own range (莫斯提马 S3, 白面鸮 S2…) keeps that range, as in AK. Null when the - * selected module (or 'none') changes no range. (The rangeGrid-only talent is dropped from `def.talents`: read the - * module record.) - */ -function moduleRangeGrid(def) { - const m = def?.raw?.module; - if (!m || !m.active || !m.id) return null; - const rec = (def.raw.modules || []).find((x) => x && x.uniEquipId === m.id); - for (const t of rec?.talentChanges || []) { const g = grid(t?.rangeGrid); if (g) return g; } - return null; -} -const applyModuleRange = (battle, unit, def) => { const g = moduleRangeGrid(def); if (g) { unit.rangeGrid = g; battle.refreshRange(unit); } }; -/** - * Pull `e` "至面前" of the unit with 力度 `force` (Battle.pullToFront: the official 拉力起点 half a tile ahead, 急停 0.6708 - * around it, 力度 − 重量 — weight ≤ force all the way, one heavier a third of the way, …); returns the distance moved. - */ -function pullToFront(battle, unit, e, force) { - return e && e.alive ? battle.pullToFront(e, unit, num(force, 0)) : 0; -} - -// --------------------------------------------------------------------------------------------------------------- -// kits - -const kits = { - // ===== 信仰搅拌机 (shotprotector) S3 退休前布道 — ammo 30 counters; talents 扫射迎宾仪礼 / 架盾送客仪礼; module: reveal - // S1 铳骑主考官 (自动触发 ⇒ DEFAULT: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo; - // a fatal hit is blocked for `ammo_cost` bullets); module SPT-Y 老朋友: range +1 (not on S3's range, PRTS 备注) - chess_char_4_01_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const counterMax = Math.max(1, Math.floor(num(bb['attack@max_target'], 3))); - const counterRatio = num(bb.base_attack_time, 0.6); // 反击最小间隔 = 实际攻击间隔 × ratio (not a BAT change) - const g = grid(def.skill?.rangeGrid); - const S2 = isSel(def, 'skchr_rmixer_2'), S3 = isSel(def, 'skchr_rmixer_3'); - return { - skills: alt(def, { - // 铳骑主考官: the next attack is a triple hit of atk_scale each and reloads `charge` bullet(s) into ONE other - // 拉特兰 operator around him (8 tiles, [ASSUMED] nearest first) whose ammo skill runs - skchr_rmixer_1: () => ({ - kind: instantKind(def), - attack: { - atkScale: num(bb.atk_scale, 1.3), hits: 3, - onHit({ battle, unit }) { - const n = num(bb.charge, 1); - const cand = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId) - .filter((a) => a !== unit && a.kind === 'op' && nationOf(a) === 'laterano' && a.skill && a.skill.active && a.skill.kind === 'ammo') - .sort((a, b) => Math.hypot(a.x - unit.x, a.y - unit.y) - Math.hypot(b.x - unit.x, b.y - unit.y) || a.id - b.id); - if (!cand[0] || !(n > 0)) return; - cand[0].skill.addAmmo(n); - battle.fx('reload', { x: cand[0].x, y: cand[0].y, id: cand[0].id, n }); - }, - }, - }), - // 八臂电锯侠: ATK/DEF up, 42/45 bullets; the fatal-hit guard is installed below - skchr_rmixer_2: () => ({ - kind: 'ammo', ammo: num(bb['attack@trigger_time'], 42), - mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, - onStart({ battle, unit }) { battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); }, - }), - }), - skill: { - kind: 'ammo', ammo: num(bb['attack@trigger_time'], 30), - mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) }, - // 3-13 exactly: SPT-Y's 攻击距离+1 does not reach it (PRTS 备注; reviewer of community report E1 after 0.1.0) - targeting: g ? { rangeGrid: g, noRangeExtend: true } : undefined, - attack: { noAttack: true }, // 停止主动攻击敌人 (ammo is spent by counters) - onStart({ battle, unit }) { - unit.mem.counterReady = -Infinity; - for (const a of battle.allies(unit.ownerId)) { - if (a === unit || a.kind !== 'op' || nationOf(a) !== 'laterano') continue; - if (a.skill && a.skill.active && a.skill.kind === 'ammo') { a.skill.addAmmo(num(bb.ammo, 0)); battle.fx('reload', { x: a.x, y: a.y, id: a.id, n: num(bb.ammo, 0) }); } - } - battle.fx('sermon', { x: unit.x, y: unit.y, id: unit.id }); - }, - }, - talents: [ - { install(battle, unit) { // 扫射迎宾仪礼: every damage dealt → DEF +30 / ASPD +3 for 10 s, ≤ 3 stacks - battle.on('damaged', (c) => { - if (c.source !== unit || !unit.alive || c.target.side !== 'enemy' || !(c.amount > 0)) return; - battle.addBuff(unit, { key: 'rmixer:t1', duration: num(t0.duration, 10), refresh: 'stack', maxStacks: Math.max(1, num(t0.max_stack_cnt, 3)), - mods: { defFlat: num(t0.def), aspd: num(t0.attack_speed) } }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 架盾送客仪礼: no active attack for 8 s → shield 15 % max HP; timer restarts when it breaks - const since = () => Math.max(unit.deployedAt, unit.mem.lastActiveAtk ?? -Infinity, unit.mem.shieldLostAt ?? -Infinity); - battle.on('attack', (c) => { if (c.attacker === unit && !unit.mem.inCounter) unit.mem.lastActiveAtk = battle.time; }, { owner: unit }); - battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.lastActiveAtk = -Infinity; unit.mem.shieldLostAt = -Infinity; } }, { owner: unit }); - whileDeployed(battle, unit, 0.1, () => { - if (unit.findBuff('rmixer:shield') || battle.time - since() < num(t1.interval, 8) - 1e-9) return; - battle.addBuff(unit, { key: 'rmixer:shield', shield: unit.s.maxHp * num(t1.shield, 0.15), visible: true, - onRemove: () => { unit.mem.shieldLostAt = battle.time; } }); - battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); - }); - } }, - ], - install(battle, unit) { - if (S2) { - // S2 "技能期间若受到致命伤害,立即消耗N发弹药抵挡这次伤害": the lethal hit is negated (HP back to its value - // before the hit) and N bullets go; with fewer left it still blocks, spends them all and the skill ends (PRTS 备注 - // "弹药量不足时仍可抵挡致命伤害,此时将消耗所有剩余弹药并退出技能状态"). "自身持有不死时此效果不会生效": a 不死 - // that runs before it (骑士戒律, priority 20) has already prevented the hit; 坚固维式重锤's held window - // (items/battle.js holdsUndying: this deployment's, not run out) runs last, so the guard steps aside while it lasts - const cost = Math.max(1, Math.floor(num(bb.ammo_cost, 30))); - battle.on('hit', (c) => { if (c.target === unit) unit.mem.rmixerPre = { dmg: c.dmg, hp: unit.hp }; }, { owner: unit, priority: -100 }); - battle.on('fatal', (c) => { - const sk = unit.skill; - if (c.unit !== unit || c.prevented || !sk || !sk.active || sk.kind !== 'ammo' || !(sk.ammoLeft > 0)) return; - if (holdsUndying(battle, unit)) return; - c.prevented = true; - const pre = unit.mem.rmixerPre; - unit.hp = pre && pre.dmg === c.dmg ? Math.max(1, Math.min(unit.s.maxHp, pre.hp)) : 1; - const spent = Math.min(cost, sk.ammoLeft); - sk.ammoLeft -= spent; - battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, n: spent }); - if (sk.ammoLeft <= 0) sk.end('ammo'); - }, { owner: unit }); - } - // module SPT-Y (elite, 老朋友): 攻击距离+1 — S1 / S2 keep it, S3's 3-13 does not (noRangeExtend above) - if (num(def.traitBb?.ability_range_forward_extend, 0) > 0) rangeUp(battle, unit, num(def.traitBb.ability_range_forward_extend, 1)); - // module SPT-X (elite default): 攻击范围内敌人的隐匿效果失效 - if (moduleIs(def, 'uniequip_002_rmixer')) whileDeployed(battle, unit, AURA, () => reveal(battle, enemiesOnRange(battle, unit))); - if (!S3) return; - // S3 counter (PRTS 备注 "反击受到任何伤害后均可触发,无需目标,视为普通攻击,受各类无法触发普通攻击效果的影响"): ANY damage - // she takes — an enemy's attack, a zone, the 无来源 源石溶剂 tick (items/battle.js periodic_damage) — fires one volley at - // ≤ 3 enemies in range (the attacker first), at most once per actual interval × ratio. With no enemy in range the - // counter still happens and spends its bullet (无需目标: the drain alone empties the skill — 莫斯提马's 特质 turns those - // bullets into 拉特兰 layers, player report D1). A 流失 (Battle.loseHp, tag 'hpLoss': it skips every damage event — - // PRTS 作战机制) and an element 损伤 never counter; stunned / disarmed: no counter - battle.on('damaged', (c) => { - const d = c.dmg; - if (c.target !== unit || !unit.canAct || unit.s.flags.disarm || !skillActive(unit) || !d || c.type === 'element') return; - if (isHpLoss(d)) return; - if (battle.time < (unit.mem.counterReady ?? -Infinity) - 1e-9) return; - const list = targetsInRange(battle, unit); - const i = c.source ? list.indexOf(c.source) : -1; - if (i > 0) { list.splice(i, 1); list.unshift(c.source); } - const targets = list.slice(0, counterMax); - unit.mem.counterReady = battle.time + unit.s.interval * counterRatio; - unit.mem.inCounter = true; - try { - if (targets.length) battle.forceAttack(unit, targets); - else { unit.stats.attacks++; unit.skill.onAttackPerformed([], true); } // a counter into nothing: its bullet goes - } finally { unit.mem.inCounter = false; } - battle.fx('counter', { x: unit.x, y: unit.y, id: unit.id, n: targets.length }); - }, { owner: unit }); - }, - }; - }, - - // ===== 莫斯提马 (splashcaster) S3 序时之匙 — ripple hits all in range, +ATK, talent 2 ×3, small knock-back - // S1 攻击力强化·γ型; S2 荒时之锁 (every enemy in range stunned for the rest of the skill, 100 %/120 % ATK arts - // per second); module SPC-X (资深万国信使定制斗篷): 攻击范围扩大 - chess_char_4_02_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const g = grid(def.skill?.rangeGrid); - const force = num(bb['attack@force'], num(bb.force, 0)); - const S3 = isSel(def, 'skchr_mostma_3'); - return { - skills: alt(def, { - 'skcom_atk_up[3]': () => ({ kind: 'duration', mods: { atkPct: num(bb.atk) } }), - skchr_mostma_2: () => ({ - kind: 'duration', - onStart({ battle, unit, skill }) { - unit.mem.timeLock = { acc: 0, locked: new Set() }; - battle.fx('zone', { x: unit.x, y: unit.y, id: unit.id, duration: skill.duration }); - }, - onTick({ battle, unit, skill, dt }) { - const L = unit.mem.timeLock; - if (!L) return; - // every enemy standing in her range (entering later too) is stunned until the skill ends - for (const e of enemiesOnRange(battle, unit)) { - if (L.locked.has(e.id)) continue; - L.locked.add(e.id); - battle.applyStatus(e, 'stun', { duration: Math.max(TICK_EPS, skill.timeLeft), source: unit }); - } - L.acc += dt; - while (L.acc + 1e-9 >= 1) { - L.acc -= 1; - for (const e of enemiesOnRange(battle, unit)) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'timeLock'] }); - } - } - }, - onEnd({ unit }) { unit.mem.timeLock = null; }, - }), - }), - install(battle, unit) { applyModuleRange(battle, unit, def); }, // module SPC-X: 攻击范围扩大 (module range grid) - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - targeting: { ...(g ? { rangeGrid: g } : {}), allInRange: true }, - attack: { projectile: 'none', splashRadius: 0, onHit({ battle, unit, target }) { if (target && target.alive) shove(battle, target, unit, force); } }, - onAttack({ battle, unit }) { battle.fx('ripple', { x: unit.x, y: unit.y, id: unit.id }); }, - }, - talents: [ - { install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec, 0.4), (a) => a.def?.profession === 'CASTER'); } }, - { install(battle, unit) { // 主观缓时: enemies in range −15 % move speed (×talent_scale during S3) - whileDeployed(battle, unit, AURA, () => { - const v = num(t1.move_speed, -0.15) * (S3 && skillActive(unit) ? num(bb.talent_scale, 3) : 1); - for (const e of enemiesOnRange(battle, unit)) pulse(battle, e, `mostma:slow:${unit.id}`, { moveMul: Math.max(0, 1 + v) }, { status: 'slow', visible: true }); - }); - } }, - ], - }; - }, - - // ===== 耶拉 (funnel, hidden) S2 心随意动 — +1 drone locking targets, ATK up, chance to chill; talent 低眉 - chess_char_4_03_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const n = 1 + Math.max(0, Math.floor(num(bb['attack@cnt'], 1))); - const prob = num(bb['attack@prob'], 0), cold = num(bb['attack@cold'], 0); - const funnel = (unit) => unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; - return { - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - targeting: { maxTargets: n }, - attack: { - // every drone ramps on its own locked target (trait init/delta/max): the multipliers are queued per target - // by the beforeAttack hook below (two drones on the same enemy ramp independently) - dmgMul: (battle, unit, target) => { - const q = unit.mem.kjeraQueue && unit.mem.kjeraQueue.get(target.id); - return q && q.length ? q.shift() : funnel(unit).init; - }, - onHit({ battle, unit, target }) { - if (target && target.alive && cold > 0 && battle.rng.chance(prob)) battle.applyStatus(target, 'cold', { duration: cold, source: unit }); - }, - }, - onStart({ battle, unit }) { unit.mem.kjeraDrones = []; unit.mem.kjeraQueue = new Map(); battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n }); }, - onEnd({ unit }) { unit.mem.kjeraDrones = []; unit.mem.kjeraQueue = null; }, - }, - talents: [{ install(battle, unit) { // 低眉: ATK +10 %, +16 % with ≥ 2 ground tiles in range - battle.on('deploy', (c) => { - if (c.unit !== unit) return; - let low = 0; - for (const k of unit.rangeKeys || []) if (battle.grid.isLow((k / COLS) | 0, k % COLS)) low++; - const v = low >= num(t0.cnt, 2) ? num(t0['kjera_t_1[high].atk'], num(t0.atk)) : num(t0.atk); - battle.addBuff(unit, { key: 'kjera:t1', mods: { atkPct: v } }); - }, { owner: unit }); - } }], - install(battle, unit) { - // n drones, each locked on its enemy until that enemy dies (while at least one enemy is in her range). A free - // drone takes an enemy no other drone holds, else doubles up on the best target (a lone enemy/boss gets both). - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit || !skillActive(unit)) return; - const ok = (e) => !!e && e.alive && canTargetEnemy(unit, e, { canHitFly: true }); - const cands = c.targets.filter(ok); - const drones = unit.mem.kjeraDrones || (unit.mem.kjeraDrones = []); - const held = new Set(); - for (let i = 0; i < n; i++) { if (drones[i] && ok(drones[i].e)) held.add(drones[i].e); else drones[i] = null; } - for (let i = 0; i < n; i++) { - if (drones[i]) continue; - const e = cands.find((x) => !held.has(x)) ?? cands[0] ?? drones.find((d) => d)?.e; - if (!e) continue; - held.add(e); - drones[i] = { e, scale: null }; - } - const f = funnel(unit); - const q = unit.mem.kjeraQueue || (unit.mem.kjeraQueue = new Map()); - const out = []; - for (const d of drones) { - if (!d) continue; - d.scale = d.scale == null ? f.init : Math.min(f.max, d.scale + f.delta); - out.push(d.e); - const l = q.get(d.e.id); - if (l) l.push(d.scale); else q.set(d.e.id, [d.scale]); - } - if (out.length) c.targets = out; - }, { owner: unit, priority: 10 }); - // queued multipliers of shots that never landed (target died mid-flight) are dropped with the target - battle.on('death', (c) => { if (c.unit.side === 'enemy' && unit.mem.kjeraQueue) unit.mem.kjeraQueue.delete(c.unit.id); }, { owner: unit }); - }, - }; - }, - - // ===== 伊内丝 (agent) S2 暗夜无明 — stealth, +ATK, +1 DP & steal ASPD per attack; talents 影织 / 影哨 - // S1 淬影突袭 (next attack: 3 s arts DoT 40 %/55 % ATK per s, not stacking, +2 DP); S3 独影归途 (passive: the - // first deployment leaves a 影哨 and retreats with the redeploy timer refreshed; every deployment: ATK up for - // the skill duration, the sentry is recalled through ≤ 4/5 enemies for 110 %/140 % phys, +1 DP per damage) - chess_char_4_04_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const tb = def.traitBb || {}; - const g = grid(def.skill?.rangeGrid); - const step = num(bb['attack@steal_atk_speed'], 5), maxSteal = num(bb['attack@steal_atk_speed_max'], 50); - const S3 = isSel(def, 'skchr_ines_3'); - const clearAspd = (battle, unit) => { - for (const id of unit.mem.inesAspdVictims || []) { const e = battle.unitById(id); if (e) battle.removeBuff(e, `ines:aspd:${unit.id}`); } - unit.mem.inesAspdVictims = []; - unit.mem.inesAspd = 0; - battle.removeBuff(unit, 'ines:aspdGain'); - }; - const s3Dur = num(def.skill?.duration, 11); - return { - skills: alt(def, { - skchr_ines_1: () => ({ - kind: instantKind(def), - attack: { - onHit({ battle, unit, target }) { - battle.addDp(unit.ownerId, num(bb.cost, 2)); - if (!target || !target.alive || target.side !== 'enemy') return; - const scale = num(bb.bleed_atk_scale, 0.4); - // (不叠加): a new DoT replaces the running one - battle.addBuff(target, { key: `ines:bleed:${unit.id}`, duration: num(bb.bleed_duration, 3) + 1e-6, interval: 1, source: unit, visible: true, refresh: 'extend', - onTick: ({ unit: t }) => battle.dealDamage(unit, t, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'dot'] }) }); - }, - }, - }), - skchr_ines_3: () => ({ - kind: 'passive', - onStart({ battle, unit }) { - if (!unit.mem.inesS3Placed) { - // 首次部署: place a 影哨 (her talent's sentry, left on retreat) and leave; 立刻刷新再部署时间 — the redeploy - // (auto, paying her DP cost like every redeploy) follows as soon as it is affordable - unit.mem.inesS3Placed = true; - battle.after(0, () => { - if (!unit.alive || !unit.deployed) return; - battle.retreat(unit, { reason: 'retreat' }); - unit.respawnAt = battle.time; - battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - return; - } - battle.addBuff(unit, { key: 'ines:s3', duration: s3Dur, mods: { atkPct: num(bb.atk) }, visible: true }); - // 立刻收回影哨: the sentry flies back to her and hits ≤ max_target enemies on its way (within - // projectile_range of the segment, [ASSUMED] nearest to its start first) - const from = unit.mem.inesSentryAt; - if (!unit.mem.sentry || !from) return; - unit.mem.sentry = null; - unit.mem.inesSentryAt = null; - const ax = from.x, ay = from.y, bx = unit.x, by = unit.y; - const L2 = (bx - ax) ** 2 + (by - ay) ** 2; - const w = num(bb.projectile_range, 1.4); - const hits = []; - for (const e of battle.aliveEnemies()) { - if (!canTargetEnemy(unit, e, { canHitFly: true })) continue; - const t = L2 > 1e-9 ? Math.max(0, Math.min(1, ((e.x - ax) * (bx - ax) + (e.y - ay) * (by - ay)) / L2)) : 0; - const d = bodyDist(e, ax + t * (bx - ax), ay + t * (by - ay)); - if (d <= w + 1e-9) hits.push({ e, t, d }); - } - hits.sort((a, b) => a.t - b.t || a.d - b.d || a.e.id - b.e.id); - const amount = unit.s.atk * num(bb.atk_scale, 1.1); // one projectile: ATK taken when it flies back - for (const { e } of hits.slice(0, Math.max(1, Math.floor(num(bb.max_target, 4))))) { - battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, tags: ['skill', 'sentryRecall'] }); - } - battle.fx('beam', { x: ax, y: ay, tx: bx, ty: by, id: unit.id }); - }, - }), - }), - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - flags: { stealth: true }, - targeting: g ? { rangeGrid: g } : undefined, - onStart({ battle, unit }) { clearAspd(battle, unit); battle.fx('stealth', { x: unit.x, y: unit.y, id: unit.id }); }, - onAttack({ battle, unit }) { battle.addDp(unit.ownerId, num(bb.cost, 1)); }, - onHit({ battle, unit, target }) { - if (!target || !target.alive || target.side !== 'enemy') return; - const room = maxSteal - (unit.mem.inesAspd ?? 0); - if (room <= 0) return; - const s = Math.min(step, room); - unit.mem.inesAspd = (unit.mem.inesAspd ?? 0) + s; - const key = `ines:aspd:${unit.id}`; - const cur = target.findBuff(key); - battle.addBuff(target, { key, mods: { aspd: (cur?.mods?.aspd ?? 0) - s }, source: unit }); - if (!(unit.mem.inesAspdVictims ||= []).includes(target.id)) unit.mem.inesAspdVictims.push(target.id); - battle.addBuff(unit, { key: 'ines:aspdGain', mods: { aspd: unit.mem.inesAspd } }); - }, - onEnd({ battle, unit }) { clearAspd(battle, unit); }, - }, - talents: [ - { install(battle, unit) { // 影织: first damage on each enemy → bind 5 s + steal 90 ATK until it dies or Ines leaves - unit.mem.woven = new Set(); - unit.mem.atkVictims = []; - const refresh = () => { - unit.mem.atkVictims = unit.mem.atkVictims.filter((id) => battle.unitById(id)?.alive); - const gain = Math.min(num(t0.steal_atk_max, Infinity), num(t0.steal_atk, 90) * unit.mem.atkVictims.length); - if (unit.alive) { if (gain > 0) battle.addBuff(unit, { key: 'ines:atkGain', mods: { atkFlat: gain } }); else battle.removeBuff(unit, 'ines:atkGain'); } - }; - battle.on('damaged', (c) => { - const e = c.target; - if (c.source !== unit || !unit.alive || e.side !== 'enemy' || c.type === 'element' || unit.mem.woven.has(e.id)) return; - unit.mem.woven.add(e.id); - if (!e.alive) return; - battle.applyStatus(e, 'bind', { duration: num(t0.duration, 5), source: unit }); - battle.addBuff(e, { key: `ines:atkSteal:${unit.id}`, mods: { atkFlat: -num(t0.steal_atk, 90) }, source: unit }); - unit.mem.atkVictims.push(e.id); - refresh(); - battle.fx('shadowWeave', { x: e.x, y: e.y, id: e.id }); - }, { owner: unit }); - battle.on('death', (c) => { - if (c.unit.side === 'enemy' && unit.mem.atkVictims.includes(c.unit.id)) refresh(); - if (c.unit !== unit) return; - for (const id of unit.mem.atkVictims) { const e = battle.unitById(id); if (e) battle.removeBuff(e, `ines:atkSteal:${unit.id}`); } - unit.mem.atkVictims = []; - unit.mem.woven = new Set(); - }, { owner: unit }); - } }, - { install(battle, unit) { // 影哨: reveal + −30 % move speed in range; a sentry keeps it after she leaves (max 1) - const mods = { moveMul: Math.max(0, 1 + num(t1.move_speed, -0.3)) }; - battle.on('death', (c) => { - if (c.unit !== unit || c.reason === 'expired') return; - unit.mem.sentry = new Set(unit.baseRangeKeys || unit.rangeKeys || []); - battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - battle.every(AURA, () => { - const set = new Set(); - if (unit.alive && unit.deployed) for (const k of unit.rangeKeys || []) set.add(k); - if (unit.mem.sentry) for (const k of unit.mem.sentry) set.add(k); - if (!set.size) return; - for (const e of enemiesOnRange(battle, unit, set)) pulse(battle, e, `ines:sentry:${unit.id}`, mods, { flags: { reveal: true } }); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - if (S3) { - // where the talent's 影哨 stays (every leave but an expiry), for the recall of the next deployment - battle.on('death', (c) => { if (c.unit === unit && c.reason !== 'expired') unit.mem.inesSentryAt = { x: unit.x, y: unit.y }; }, { owner: unit, priority: 5 }); - // 技能期间每对一个敌人造成伤害就获得1点部署费用 - battle.on('damaged', (c) => { - if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.findBuff('ines:s3')) return; - battle.addDp(unit.ownerId, num(bb.cost, 1)); - }, { owner: unit }); - } - // module (elite): the first retreat's redeploy time is 35 % shorter - const rt = num(tb.respawn_time, 0); - if (!rt) return; - battle.on('death', (c) => { - if (c.unit !== unit || unit.mem.firstRetreatDone || !Number.isFinite(unit.respawnAt)) return; - unit.mem.firstRetreatDone = true; - unit.respawnAt = unit.deathAt + (unit.respawnAt - unit.deathAt) * Math.max(0, 1 + rt); - }, { owner: unit }); - }, - }; - }, - - // ===== 蜜蜡 (phalanx, hidden) S2 守卫尖碑 — obelisk on a melee tile in range: 200 % arts burst + stun, blocks 3 - chess_char_4_05_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const tb = def.traitBb || {}; - const tokenId = def.raw?.skill?.overrideTokenKey ?? (def.tokens || [])[0] ?? 'token_10011_beewax_oblisk'; - const keepDef = num(tb['soil_e_002[buff].def'], 0), keepRes = num(tb['soil_e_002[buff].magic_resistance'], 0); - const obeliskKit = { skill: null, talents: [], trait: { noAttack: true } }; - return { - skill: { - kind: 'duration', - onStart({ battle, unit }) { - if (keepDef || keepRes) battle.addBuff(unit, { key: 'beewax:keep', mods: { defPct: keepDef, resFlat: keepRes } }); - // melee deploy tile in range: nearest to the most advanced enemy (path tile first) - const foes = targetsInRange(battle, unit); - const ref = foes[0] || battle.aliveEnemies().filter((e) => !e.hidden).sort((a, b) => battle.remainingDistance(a) - battle.remainingDistance(b))[0] || unit; - let best = null, bd = Infinity; - for (const k of unit.rangeKeys || []) { - const r = (k / COLS) | 0, c = k % COLS; - if (!freeTile(battle, r, c)) continue; - const d = Math.hypot(c - ref.x, r - ref.y) + (battle.grid.groundPassable(r, c) ? 0 : 5); - if (d < bd - 1e-9) { bd = d; best = [r, c]; } - } - if (!best) return; - const tok = battle.tokenDef(tokenId, unit); // the owner's skill / module variant (DESIGN §16) - const dur = num(tok?.talents?.[0]?.bb?.duration, num(def.skill?.duration, 20)); - const ob = battle.spawnToken(unit, tokenId, best[0], best[1], { duration: dur, kit: obeliskKit }); - if (!ob) return; - unit.mem.obelisk = ob; - battle.fx('obelisk', { x: ob.x, y: ob.y, id: ob.id }); - const tokGrid = grid(tok?.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]]; - for (const e of targetsInGrid(battle, ob, tokGrid)) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 2), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); - if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 1), source: unit }); - } - }, - onEnd({ battle, unit }) { battle.removeBuff(unit, 'beewax:keep'); }, - }, - talents: [{ install(battle, unit) { // 沙原的庇护: +4 % max HP/s while the skill is off - const set = () => toggleBuff(battle, unit, 'beewax:regen', unit.alive && !skillActive(unit), { hpRegenRatio: num(t0.hp_recovery_per_sec_by_max_hp_ratio, 0.04) }); - for (const ev of ['deploy', 'skillStart', 'skillEnd']) battle.on(ev, (c) => { if (c.unit === unit) set(); }, { owner: unit }); - } }], - }; - }, - - // ===== 寒芒克洛丝 (fastshot) S2 封喉 — double shot → 4 shots after 40 hits; talent 中的 (20 % ×1.5 + 0.2 s stun) - // S1 无痕 (ATK up, double shot, 迷彩 = the engine's `camou` flag: only an enemy she blocks targets her) - chess_char_4_06_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const need = num(bb['attack@max_stack_count'], 40); - const S2 = isSel(def, 'skchr_kroos2_2'); - return { - skills: alt(def, { - skchr_kroos2_1: () => ({ - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - flags: { camou: true }, - attack: { hits: 2 }, - onStart({ battle, unit }) { battle.fx('camouflage', { x: unit.x, y: unit.y, id: unit.id }); }, - }), - }), - skill: { - kind: 'duration', - mods: { batPct: batFlat(def, bb.base_attack_time) }, - attack: { hitsFn: (battle, unit) => ((unit.mem.kroosHits ?? 0) >= need ? 4 : 2) }, - onStart({ unit }) { unit.mem.kroosHits = 0; }, - }, - talents: [{ install(battle, unit) { - // 中的: the ×1.5 is rolled before mitigation; the stun (and the S2 "击中目标" count) only for shots that landed - const crits = new WeakSet(); - battle.on('hit', (c) => { - if (c.source !== unit || !c.dmg.isAttack || c.target.side !== 'enemy') return; - if (battle.rng.chance(num(t0.prob, 0.2))) { c.dmg.mul *= num(t0.atk_scale, 1.5); crits.add(c.dmg); } - }, { owner: unit }); - battle.on('damaged', (c) => { - if (c.source !== unit || !c.dmg || !c.dmg.isAttack || c.target.side !== 'enemy') return; - if (S2 && skillActive(unit)) { - unit.mem.kroosHits = (unit.mem.kroosHits ?? 0) + 1; - if (unit.mem.kroosHits === need) battle.fx('quadShot', { x: unit.x, y: unit.y, id: unit.id }); - } - if (!crits.has(c.dmg)) return; - crits.delete(c.dmg); - if (c.target.alive) battle.applyStatus(c.target, 'stun', { duration: num(t0.stun, 0.2), source: unit }); - battle.fx('crit', { x: c.target.x, y: c.target.y, id: c.target.id }); - }, { owner: unit }); - } }], - }; - }, - - // ===== 风笛 (charger) S2 高效冲击 — next attack 145 % ×2 (charges); talents 精密填弹 / 军事传统 - // S1 迅捷打击·γ型; S3 闭膛连发 (BAT +0.7 s, block +1, ATK/DEF up, triple hits); module CHG-Y (“棍棒与口袋”): - // attacks on enemies below 40 % HP use 115 % ATK - chess_char_4_07_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const tb = def.traitBb || {}; - return { - skills: alt(def, { - 'skcom_quickattack[3]': () => ({ kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } }), - skchr_bpipe_3: () => ({ - kind: 'duration', - mods: { atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt, 1), batPct: batFlat(def, bb.base_attack_time) }, - attack: { hits: 3 }, - onStart({ battle, unit }) { battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id }); }, - }), - }), - install(battle, unit) { - const hr = num(tb.hp_ratio, 0), sc = num(tb.atk_scale, 0); - if (!(hr > 0) || !(sc > 0)) return; - battle.on('hit', (c) => { // 攻击力提升至115% (before mitigation) - if (c.source === unit && c.dmg.isAttack && c.target.side === 'enemy' && c.target.hpRatio < hr) c.dmg.amount *= sc; - }, { owner: unit }); - }, - skill: { - kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', - attack: { atkScale: num(bb.atk_scale, 1.45), hits: 2 }, - onStart({ battle, unit }) { battle.fx('charge', { x: unit.x, y: unit.y, id: unit.id }); }, - }, - talents: [ - { install(battle, unit) { // 精密填弹: 25 % → ×1.3 and one extra target - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit) return; - unit.mem.bpBoost = battle.rng.chance(num(t0.prob, 0.25)); - if (!unit.mem.bpBoost) return; - const extra = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile).filter((e) => !c.targets.includes(e)); - sortEnemyTargets(battle, unit, extra, c.profile?.priority ?? null); - if (extra[0]) c.targets = [...c.targets, extra[0]]; - }, { owner: unit }); - battle.on('hit', (c) => { if (c.source === unit && c.dmg.isAttack && unit.mem.bpBoost) c.dmg.mul *= num(t0.atk_scale, 1.3); }, { owner: unit }); - battle.on('attack', (c) => { if (c.attacker === unit) unit.mem.bpBoost = false; }, { owner: unit }); - } }, - { install(battle, unit) { // 军事传统: every 先锋 of the team starts with +6 SP - battle.on('deploy', (c) => { - const u = c.unit; - if (u.side !== 'ally' || u.kind !== 'op' || u.ownerId !== unit.ownerId || u.def?.profession !== 'PIONEER' || !u.skill || (u.skill.active && u.skill.isTimed)) return; - u.skill.gainSp(num(t1.sp, 6), 'init'); - }, { owner: unit }); - } }, - ], - }; - }, - - // ===== 瑰盐 (ringhealer, hidden) S2 绝妙的长效药呀 — allies in range: 20 % of phys/arts damage → 5 s HP loss - chess_char_4_08_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const scale = num(bb['attack@damage_scale'], 0.8), fin = num(bb['attack@final_duration'], 5), iv = Math.max(0.1, num(bb['attack@interval'], 1)); - return { - skill: { kind: 'duration', heal: true, mods: { batPct: batFlat(def, bb.base_attack_time) }, - onStart({ battle, unit }) { battle.fx('saltWard', { x: unit.x, y: unit.y, id: unit.id }); } }, - talents: [{ install(battle, unit) { // 最好的草药医生: own ATK −5 %, operators in range receive +15 % healing - battle.addBuff(unit, { key: 'rosesa:t1', mods: { atkPct: num(t0.atk, -0.05) }, persist: true, allowDead: true }); - whileDeployed(battle, unit, AURA, () => { - for (const a of battle.alliesInGrid(unit)) if (a.kind === 'op') pulse(battle, a, `rosesa:heal:${unit.id}`, { healingTakenMul: num(t0.heal_scale, 1.15) }); - }); - } }], - install(battle, unit) { - const deferred = new WeakSet(); - battle.on('hit', (c) => { - const t = c.target; - // "友方干员": operators only (summons/devices keep the full hit) - if (!skillActive(unit) || !unit.alive || t.side !== 'ally' || t.kind !== 'op' || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return; - if (!keySet(unit).has(t.tileR * COLS + t.tileC)) return; - c.dmg.mul *= scale; - deferred.add(c.dmg); - }, { owner: unit, priority: -5 }); - battle.on('damaged', (c) => { - if (!deferred.has(c.dmg) || !(c.amount > 0) || !c.target.alive) return; - deferred.delete(c.dmg); - const total = (c.amount * (1 - scale)) / Math.max(1e-6, scale); - const per = total / Math.max(1, Math.round(fin / iv)); - const src = c.source; - battle.addBuff(c.target, { key: 'rosesa:dot', refresh: 'independent', maxStacks: 1000, duration: fin + 1e-6, interval: iv, - onTick: ({ unit: tgt }) => battle.loseHp(tgt, per, { source: src && src.side === 'enemy' ? src : null }) }); - }, { owner: unit }); - // module (elite) RIN-X: 攻击范围扩大 (module range grid) - applyModuleRange(battle, unit, def); - }, - }; - }, - - // ===== 水月 (stalker) S2 囚徒困境 — faster, +ATK, talent 1 +1 target & bind; talents 创伤性癔症 / 反移情; module slow - // S1 唤醒 (charges: next attack 200 %/230 %, talent 1 ×2.0/×2.3); S3 镜花水月 (wider range, ATK up, talent 1 +2 - // targets & stun; an attack hitting < 3 enemies costs 15 % max HP); modules: AMB-Y 65 % dodge (trait, profession) - chess_char_4_09_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); - const S1 = isSel(def, 'skchr_mizuki_1'), S2 = isSel(def, 'skchr_mizuki_2'), S3 = isSel(def, 'skchr_mizuki_3'); - const g = grid(def.skill?.rangeGrid); - return { - skills: alt(def, { - skchr_mizuki_1: () => ({ kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 2) } }), - skchr_mizuki_3: () => ({ - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - targeting: g ? { rangeGrid: g } : undefined, - onStart({ battle, unit }) { battle.fx('ripple', { x: unit.x, y: unit.y, id: unit.id }); }, - onAttack({ battle, unit, targets }) { - if ((targets || []).filter((e) => e.side === 'enemy').length >= 3) return; - battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio'], 0.15), { tags: ['skill', 'selfLoss'] }); // (no source: not damage dealt) - }, - }), - }), - skill: { kind: 'duration', mods: { batPct: batFlat(def, bb.base_attack_time), atkPct: num(bb.atk) }, - onStart({ battle, unit }) { battle.fx('dilemma', { x: unit.x, y: unit.y, id: unit.id }); } }, - talents: [ - { install(battle, unit) { // 创伤性癔症: +50 % ATK arts to the lowest-HP enemy hit (S2 +1 target & bind, S3 +2 & stun, S1 ×2) - battle.on('attack', (c) => { - if (c.attacker !== unit || !unit.alive) return; - const act = skillActive(unit) && (S2 || S3); - const n = Math.max(1, Math.floor(num(t0['attack@max_target'], 1))) + (act ? Math.floor(num(bb['attack@max_target'], 1)) : 0); - const mul = S1 && c.isSkill ? num(bb.talent_scale, 2) : 1; - const list = c.targets.filter((e) => e.alive && e.side === 'enemy').sort((a, b) => a.hp - b.hp || a.id - b.id).slice(0, n); - for (const e of list) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0['attack@mizuki_t_1.atk_scale'], 0.5) * mul, type: 'arts', tags: ['talent'] }); - if (!act || !e.alive) continue; - if (S2) battle.applyStatus(e, 'bind', { duration: num(bb['attack@unmovable'], 0.8), source: unit }); - else battle.applyStatus(e, 'stun', { duration: num(bb['attack@stun'], 0.7), source: unit }); - } - }, { owner: unit }); - } }, - { install(battle, unit) { // 反移情: ATK +10 % while an enemy in range is below 50 % HP - whileDeployed(battle, unit, 0.1, () => { - const on = enemiesOnRange(battle, unit).some((e) => e.hpRatio < num(t1.hp_ratio, 0.5)); - toggleBuff(battle, unit, 'mizuki:t2', on, { atkPct: num(t1.atk, 0.1) }); - }); - } }, - ], - install(battle, unit) { - const ms = num(mb.move_speed, 0); - if (ms) whileDeployed(battle, unit, AURA, () => { for (const e of enemiesOnRange(battle, unit)) pulse(battle, e, `mizuki:slow:${unit.id}`, { moveMul: Math.max(0, 1 + ms) }); }); - }, - }; - }, - - // ===== 阿罗玛 (blastcaster) S2 小心地滑 — +ATK, levitated enemies take 65 % ATK arts on landing; talent 起泡性能测试 - // S1 强效清洁 (charges: next attack 150 %/180 % arts, flying victims +55 %/70 % ATK arts) - chess_char_4_10_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const tb = def.traitBb || {}; - const land = num(bb['attack@atk_scale_when_fly_finish'], 0.65); - const S2 = isSel(def, 'skchr_aroma_2'); - return { - skills: alt(def, { - skchr_aroma_1: () => ({ - kind: instantKind(def), - attack: { - atkScale: num(bb.atk_scale, 1.5), - onEachHit({ battle, unit, target }) { // every enemy of her line the blast strikes that flies - if (target && target.alive && target.isFlying) battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb.atk_scale_to_fly, 0.55), type: 'arts', isSkill: true, tags: ['skill', 'antiAir'] }); - }, - }, - }), - }), - skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, onStart({ battle, unit }) { battle.fx('slippery', { x: unit.x, y: unit.y, id: unit.id }); } }, - talents: [{ install(battle, unit) { // first attack on each enemy: ×1.1 and levitate 2.5 s - // PRTS 阿罗玛 备注: "天赋采用施加非首次标记的方式判断是否为'首次进行攻击',自身离场时移除自身已施加的标记,不同阿罗玛之间的 - // 非首次标记通用" — one mark per enemy (AROMA_MARK, the setter as source) for every 阿罗玛; hers go when she leaves - // the field (until 0.1.1 each 阿罗玛 kept her own list, so two of them both triggered on one enemy) - unit.mem.bubbled = new Set(); // the enemies she marked - battle.on('hit', (c) => { - const e = c.target; - if (c.source !== unit || !c.dmg.isAttack || e.side !== 'enemy' || e.findBuff(AROMA_MARK)) return; - battle.addBuff(e, { key: AROMA_MARK, source: unit }); - unit.mem.bubbled.add(e.id); - c.dmg.mul *= num(t0.damage_scale, 1.1); - if (battle.applyStatus(e, 'levitate', { duration: num(t0.levitate_duration, 2.5), source: unit })) battle.fx('levitate', { x: e.x, y: e.y, id: e.id }); - }, { owner: unit }); - battle.on('death', (c) => { - if (c.unit !== unit) return; - for (const id of unit.mem.bubbled) { - const e = battle.unitById(id); - if (e && e.findBuff(AROMA_MARK)?.source === unit) battle.removeBuff(e, AROMA_MARK); - } - unit.mem.bubbled.clear(); - }, { owner: unit }); - } }], - install(battle, unit) { - // S2: landing damage for every levitated enemy (any source) that lands inside her range while the skill runs - const floating = new Map(); - battle.on('statusApplied', (c) => { if (c.status === 'levitate' && c.target.side === 'enemy') floating.set(c.target.id, c.target); }, { owner: unit }); - battle.every(1 / 30, () => { - for (const [id, e] of floating) { - if (!e.alive) { floating.delete(id); continue; } - if (e.findBuff('levitate')) continue; - floating.delete(id); - if (S2 && unit.alive && unit.deployed && skillActive(unit) && bodyInKeys(e, keySet(unit))) { - battle.dealDamage(unit, e, { amount: unit.s.atk * land, type: 'arts', isSkill: true, tags: ['skill', 'landing'] }); - battle.fx('splash', { x: e.x, y: e.y, id: e.id }); - } - } - }, { owner: unit }); - // module (elite) trait: farther targets take more damage (up to ×1.1) - const ds = num(tb.damage_scale, 0); - if (ds > 0) { - const lo = num(tb.min_dist, 0), hi = num(tb.max_dist, 4); - battle.on('hit', (c) => { - if (c.source !== unit || !c.dmg.isAttack) return; - const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); - c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); - }, { owner: unit }); - } - }, - }; - }, - - // ===== 凯瑟琳 (craftsman) S2 战火淬炼 — stops attacking, HP/DEF up, devices give 6 %/s shields; talent 定向支援信号 - // "携带3个支援装置(最多部署2个)": the 爬行号·防护单元 are hand pieces (2 = the deploy limit) the player places and - // turns towards an operator (user playtest #6, PRTS 卫戍协议/帮助 §战斗部署); they deploy with the board and the - // token kit (tokens.js catShield) gives the shields — S2 overwrite_ratio per second while this skill runs — so - // this kit places nothing (none placed ⇒ no device). - // S1 岁月锻打 (passive: she and every other operator holding a device shield ATK/DEF +8 %/+11 %); module CRA-X - // carries one more device (talent cnt, data — a spare the hand never shows) - chess_char_4_11_a: (bb, chess, def) => ({ - skills: alt(def, { - skchr_cathy_1: () => ({ - kind: 'passive', - mods: { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) }, - onStart({ battle, unit }) { - unit.mem.forgeAura?.cancel(); - unit.mem.forgeAura = whileDeployed(battle, unit, AURA, () => { - for (const a of battle.allies(unit.ownerId)) { - if (a === unit || a.kind !== 'op' || !a.findBuff(CAT_SHIELD_KEY)) continue; - pulse(battle, a, `cathy:forge:${unit.id}`, { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) }); - } - }); - }, - }), - }), - skill: { - kind: 'duration', - mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) }, - attack: { noAttack: true }, - onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); }, - }, - }), - - // ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado: slow, 85 % arts pulses + pull, final pull - // S1 缺水的大洋裂断 (charges: next attack pulls the target to her front, 150 %/180 %); S2 缺水的掌握怒海 (BAT +0.5 s, - // wider range, ≤ 2 targets — blocked first — at 135 %/150 % and pulled to her front); module HOK-Y (淡金坠饰): - // a pull towards herself of an enemy farther than 2.5 tiles is one force level stronger - chess_char_4_12_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); - const tb = def.traitBb || {}; - const R = 1.5; // [ASSUMED] tornado radius (no blackboard key) - const force = num(bb.force, num(bb['attack@force'], 0)); - const iv = Math.max(0.1, num(bb.interval, 1.5)); - const g = grid(def.skill?.rangeGrid); - const dragDmg = (battle, unit, e, moved) => { // module: dragged enemies take arts damage ∝ distance - if (!(moved > 0) || !(num(tb.value, 0) > 0) || !e.alive) return; - battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(1e-6, num(tb.dist, 1)), type: 'arts', isSkill: true, tags: ['drag'] }); - }; - // module HOK-Y: "向自身拖拽较远的敌人时力度提升一个等级" - const farR = num(mb['skill@range_radius'], num(mb['attack@range_radius'], 0)), farF = num(mb['skill@delta_force'], num(mb['attack@delta_force'], 0)); - const selfForce = (unit, e, f) => (farR > 0 && farF > 0 && Math.hypot(e.x - unit.x, e.y - unit.y) > farR + 1e-9 ? f + farF : f); - const pullSelf = (battle, unit, e, f) => { if (e && e.alive) dragDmg(battle, unit, e, pullToFront(battle, unit, e, selfForce(unit, e, f))); }; - return { - skills: alt(def, { - skchr_glady_1: () => ({ - kind: instantKind(def), - attack: { atkScale: num(bb.atk_scale, 1.5), onHit({ battle, unit, target }) { pullSelf(battle, unit, target, num(bb.force, 1)); } }, - }), - skchr_glady_2: () => ({ - kind: 'duration', - mods: { batPct: batFlat(def, bb.base_attack_time) }, - // (target sorting already puts the enemies she blocks first) - targeting: { ...(g ? { rangeGrid: g } : {}), maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))) }, - attack: { atkScale: num(bb['attack@atk_scale'], 1.35), onEachHit({ battle, unit, target }) { pullSelf(battle, unit, target, num(bb['attack@force'], 1)); } }, - onStart({ battle, unit }) { battle.fx('wake', { x: unit.x, y: unit.y, id: unit.id }); }, - }), - }), - skill: { - kind: 'duration', - onStart({ battle, unit, skill }) { - const foes = targetsInRange(battle, unit).sort((a, b) => bodyDist(b, unit.x, unit.y) - bodyDist(a, unit.x, unit.y) || a.id - b.id); - const t = foes[0]; - unit.mem.tornado = t ? { x: t.x, y: t.y, acc: 0 } : null; - if (!t) return; - battle.applyStatus(t, 'bind', { duration: skill.duration, source: unit }); - battle.fx('tornado', { x: t.x, y: t.y, id: unit.id, r: R, duration: skill.duration }); - }, - onTick({ battle, unit, dt }) { - const T = unit.mem.tornado; - if (!T) return; - const inside = battle.foesInRadius(T.x, T.y, R); - for (const e of inside) pulse(battle, e, `glady:slow:${unit.id}`, { moveMul: Math.max(0, 1 + num(bb.move_speed, -0.5)) }); - T.acc += dt; - if (T.acc + 1e-9 < iv) return; - T.acc -= iv; - for (const e of inside) { - if (!e.alive || e.s.flags.untargetable) continue; - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 0.85), type: 'arts', isSkill: true, tags: ['skill', 'tornado'] }); - // "小力地拖拽至中心": a pull to the marked point (PRTS 推与拉: its 6 tornado pulls stop 0.05 from it) - if (e.alive) dragDmg(battle, unit, e, battle.pull(e, force, { to: T, stop: 0.05 })); - } - battle.fx('tornadoPulse', { x: T.x, y: T.y, id: unit.id, r: R }); - }, - onEnd({ battle, unit, reason }) { - const T = unit.mem.tornado; - unit.mem.tornado = null; - if (!T || reason === 'death' || !unit.alive) return; - for (const e of battle.foesInRadius(T.x, T.y, R)) pullSelf(battle, unit, e, force); - battle.fx('pull', { x: T.x, y: T.y, id: unit.id }); - }, - }, - talents: [ - { install(battle, unit) { // 阿戈尔的波涛: 深海猎人 regen 2.5 %/s, −25 % phys/arts damage from 海怪 - whileDeployed(battle, unit, AURA, () => { - for (const a of battle.allies(unit.ownerId)) if (isAbyssal(a)) pulse(battle, a, 'glady:tide', { hpRegenRatio: num(t0.hp_recovery_per_sec_by_max_hp_ratio, 0.025) }); - }); - battle.on('hit', (c) => { - if (!c.target.findBuff?.('glady:tide') || !enemyHasTag(c.source, 'seamonster') || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return; - c.dmg.mul *= 1 - num(t0.damage_resistance, 0.25); - }, { owner: unit }); - } }, - { install(battle, unit) { // 弱肉强食: ×1.3 against enemies of weight ≤ 3 - battle.on('hit', (c) => { - if (c.source === unit && c.dmg.isAttack && c.target.side === 'enemy' && c.target.s.massLevel <= num(t1.value, 3)) c.dmg.mul *= num(t1.atk_scale, 1.3); - }, { owner: unit }); - } }, - ], - }; - }, - - // ===== 灵知 (underminer) S2 零度爆发 — charges: 130 % arts + cold to all in range, charged ⇒ 2nd cold; 坚冰 / 殊途同归 - // S1 高速思考 (next attack: 2 × 135 %/150 % arts); S3 失温症 (ASPD up, 2 targets — unfrozen first; frozen enemies - // in range stay frozen until the skill ends, then take 300 %/400 % arts and thaw); module UMD-Y (一号项目模型): - // +0.25 SP/s while an elite / leader enemy is in range - chess_char_4_13_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const tb = def.traitBb || {}; - const S3 = isSel(def, 'skchr_gnosis_3'); - const frozen = (e) => !!e.findBuff('freeze'); - return { - skills: alt(def, { - skchr_gnosis_1: () => ({ kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 1.35), hits: 2 } }), - skchr_gnosis_3: () => ({ - kind: 'duration', - mods: { aspd: num(bb.attack_speed) }, - targeting: { maxTargets: Math.max(1, Math.floor(num(bb.max_target, 2))) }, - onStart({ battle, unit }) { unit.mem.hypothermia = new Set(); battle.fx('coldWind', { x: unit.x, y: unit.y, id: unit.id }); }, - onTick({ battle, unit, skill }) { - const H = unit.mem.hypothermia; - if (!H) return; - // 范围内所有敌人的冻结延长至技能结束 - for (const e of enemiesOnRange(battle, unit)) { - const f = e.findBuff('freeze'); - if (!f) continue; - H.add(e.id); - if (f.timeLeft < skill.timeLeft) { f.timeLeft = skill.timeLeft; f.duration = Math.max(f.duration, skill.timeLeft); } - } - }, - onEnd({ battle, unit, reason }) { - const H = unit.mem.hypothermia; - unit.mem.hypothermia = null; - if (!H || reason === 'death' || !unit.alive) return; - // 技能结束时对所有冻结的敌人造成N%的法术伤害并结束冻结 (the frozen enemies of her range) - const list = enemiesOnRange(battle, unit).filter(frozen); - for (const id of H) { const e = battle.unitById(id); if (e && e.alive && frozen(e) && !list.includes(e)) list.push(e); } - for (const e of list) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 3), type: 'arts', isSkill: true, tags: ['skill', 'shatter'] }); - if (e.alive) battle.removeStatus(e, 'freeze'); - } - battle.fx('iceSpike', { x: unit.x, y: unit.y, id: unit.id, n: list.length }); - }, - }), - }), - install(battle, unit) { - if (S3) { - // 优先攻击未冻结的单位 (while S3 runs) - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit || !skillActive(unit)) return; - const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile); - for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3 - if (cands.length <= 1) return; - sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null); - const n = Math.max(1, Math.floor((c.profile?.maxTargets || 1) + unit.s.maxTargets)); - c.targets = [...cands.filter((e) => !frozen(e)), ...cands.filter(frozen)].slice(0, n); - }, { owner: unit, priority: 5 }); - } - const sp = num(tb.sp_recovery_per_sec, 0); // module UMD-Y - if (sp > 0) whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'gnosis:module', enemiesOnRange(battle, unit).some(isEliteEnemy), { spRecoveryFlat: sp })); - }, - skill: { - kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', - onStart({ battle, unit, skill }) { - const charged = skill.maxCharges > 1 && skill.charges + 1 >= skill.maxCharges; // cast with every charge stored - for (const e of targetsInRange(battle, unit)) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.3), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); - if (!e.alive) continue; - battle.applyStatus(e, 'cold', { duration: num(bb.cold, 2.5), source: unit }); - if (charged && e.alive) battle.applyStatus(e, 'cold', { duration: num(bb.cold, 2.5), source: unit }); - } - battle.fx('frostNova', { x: unit.x, y: unit.y, id: unit.id, charged }); - }, - }, - talents: [ - { install(battle, unit) { // 坚冰: attacks chill 1 s; cold enemies in range fragile 25 %, frozen 50 % - battle.on('damaged', (c) => { - if (c.source === unit && c.dmg?.isAttack && !c.dmg.isSplash && c.target.side === 'enemy' && c.target.alive) battle.applyStatus(c.target, 'cold', { duration: num(t0.cold, 1), source: unit }); - }, { owner: unit }); - whileDeployed(battle, unit, 0.1, () => { - for (const e of enemiesOnRange(battle, unit)) { - const f = e.s.flags; - const m = f.freeze ? num(t0.damage_scale_freeze, 1.5) : f.cold ? num(t0.damage_scale_cold, 1.25) : 0; - // 同名效果取最高: one 坚冰 per enemy, the strongest — two 灵知 (a pair / 联防 partner's copy) never compound - if (m) battle.applyStrongest(e, 'gnosis:fragile', { duration: 0.15, value: m, mods: (v) => ({ dmgTakenMul: v }), source: unit }); - } - }); - } }, - { install(battle, unit) { // 殊途同归: 10 s after deployment every 谢拉格 operator gains 抵抗 (negative status durations ×0.5) - // engine `resist` status (applied before a new status lands, so a longer one already running is never - // shortened; 同名效果不叠加: several 灵知 — or 流明's 抵抗 — never compound) - const value = Math.min(1, Math.max(0, -num(t1.one_minus_status_resistance, -0.5))); - whileDeployed(battle, unit, AURA, () => { - if (battle.time - unit.deployedAt < num(t1.interval, 10) - 1e-9 || !(value > 0)) return; - for (const a of battle.allies(unit.ownerId)) if (a.kind === 'op' && nationOf(a) === 'kjerag') battle.applyStatus(a, 'resist', { duration: AURA_DUR, value, source: unit }); - }); - } }, - ], - }; - }, - - // ===== 莱恩哈特 (splashcaster) S2 解构与爆破 — charges: 170 % arts to all in the wider range + RES −8 % for 6 s; 破片杀伤 - chess_char_4_14_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const g = grid(def.skill?.rangeGrid); - const mr = num(bb.magic_resistance, 0); - return { - skills: alt(def, { 'skcom_atk_up[3]': () => ({ kind: 'duration', mods: { atkPct: num(bb.atk) } }) }), // S1 攻击力强化·γ型 - skill: { - kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', - targeting: g ? { rangeGrid: g } : undefined, - onStart({ battle, unit }) { - for (const e of targetsInRange(battle, unit)) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.7), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); - // 同名效果取最高: one RES cut per enemy (the strongest), never one per 莱恩哈特 - if (e.alive && mr) battle.applyStrongest(e, 'lionhd:res', { duration: num(bb.duration, 6), value: mr, mods: resCut, source: unit }); - } - battle.fx('explosion', { x: unit.x, y: unit.y, id: unit.id }); - }, - }, - talents: [{ install(battle, unit) { // 破片杀伤: ATK +4 % per enemy in range (≤ 5) - whileDeployed(battle, unit, 0.1, () => { - const n = Math.min(num(t0.max_valid_stack_cnt, 5), targetsInRange(battle, unit).length); - toggleBuff(battle, unit, 'lionhd:t1', n > 0, { atkPct: num(t0.atk, 0.04) * n }); - }); - } }], - install(battle, unit) { applyModuleRange(battle, unit, def); }, // module SPC-X (elite default): 攻击范围扩大 - }; - }, - - // ===== 录武官 (physician, hidden) S2 一点关窍 — +ATK; healed allies regain 80 HP per hit taken for 10 s; 学成于聚 - chess_char_4_15_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const tb = def.traitBb || {}; - return { - skill: { - kind: 'duration', heal: true, - mods: { atkPct: num(bb.atk) }, - onStart({ battle, unit }) { battle.fx('knack', { x: unit.x, y: unit.y, id: unit.id }); }, - onHit({ battle, unit, target }) { - if (target && target.alive && target.side === 'ally' && target.kind === 'op') battle.addBuff(target, { key: `reckpr:guard:${unit.id}`, duration: num(bb['attack@buff_duration'], 10), visible: true, source: unit }); - }, - }, - talents: [{ install(battle, unit) { // 学成于聚: an operator in range casts ⇒ +1 SP and ASPD +16 for 8 s - battle.on('skillStart', (c) => { - const a = c.unit; - if (a === unit || a.side !== 'ally' || a.kind !== 'op' || !unit.alive || !unit.deployed || !keySet(unit).has(a.tileR * COLS + a.tileC)) return; - if (!battle.rng.chance(num(t0.prob, 1))) return; - if (!skillActive(unit)) unit.skill?.gainSp(num(t0.sp, 1), 'talent'); - battle.addBuff(unit, { key: 'reckpr:t1', duration: num(t0.duration, 8), mods: { aspd: num(t0.attack_speed, 16) }, maxStacks: Math.max(1, num(t0.max_stack_cnt, 1)) }); - }, { owner: unit }); - } }], - install(battle, unit) { - battle.on('damaged', (c) => { - const t = c.target; - if (t.side !== 'ally' || !t.alive || !(c.amount > 0) || isHpLoss(c.dmg) || !t.findBuff(`reckpr:guard:${unit.id}`)) return; - battle.heal(unit, t, num(bb['attack@fixed_heal_value'], 80)); - }, { owner: unit }); - installLowHpHealBonus(battle, unit, tb); - }, - }; - }, - - // ===== 缄默德克萨斯 (executor) S3 剑雨滂沱 (passive) — deploy burst 2×115 % + 1.5 s stun, sword rain 1/s; talents - // S1 细雨无声 (passive, for the skill duration: ATK up; hits silence the target 5 s/8 s — 失去特殊能力 — with - // 260/320 arts per s meanwhile); S2 阵雨连绵 (passive: deploy burst 150 %/180 % arts + RES −15 %/−20 % around her; - // for the skill duration ATK up and attacks become arts double hits). Talent 德克萨斯传统 applies to every passive. - // The S3 rain hits air units (PRTS 备注 "效果可对空"); so does the S2 deploy burst [ASSUMED: no note]. - chess_char_4_16_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const tb = def.traitBb || {}; - const g = grid(def.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]]; - const S1 = isSel(def, 'skchr_texas2_1'), S2 = isSel(def, 'skchr_texas2_2'); - const dur = num(def.skill?.duration, S1 ? 11 : S2 ? 8 : 6); - const talentAtk = (battle, unit) => battle.addBuff(unit, { key: 'texas2:rainAtk', duration: dur, mods: { atkPct: num(t0.atk, 0.2) } }); - const castS1 = (battle, unit) => { - if (!unit.alive || !unit.deployed) return; - talentAtk(battle, unit); - battle.addBuff(unit, { key: 'texas2:drizzle', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true }); - battle.fx('swordRain', { x: unit.x, y: unit.y, id: unit.id }); - }; - const castS2 = (battle, unit) => { - if (!unit.alive || !unit.deployed) return; - talentAtk(battle, unit); - battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true }); - const mr = num(bb.magic_resistance, 0); - for (const e of targetsInGrid(battle, unit, g)) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); - // 同名效果取最高: one RES cut per enemy (the strongest), never one per 缄默德克萨斯 - if (e.alive && mr) battle.applyStrongest(e, 'texas2:resDown', { duration: num(bb.debuff_duration, 8), value: mr, mods: resCut, source: unit }); - } - battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id }); - }; - const castS3 = (battle, unit) => { - if (!unit.alive || !unit.deployed) return; - talentAtk(battle, unit); - for (const e of targetsInGrid(battle, unit, g)) { - for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['appear.atk_scale'], 1.15), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); - if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb['appear.stun'], 1.5), source: unit }); - } - battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id }); - unit.mem.rainUntil = battle.time + dur; - unit.mem.rainTimer?.cancel(); - unit.mem.rainTimer = battle.every(Math.max(0.1, num(bb['texas2_s_3[sword].interval'], 1)), (b, sched) => { - if (!unit.alive || !unit.deployed || b.time > unit.mem.rainUntil + 1e-9) { sched.cancel(); return; } - const list = targetsInGrid(b, unit, g); - sortEnemyTargets(b, unit, list, null); - for (const e of list.slice(0, Math.max(1, Math.floor(num(bb.max_target, 2))))) { - b.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 0.85), type: 'arts', isSkill: true, tags: ['skill', 'swordRain'] }); - if (e.alive) b.applyStatus(e, 'stun', { duration: num(bb.stun, 0.2), source: unit }); - b.fx('swordRain', { x: e.x, y: e.y, id: e.id }); - } - }, { owner: unit }); - }; - const cast = S1 ? castS1 : S2 ? castS2 : castS3; - return { - skill: { kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }, - skills: alt(def, { - skchr_texas2_1: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }), - skchr_texas2_2: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }), - }), - // S2: 攻击变为二连击 while the passive lasts - trait: S2 ? { hitsFn: (b, u) => (u.findBuff('texas2:shower') ? 2 : 1) } : undefined, - talents: [ - { install(battle, unit) { // 德克萨斯传统 (2nd half): first kill of each deployment ⇒ full heal + recast - // (the passive's deploy burst runs inside skill.reset, BEFORE the `deploy` hook: the per-deployment state is - // therefore reset when she leaves the field, so a kill by the deploy burst itself counts) - battle.on('death', (c) => { if (c.unit === unit) unit.mem.texasKilled = false; }, { owner: unit }); - battle.on('kill', (c) => { - if (c.killer !== unit || !unit.alive || c.victim.side !== 'enemy' || unit.mem.texasKilled) return; - unit.mem.texasKilled = true; - battle.removeBuff(unit, 'texas2:swordplay'); - battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio, 1), { self: true }); - cast(battle, unit); - }, { owner: unit }); - } }, - { install(battle, unit) { // 德克萨斯剑术: until her first kill after each deployment: ASPD +8, −25 % damage taken - battle.on('deploy', (c) => { - if (c.unit !== unit || unit.mem.texasKilled) return; // the deploy burst may already have scored the kill - battle.addBuff(unit, { key: 'texas2:swordplay', mods: { aspd: num(t1.attack_speed, 8), dmgTakenMul: 1 - num(t1.damage_resistance, 0.25) } }); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - if (S1) { - // 攻击使命中目标失去特殊能力N秒,期间目标每秒受到X点法术伤害 (landed normal attacks while the passive lasts) - const sil = num(bb['attack@silence'], 5), dotDur = num(bb['attack@texas2_s_1[dot].duration'], sil); - const dot = num(bb['attack@texas2_s_1[dot].dot_damage'], 260), dotIv = Math.max(0.1, num(bb['attack@texas2_s_1[dot].interval'], 1)); - battle.on('damaged', (c) => { - const e = c.target; - if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !unit.findBuff('texas2:drizzle')) return; - battle.applyStatus(e, 'silence', { duration: sil, source: unit }); - battle.addBuff(e, { key: `texas2:drizzleDot:${unit.id}`, duration: dotDur + 1e-6, interval: dotIv, source: unit, refresh: 'extend', // a re-hit refreshes it, the per-second ticks keep their rhythm - onTick: ({ unit: t }) => battle.dealDamage(unit, t, { amount: dot, type: 'arts', isSkill: true, tags: ['skill', 'dot'] }) }); - }, { owner: unit }); - } - if (S2) { - // 攻击…造成法术伤害 while the passive lasts (the double hit is the kit trait's hitsFn) - battle.on('hit', (c) => { - if (c.source === unit && c.dmg.isAttack && c.dmg.type === 'phys' && unit.findBuff('texas2:shower')) c.dmg.type = 'arts'; - }, { owner: unit, priority: 50 }); - } - const a = num(tb.atk, 0); // module (elite): ATK +10 % with no ally on the 4 adjacent tiles - if (!a) return; - whileDeployed(battle, unit, AURA, () => toggleBuff(battle, unit, 'texas2:module', lonely(battle, unit), { atkPct: a })); - }, - }; - }, - - // ===== 星熊 (protector) S2 荆棘 (passive) — DEF +13 %, counters every attacker for 65 % ATK phys; talents - // S1 战意 (TAKE_DAMAGE: DEF/ATK up); S3 力之锯 (ATK/DEF up, cuts every enemy on her front tile — all enemies of her - // range); module PRO-X (护身符): DEF +20 % while blocking - chess_char_4_17_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const tb = def.traitBb || {}; - const S2 = isSel(def, 'skchr_hsguma_2'); - return { - skills: alt(def, { - skchr_hsguma_1: () => ({ kind: 'duration', mods: { defPct: num(bb.def), atkPct: num(bb.atk) } }), - skchr_hsguma_3: () => ({ - kind: 'duration', - mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, - targeting: { allInRange: true }, - onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); }, - }), - }), - skill: { kind: 'passive', mods: { defPct: num(bb.def) } }, - talents: [ - { install(battle, unit) { // 战术装甲: 25 % 伤害抵挡 — negates an enemy phys/arts damage instance ("抵挡一次物理或法术伤害") - battle.on('hit', (c) => { // 抵挡 is target-side: a 无来源 burst (source null) counts via the enemy credited with it - const src = c.source || c.credit; - if (c.target !== unit || !src || src.side !== 'enemy' || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return; - if (battle.rng.chance(num(t0.prob, 0.25))) { c.dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); } - }, { owner: unit }); - } }, - { install(battle, unit) { // 特种作战策略: 重装 allies DEF +6 % - whileDeployed(battle, unit, AURA, () => { for (const a of battle.allies(unit.ownerId)) if (a.def?.profession === 'TANK') pulse(battle, a, 'hsguma:def', { defPct: num(t1.def, 0.06) }); }); - } }, - ], - install(battle, unit) { - const bd = num(tb.def, 0); // module PRO-X: 阻挡敌人时防御力+20% - if (bd) whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'hsguma:module', unit.blocking.length > 0, { defPct: bd })); - if (!S2) return; - battle.on('damaged', (c) => { - const src = c.source; - if (c.target !== unit || !unit.alive || !src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return; - battle.dealDamage(unit, src, { amount: unit.s.atk * num(bb.atk_scale, 0.65), type: 'phys', canDodge: false, isSkill: true, tags: ['counter'] }); - battle.fx('thorns', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - }, - }; - }, - - // ===== 泥岩 (unyield) S2 岩崩锤 — next attack: heal 4 %, 190 % phys to all ground enemies around, 30 % stun; talents - // S1 防御力强化·γ型 (TAKE_DAMAGE); S3 秽壤的血脉 (10 s dormant — cannot act, takes no damage, keeps blocking — - // with enemies around −60 % speed; then ground enemies around stunned 3 s/3.5 s and for the rest of the skill - // BAT −0.3 s, ATK/DEF up, attacks every blocked enemy); module UNY-Y (沃土的愿景): ATK/DEF +8 % with no ally - // on the 8 tiles around - chess_char_4_18_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const tb = def.traitBb || {}; - const g = grid(def.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]]; - const sleep = num(bb.sleep, 10); - return { - skills: alt(def, { - 'skcom_def_up[3]': () => ({ kind: 'duration', mods: { defPct: num(bb.def) } }), - skchr_mudrok_3: () => ({ - kind: 'duration', - attack: { hitAllBlocked: true }, - onStart({ battle, unit }) { - unit.mem.mudS3 = { awake: false, t: 0 }; - battle.addBuff(unit, { key: 'mudrok:dormant', duration: sleep + 1, flags: { invulnerable: true, disarm: true }, visible: true }); - battle.fx('stone', { x: unit.x, y: unit.y, id: unit.id }); - }, - onTick({ battle, unit, dt }) { - const M = unit.mem.mudS3; - if (!M || M.awake) return; - M.t += dt; - for (const e of targetsInGrid(battle, unit, g)) pulse(battle, e, `mudrok:slow:${unit.id}`, { moveMul: Math.max(0, 1 + num(bb.move_speed, -0.6)) }); - if (M.t + 1e-9 < sleep) return; - M.awake = true; - battle.removeBuff(unit, 'mudrok:dormant'); - for (const e of targetsInGrid(battle, unit, g)) if (!e.isFlying && e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 3), source: unit }); - battle.addBuff(unit, { key: 'mudrok:awake', mods: { atkPct: num(bb.atk), defPct: num(bb.def), batPct: batFlat(def, bb.base_attack_time) } }); - battle.fx('rockfall', { x: unit.x, y: unit.y, id: unit.id }); - }, - onEnd({ battle, unit }) { - unit.mem.mudS3 = null; - battle.removeBuff(unit, 'mudrok:dormant'); - battle.removeBuff(unit, 'mudrok:awake'); - }, - }), - }), - skill: { - kind: 'instant', - targeting: { rangeGrid: g, allInRange: true, canHitFly: false }, - attack: { - atkScale: num(bb.atk_scale, 1.9), groundOnly: true, - onHit({ battle, unit, target }) { - if (target && target.alive && battle.rng.chance(num(bb.buff_prob, 0.3))) battle.applyStatus(target, 'stun', { duration: num(bb.stun, 0.5), source: unit }); - }, - }, - onStart({ battle, unit }) { - battle.heal(unit, unit, unit.s.maxHp * num(bb.hp_ratio, 0.04), { self: true }); - battle.fx('rockslide', { x: unit.x, y: unit.y, id: unit.id }); - }, - }, - talents: [ - { install(battle, unit) { // 沃土予身: a hit-negating layer every 9 s (≤ 3, 1 on deploy); each broken layer heals 20 % - const setLayers = (n) => { - unit.mem.layers = n; - if (n > 0) battle.addBuff(unit, { key: 'mudrok:layers', shieldHits: n, visible: true }); - else battle.removeBuff(unit, 'mudrok:layers'); - }; - battle.on('deploy', (c) => { - if (c.unit !== unit) return; - setLayers(Math.min(num(t0.max_times, 3), num(t0.times, 1))); - unit.mem.layerTimer?.cancel(); - unit.mem.layerTimer = whileDeployed(battle, unit, num(t0.interval, 9), () => { - if ((unit.mem.layers ?? 0) < num(t0.max_times, 3)) setLayers(Math.min(num(t0.max_times, 3), (unit.mem.layers ?? 0) + num(t0.times, 1))); - }); - }, { owner: unit }); - battle.on('damaged', (c) => { - if (c.target !== unit || !unit.alive) return; - const left = unit.findBuff('mudrok:layers')?.shieldHits ?? 0; - const broken = (unit.mem.layers ?? 0) - left; - if (broken <= 0) return; - unit.mem.layers = left; - battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio, 0.2) * broken, { self: true }); - battle.fx('shieldBreak', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 手足相惜: −30 % damage from 萨卡兹 enemies - battle.on('hit', (c) => { if (c.target === unit && enemyHasTag(c.source, 'sarkaz')) c.dmg.mul *= 1 - num(t1.damage_resistance, 0.3); }, { owner: unit }); - } }, - ], - install(battle, unit) { - const ds = num(tb.damage_scale, 0); // module (elite): −15 % damage from enemies it blocks - if (ds > 0) battle.on('hit', (c) => { if (c.target === unit && c.source && c.source.blockedBy === unit) c.dmg.mul *= ds; }, { owner: unit }); - const ma = num(tb.atk, 0), md = num(tb.def, 0); // module UNY-Y: 周围8格没有友方干员时攻击力和防御力+8% - if (ma || md) whileDeployed(battle, unit, AURA, () => toggleBuff(battle, unit, 'mudrok:module', lonely(battle, unit, true), { atkPct: ma, defPct: md })); - }, - }; - }, - - // ===== 焰尾 (pioneer) S3 焰心 — 8 DP over the skill, faster, +ATK, block +1, 60 % dodge; talents 前锋剑术 / 红松骑士团团长 - // S1 迅敏直觉 (+6 DP, dodges the next physical attack); S2 “红松林” (+11/12 DP; ≤ 6 enemies around: 2 × 180 %/210 % - // phys + 0.5 s stun, air units too — PRTS 备注 "※可对空"; allies around +40 %/45 % physical dodge for 10 s); - // module SOL-X (她们的未来): ATK/DEF +8 % while blocking. Her dodges (any source) feed talent 前锋剑术. - chess_char_4_19_a: (bb, chess, def) => { - const t1 = tbb(def, 1); - const tb = def.traitBb || {}; - const total = Math.max(0, Math.floor(num(bb.value, 8))), per = num(bb.cost, 1); - const p = num(bb.prob, 0.6); - const S1 = isSel(def, 'skchr_flamtl_1'), S3 = isSel(def, 'skchr_flamtl_3'); - const g = grid(def.skill?.rangeGrid); - return { - skills: alt(def, { - // (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1) - skchr_flamtl_1: () => ({ - kind: 'instant', - trigger: 'SP_FULL', - onStart({ battle, unit }) { - battle.addDp(unit.ownerId, num(bb.cost, 6)); - battle.addBuff(unit, { key: 'flamtl:evade', visible: true }); - battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost, 6) }); - }, - }), - skchr_flamtl_2: () => ({ - kind: 'instant', - onStart({ battle, unit }) { - battle.addDp(unit.ownerId, num(bb.cost, 11)); - const area = g || [[0, 0], [0, 1]]; - const foes = targetsInGrid(battle, unit, area); - sortEnemyTargets(battle, unit, foes, null); - for (const e of foes.slice(0, Math.max(1, Math.floor(num(bb.max_target, 6))))) { - for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.8), type: 'phys', isSkill: true, tags: ['skill', 'redPine'] }); - if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 0.5), source: unit }); - } - for (const a of battle.unitsInGrid(unit, area, { side: 'ally' })) { - if (a.kind === 'device' || !battle.allySelectable(a, unit)) continue; - battle.addBuff(a, { key: 'flamtl:redPine', duration: num(bb['flamtl_s_2.duration'], 10), mods: { dodgePhys: num(bb['flamtl_s_2.prob'], 0.4) }, visible: true, source: unit }); - } - battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id }); - }, - }), - }), - skill: { - kind: 'duration', - // the 60 % dodge is rolled on its own in the `hit` hook below (independent of other dodge sources such as her - // talent's 22 %: 1 − (1−p)(1−q), never an additive 80 % + 22 % = 100 % immunity) - mods: { atkPct: num(bb.atk), batPct: Math.max(-0.9, num(bb.base_attack_time, 1) - 1), blockCnt: num(bb.block_cnt, 1) }, - onStart({ battle, unit, skill }) { unit.mem.flame = { acc: 0, given: 0, step: skill.duration / Math.max(1, total) }; battle.fx('flame', { x: unit.x, y: unit.y, id: unit.id }); }, - onTick({ battle, unit, dt }) { - const F = unit.mem.flame; - if (!F) return; - F.acc += dt; - while (F.acc + 1e-9 >= F.step && F.given < total) { F.acc -= F.step; F.given++; battle.addDp(unit.ownerId, per); } - }, - onEnd({ battle, unit, reason }) { - const F = unit.mem.flame; - unit.mem.flame = null; - if (F && reason === 'duration' && F.given < total) battle.addDp(unit.ownerId, per * (total - F.given)); - }, - }, - install(battle, unit) { - if (S1) { - battle.on('hit', (c) => { // S1: "闪避下次物理攻击" - const d = c.dmg; - if (c.target !== unit || d.cancel || d.type !== 'phys' || !d.isAttack || !c.source || c.source.side !== 'enemy' || !unit.findBuff('flamtl:evade')) return; - battle.removeBuff(unit, 'flamtl:evade'); - d.cancel = true; - battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); - battle.emit('dodge', { source: c.source, target: unit, dmg: d }); - }, { owner: unit, priority: 20 }); - } - const ma = num(tb.atk, 0), md = num(tb.def, 0); // module SOL-X: 阻挡敌人时攻击力和防御力各+8% - if (ma || md) whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'flamtl:module', unit.blocking.length > 0, { atkPct: ma, defPct: md })); - if (!S3) return; - battle.on('hit', (c) => { // S3: "获得60%的物理和法术闪避" - const d = c.dmg; - if (c.target !== unit || !skillActive(unit) || d.cancel || !d.canDodge || (d.type !== 'phys' && d.type !== 'arts')) return; - if (!battle.rng.chance(p)) return; - d.cancel = true; - battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); - battle.emit('dodge', { source: c.source, target: unit, dmg: d }); - }, { owner: unit, priority: 20 }); - }, - talents: [ - { install(battle, unit) { // 前锋剑术: after a dodge the next attack hits twice and every blocked enemy - battle.on('dodge', (c) => { if (c.target === unit) unit.mem.riposte = true; }, { owner: unit }); - battle.on('death', (c) => { if (c.unit === unit) unit.mem.riposte = unit.mem.riposteNow = false; }, { owner: unit }); - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit || !unit.mem.riposte) return; - unit.mem.riposte = false; - unit.mem.riposteNow = true; - const extra = unit.blocking.filter((e) => e.alive && canTargetEnemy(unit, e, c.profile) && !c.targets.includes(e)); - c.targets = [...c.targets, ...extra]; - }, { owner: unit }); - battle.on('attack', (c) => { - if (c.attacker !== unit || !unit.mem.riposteNow) return; - unit.mem.riposteNow = false; - for (const e of c.targets) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * unit.s.atkScaleMul, type: 'phys', isAttack: true, tags: ['riposte'] }); - battle.fx('riposte', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 红松骑士团团长: 卡西米尔 operators +22 % physical dodge - whileDeployed(battle, unit, AURA, () => { for (const a of battle.allies(unit.ownerId)) if (nationOf(a) === 'kazimierz') pulse(battle, a, 'flamtl:dodge', { dodgePhys: num(t1.prob, 0.22) }); }); - } }, - ], - }; - }, - - // ===== 远牙 (longrange) S3 光羽箭 — infinite line, +ATK, ×1.25 beyond the normal range; talents 凝神 / 屏息; module - // S1 迅捷打击·γ型; S2 同盟支援 (ASPD up; enemies blocked by any ally anywhere on the field are in range — their - // tiles are extra range keys while the skill runs). Talent 屏息 (taunt −1, ignores dodge) covers every skill. - // Module DEA-Y (支持者来信): +1 SP when an attacked enemy survives the hit - chess_char_4_20_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const tb = def.traitBb || {}; - const S3 = isSel(def, 'skchr_fartth_3'); - const taunt = num(t1.taunt_level, -1); - const setAllied = (battle, unit, keys) => { - const sig = keys ? keys.join(',') : ''; - if (sig === (unit.mem.alliedSig ?? '')) return; - unit.mem.alliedSig = sig; - battle.setExtraRange(unit, keys); - }; - return { - skills: alt(def, { - 'skcom_quickattack[3]': () => ({ kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed), taunt } }), - skchr_fartth_2: () => ({ - kind: 'duration', - mods: { aspd: num(bb.attack_speed), taunt }, - onStart({ battle, unit }) { battle.fx('link', { x: unit.x, y: unit.y, id: unit.id }); }, - onTick({ battle, unit }) { - const keys = []; - for (const e of battle.enemies) if (e.alive && !e.hidden && e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive) keys.push(tileKey(e)); - keys.sort((a, b) => a - b); - setAllied(battle, unit, keys.length ? keys : null); - }, - onEnd({ battle, unit }) { setAllied(battle, unit, null); }, - }), - }), - skill: { kind: 'duration', mods: { atkPct: num(bb.atk), taunt: num(t1.taunt_level, -1) }, targeting: { rangeGrid: LINE }, - onStart({ battle, unit }) { battle.fx('featherArrow', { x: unit.x, y: unit.y, id: unit.id }); } }, - talents: [ - { install(battle, unit) { // 凝神: ATK +15 % when not hurt for 10 s - battle.on('damaged', (c) => { if (c.target === unit && c.amount > 0 && !isHpLoss(c.dmg)) unit.mem.lastHurt = battle.time; }, { owner: unit }); // (a 流失 is not 受伤害) - battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.lastHurt = -Infinity; }, { owner: unit }); - whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'fartth:focus', battle.time - (unit.mem.lastHurt ?? -Infinity) >= num(t0.delay, 10) - 1e-9, { atkPct: num(t0.atk, 0.15) })); - } }, - { install(battle, unit) { // 屏息 (2nd half): skill attacks ignore physical dodge - battle.on('hit', (c) => { if (c.source === unit && skillActive(unit) && c.dmg.type === 'phys') c.dmg.canDodge = false; }, { owner: unit }); - } }, - ], - install(battle, unit) { - if (isSel(def, 'skchr_fartth_2')) { - // 同盟支援 reaches BLOCKED enemies outside her range only: the blocked enemies' tiles are extra range keys, so an - // unblocked enemy (a flyer…) standing on such a tile outside her own range is dropped for the next valid target - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit || !skillActive(unit) || !unit.extraRangeKeys) return; - const own = new Set(absoluteRangeKeys(unit.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend)); - const ok = (e) => bodyInKeys(e, own) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive); - if (c.targets.every(ok)) return; - const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile); - for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3 - for (let i = cands.length - 1; i >= 0; i--) if (!ok(cands[i])) cands.splice(i, 1); - sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null); - c.targets = cands.slice(0, Math.max(1, c.targets.length)); - }, { owner: unit, priority: 5 }); - } - const sp = num(tb.sp, 0); // module DEA-Y: 攻击的敌人未被击倒时自身额外获得1点技力 - if (sp > 0) { - // (`damaged` fires before the kill: a lethal hit leaves the target `alive` with 0 HP — that one gives no SP) - battle.on('damaged', (c) => { - if (c.source === unit && c.dmg?.isAttack && !c.dmg.isSplash && c.target.side === 'enemy' && c.target.alive && !downed(c.target)) unit.skill?.gainSp(sp, 'module'); - }, { owner: unit }); - } - const ds = num(tb.damage_scale, 0), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5); - battle.on('hit', (c) => { - if (c.source !== unit || !c.dmg.isAttack) return; - if (S3 && skillActive(unit) && unit.baseRangeKeys && !bodyInKeys(c.target, unit.baseRangeKeys)) c.dmg.mul *= num(bb.damage_scale, 1.25); - if (ds > 0) { const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); } - }, { owner: unit }); - }, - }; - }, - - // ===== 白面鸮 (ringhealer) S2 脑啡肽 — wider range, much faster heals; talent 技力光环 (+0.3 SP/s to all allies) - chess_char_4_21_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const g = grid(def.skill?.rangeGrid); - return { - skills: alt(def, { 'skcom_heal_up[3]': () => ({ kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } }) }), // S1 治疗强化·γ型 - skill: { kind: 'duration', heal: true, mods: { batPct: batFlat(def, bb.base_attack_time) }, targeting: g ? { rangeGrid: g } : undefined, - onStart({ battle, unit }) { battle.fx('healField', { x: unit.x, y: unit.y, id: unit.id }); } }, - talents: [{ install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec, 0.3), (a) => a.kind !== 'device'); } }], - install(battle, unit) { applyModuleRange(battle, unit, def); }, // module RIN-X (elite default): 攻击范围扩大 - }; - }, - - // ===== 银灰 (lord) S3 真银斩 — DEF −70 %, ATK +125 %, wider range, ≤ 4 targets at melee scale; 领袖 / 鹰眼视觉; module - // S1 强力击·γ型 (attack SP: next attack 205 %/225 %); S2 雪境生存法则 (toggle — [ASSUMED] once switched on it stays - // on for the deployment: smaller range, DEF up, 3.5 %/4 % max HP regen per s) - chess_char_4_22_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const tb = def.traitBb || {}; - const g = grid(def.skill?.rangeGrid); - return { - skills: alt(def, { - skchr_svrash_1: () => ({ kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 2.05) } }), - skchr_svrash_2: () => ({ - kind: 'toggle', - mods: { defPct: num(bb.def), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }, - targeting: g ? { rangeGrid: g } : undefined, - onStart({ battle, unit }) { battle.fx('snow', { x: unit.x, y: unit.y, id: unit.id }); }, - }), - }), - skill: { - kind: 'duration', - mods: { defPct: num(bb.def), atkPct: num(bb.atk) }, - targeting: { ...(g ? { rangeGrid: g } : {}), maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 4))) }, - attack: { projectile: 'none', dmgMul: () => 1 }, // 视为近距离攻击: no ranged ×0.8 - onStart({ battle, unit }) { battle.fx('truesilver', { x: unit.x, y: unit.y, id: unit.id }); }, - onAttack({ battle, unit }) { battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id }); }, - }, - talents: [ - { install(battle, unit) { // 领袖: ATK +10 %; every unit of the team redeploys 10 % faster - battle.addBuff(unit, { key: 'svrash:t1', mods: { atkPct: num(t0.atk, 0.1) }, persist: true, allowDead: true }); - const p = battle.getPlayer(unit.ownerId); - for (const a of p ? p.units : []) if (a.kind === 'op') battle.addBuff(a, { key: 'svrash:leader', mods: { redeployMul: Math.max(0, 1 + num(t0.respawn_time, -0.1)) }, persist: true, allowDead: true }); - } }, - { install(battle, unit) { whileDeployed(battle, unit, AURA, () => reveal(battle, enemiesOnRange(battle, unit))); } }, // 鹰眼视觉 - ], - install(battle, unit) { - const m = num(tb.atk_scale_m, 0); // module (elite): attacks add 10 % ATK arts damage - if (!(m > 0)) return; - battle.on('damaged', (c) => { - if (c.source !== unit || !c.dmg?.isAttack || c.type !== 'phys' || !c.target.alive || c.target.side !== 'enemy') return; - battle.dealDamage(unit, c.target, { amount: unit.s.atk * m, type: 'arts', tags: ['module'] }); - }, { owner: unit }); - }, - }; - }, - - // ===== 百炼嘉维尔 (centurion) S3 丛林之魂 — ATK/ASPD/block up, takes 50 % now, the rest as 20 s HP loss; talents; module - // S1 精准痛击 (ATK up, heals herself 30 %/35 % of the damage dealt); S2 链锯强袭 (wider range, ATK/DEF up, pulls - // unblocked enemies it hits to her front); module CEN-Y (好锯多磨): −20 % physical damage taken above 50 % HP - chess_char_4_23_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const tb = def.traitBb || {}; - const dr = Math.max(0, Math.min(0.99, num(bb.damage_resistance, 0.5))); - const fin = num(bb.final_duration, 20), iv = Math.max(1 / 30, num(bb.interval, 0.1)); - const S3 = isSel(def, 'skchr_gvial2_3'); - const g = grid(def.skill?.rangeGrid); - return { - skills: alt(def, { - skchr_gvial2_1: () => ({ - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - onHit({ battle, unit, dealt }) { if (dealt > 0 && unit.alive) battle.heal(unit, unit, dealt * num(bb.heal_scale, 0.3), { self: true }); }, - }), - skchr_gvial2_2: () => ({ - kind: 'duration', - mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, - targeting: g ? { rangeGrid: g } : undefined, - attack: { onEachHit({ battle, unit, target }) { if (target && target.alive && !target.blockedBy) pullToFront(battle, unit, target, num(bb['attack@force'], 1)); } }, - onStart({ battle, unit }) { battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); }, - }), - }), - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed), blockCnt: num(bb.block_cnt, 2) }, - onStart({ battle, unit }) { unit.mem.gvialDebt = 0; battle.fx('jungleSoul', { x: unit.x, y: unit.y, id: unit.id }); }, - onEnd({ battle, unit, reason }) { - const debt = unit.mem.gvialDebt ?? 0; - unit.mem.gvialDebt = 0; - if (reason === 'death' || !unit.alive || !(debt > 0)) return; - const per = debt * iv / fin; - battle.addBuff(unit, { key: 'gvial:bleed', refresh: 'independent', maxStacks: 10, duration: fin + 1e-6, interval: iv, visible: true, - onTick: ({ unit: u }) => battle.loseHp(u, per) }); - }, - }, - talents: [ - { install(battle, unit) { // 战地巨斧: ATK/DEF +10 %, +4 % per extra blocked enemy - whileDeployed(battle, unit, 0.1, () => { - const extra = Math.max(0, unit.blocking.length - 1); - toggleBuff(battle, unit, 'gvial:t1', true, { atkPct: num(t0.atk, 0.1) + num(t0.atk_add, 0.04) * extra, defPct: num(t0.def, 0.1) + num(t0.def_add, 0.04) * extra }); - }); - } }, - { install(battle, unit) { // 医学背景: healing received +20 %, +40 % below half HP - battle.on('heal', (c) => { if (c.target === unit) c.amount *= unit.hpRatio < num(t1.hp_ratio, 0.5) ? num(t1.heal_scale_2, 1.4) : num(t1.heal_scale_1, 1.2); }, { owner: unit }); - } }, - ], - install(battle, unit) { - const s = num(tb.atk_scale, 0); // module (elite): ×1.1 against blocked enemies - if (s > 0) battle.on('hit', (c) => { if (c.source === unit && c.dmg.isAttack && c.target.blockedBy === unit) c.dmg.mul *= s; }, { owner: unit }); - const yr = num(tb.hp_ratio, 0), yd = num(tb.damage_resistance, 0); // module CEN-Y: 生命值高于50%时受到的物理伤害降低20% - if (yr > 0 && yd > 0) battle.on('hit', (c) => { if (c.target === unit && c.dmg.type === 'phys' && unit.hpRatio > yr) c.dmg.mul *= 1 - yd; }, { owner: unit }); - if (!S3) return; - const deferred = new WeakSet(); - battle.on('hit', (c) => { - if (c.target !== unit || !skillActive(unit)) return; - c.dmg.mul *= 1 - dr; - deferred.add(c.dmg); - }, { owner: unit, priority: -5 }); - battle.on('damaged', (c) => { - if (c.target !== unit || !deferred.has(c.dmg)) return; - deferred.delete(c.dmg); - if (c.amount > 0) unit.mem.gvialDebt = (unit.mem.gvialDebt ?? 0) + (c.amount * dr) / (1 - dr); - }, { owner: unit }); - }, - }; - }, - - // ===== 卡涅利安 (phalanx) S2 沙缚镣锁 — faster attacks on every enemy in range (the trait's 群体法术伤害), 0.3 s - // sluggish on each; charged: ATK +10 % and bind; talents - // S1 沙暴守卫 (SEARCH: ATK/DEF up; charged — cast with every charge stored — "特性效果在技能期间继续生效", PRTS 备注 - // "应用蓄力时:应用技能未开启时的特性": the skill-off trait stays in force, the DEF/RES guard AND 不攻击 — she makes - // no attack until the skill ends, a pure guard); - // S3 食噬之印 (wider range, ATK +0 % → +140 %/+200 % in 1 s steps over 20 s — PRTS 备注; charged: each attack adds a - // stack of the mark BEFORE its damage — PRTS 备注 "于攻击造成伤害前生效,多层效果之间加算叠加" — +20 % damage from - // her per stack, ≤ 5, so ×1.2 on the first hit and ×2.0 from the 5th, until the skill ends; one mark per enemy: - // another 卡涅利安 only adds stacks to it, the bonus is the setter's — PRTS 备注 popup). Talent 生命之餐 heals on - // every skill; module PLX-X keeps part of the guard during any skill, PLX-Y (乡音): +3 % damage per enemy in range - // (≤ 5) - chess_char_4_24_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); - const tb = def.traitBb || {}; - const keepDef = num(tb['billro_e_002[buff].def'], 0), keepRes = num(tb['billro_e_002[buff].magic_resistance'], 0); - const S1 = isSel(def, 'skchr_billro_1'), S2 = isSel(def, 'skchr_billro_2'), S3 = isSel(def, 'skchr_billro_3'); - const g = grid(def.skill?.rangeGrid); - const isCharged = (skill) => skill.maxCharges > 1 && skill.charges + 1 >= skill.maxCharges; // cast with every charge stored - return { - // charged S1: the skill-off trait's 不攻击 stays in force while the skill lasts (PRTS S1 备注) — no attack at all - trait: S1 ? { canAttack: (battle, u) => !(skillActive(u) && u.mem.billroCharged) } : null, - skills: alt(def, { - skchr_billro_1: () => ({ - kind: 'duration', - mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, - onStart({ battle, unit, skill }) { - unit.mem.billroCharged = isCharged(skill); - // 蓄力额外效果:特性效果在技能期间继续生效 (the profession removes its own guard buff when a skill starts; the - // kit trait's canAttack keeps the trait's 不攻击) - if (unit.mem.billroCharged) battle.addBuff(unit, { key: 'billro:s1guard', mods: { defPct: num(unit.profile?.guardDef, 2), resFlat: num(unit.profile?.guardRes, 20) } }); - battle.fx('shell', { x: unit.x, y: unit.y, id: unit.id }); - }, - onEnd({ battle, unit }) { unit.mem.billroCharged = false; battle.removeBuff(unit, 'billro:s1guard'); }, - }), - skchr_billro_3: () => ({ - kind: 'duration', - targeting: g ? { rangeGrid: g } : undefined, - onStart({ battle, unit, skill }) { - unit.mem.billroCharged = isCharged(skill); - unit.mem.billroRamp = 0; - unit.mem.billroMarked = new Set(); - battle.addBuff(unit, { key: 'billro:s3atk', mods: { atkPct: 0 } }); - battle.fx('devour', { x: unit.x, y: unit.y, id: unit.id }); - }, - onTick({ unit, dt }) { // 攻击力逐渐增至+N%: PRTS 备注 "从+0%开始在20秒内线性增加,攻击力每1秒更新1次" - unit.mem.billroRamp = (unit.mem.billroRamp ?? 0) + dt; - const steps = Math.min(BILLRO_S3_RAMP, Math.floor(unit.mem.billroRamp + 1e-9)); - const b = unit.findBuff('billro:s3atk'); - if (b && b.mods.atkPct !== num(bb.atk) * steps / BILLRO_S3_RAMP) { b.mods = { atkPct: num(bb.atk) * steps / BILLRO_S3_RAMP }; unit.markDirty(); } - }, - onEnd({ battle, unit }) { - battle.removeBuff(unit, 'billro:s3atk'); - // "持续至技能结束": the marks she set end with her skill (stacks another 卡涅利安 added to them included) - for (const id of unit.mem.billroMarked || []) { - const e = battle.unitById(id); - if (e && e.findBuff(BILLRO_MARK)?.source === unit) battle.removeBuff(e, BILLRO_MARK); - } - unit.mem.billroMarked = null; - unit.mem.billroCharged = false; - }, - }), - }), - skill: { - kind: 'duration', - mods: { batPct: batFlat(def, bb.base_attack_time) }, - onStart({ battle, unit, skill }) { - const charged = isCharged(skill); - unit.mem.billroCharged = charged; - if (charged) battle.addBuff(unit, { key: 'billro:charged', mods: { atkPct: num(bb.atk, 0.1) } }); - battle.fx(charged ? 'sandChainsCharged' : 'sandChains', { x: unit.x, y: unit.y, id: unit.id }); - }, - onEnd({ battle, unit }) { unit.mem.billroCharged = false; battle.removeBuff(unit, 'billro:charged'); }, - }, - talents: [ - { install(battle, unit) { // 生命之餐: every skill start heals 40 % max HP (×2 when charged) - battle.on('skillStart', (c) => { - if (c.unit !== unit) return; - const charged = isCharged(c.skill); - battle.heal(unit, unit, unit.s.maxHp * (charged ? num(t0['billro_t_1[enhance].heal_scale'], 0.8) : num(t0.heal_scale, 0.4)), { self: true }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 蓄势待发: +0.6 SP/s once a charge is stored - whileDeployed(battle, unit, 0.1, () => toggleBuff(battle, unit, 'billro:t2', !!unit.skill && unit.skill.charges >= 1 && !skillActive(unit), { spRecoveryFlat: num(t1.sp_recovery_per_sec, 0.6) })); - } }, - ], - install(battle, unit) { - // module PLX-X (elite default): 技能开启时保留部分效果 (any skill; a charged S1 keeps the whole guard instead) - if (keepDef || keepRes) { - battle.on('skillStart', (c) => { - if (c.unit !== unit || (S1 && unit.mem.billroCharged)) return; - battle.addBuff(unit, { key: 'billro:keep', mods: { defPct: keepDef, resFlat: keepRes } }); - }, { owner: unit }); - battle.on('skillEnd', (c) => { if (c.unit === unit) battle.removeBuff(unit, 'billro:keep'); }, { owner: unit }); - } - // module PLX-Y: 范围内敌人越多造成的伤害越高(每个 +3 %,最多 5 个) - const per = num(mb.damage_scale, 0), cap = num(mb.max_valid_stack_cnt, 5); - if (per > 0) { - battle.on('hit', (c) => { - if (c.source !== unit || c.target.side !== 'enemy') return; - const n = Math.min(cap, enemiesOnRange(battle, unit).length); - if (n > 0) c.dmg.mul *= 1 + per * n; - }, { owner: unit }); - } - if (S3) { - // charged S3 食噬之印: each attack adds a stack BEFORE its damage (PRTS 备注 "于攻击造成伤害前生效"); stacks add up, - // +20 % damage taken from the mark's setter per stack. One mark per enemy (PRTS 备注 popup: "存在则为已有的BUFF叠加 - // 一次加成,不存在则给与敌人仅对此卡涅利安的伤害生效的一个食噬之印BUFF"): another 卡涅利安 stacks it without the bonus - const per = num(bb['attack@damage_scale'], 0.2); - battle.on('hit', (c) => { - const e = c.target; - if (c.source !== unit || e.side !== 'enemy') return; - let m = e.findBuff?.(BILLRO_MARK) || null; - if (c.dmg.isAttack && skillActive(unit) && unit.mem.billroCharged) { - if (m) battle.addBuff(e, { key: BILLRO_MARK, refresh: 'stack', maxStacks: BILLRO_MARK_MAX, duration: m.timeLeft }); - else { - m = battle.addBuff(e, { key: BILLRO_MARK, refresh: 'stack', maxStacks: BILLRO_MARK_MAX, duration: Math.max(0.1, (unit.skill?.timeLeft ?? 0) + 0.1), source: unit, visible: true }); - if (m) unit.mem.billroMarked?.add(e.id); - } - } - if (m && m.source === unit) c.dmg.mul *= 1 + per * (m.stacks || 1); - }, { owner: unit }); - } - if (!S2) return; - // "每次攻击对目标造成0.3秒停顿" (charged: 束缚): every enemy her attack damages — each one on her range - battle.on('damaged', (c) => { - const e = c.target; - if (c.source !== unit || !c.dmg || !c.dmg.isAttack || e.side !== 'enemy' || !e.alive || !skillActive(unit)) return; - if (unit.mem.billroCharged) battle.applyStatus(e, 'bind', { duration: num(bb['attack@root'], 0.3), source: unit }); - else battle.applyStatus(e, 'sluggish', { duration: num(bb['attack@sluggish'], 0.3), source: unit }); - }, { owner: unit }); - }, - }; - }, - - // ===== 魔王 (bard) S3 编织重构现世 — wider range, trait 65 %, motes stay, 鼓舞 +65 % of her max HP, HP redistribution - // S1 往昔萦绕身旁 (SP_FULL toggle, 持续时间无限: trait 20 %/28 %, motes respawn in `talent_cool_down` s); S2 明日渺远不及 - // (motes cap +3 and 6 at once, wider orbit — enemies within outside_radius — no longer touch operators: a mote - // hitting an enemy deals 220 %/260 % ATK true damage + 3 s bind and respawns like any mote [ASSUMED]; 鼓舞: other - // allies in range ATK + 65 %/80 % of her ATK) - chess_char_4_25_a: (bb, chess, def) => { - const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); - const g = grid(def.skill?.rangeGrid); - const redistIv = Math.max(0.5, num(bb['attack@cetsyr_s_3[cal_hp_ratio].interval'], 2)); - const S1 = isSel(def, 'skchr_cetsyr_1'), S2 = isSel(def, 'skchr_cetsyr_2'), S3 = isSel(def, 'skchr_cetsyr_3'); - const baseCnt = Math.max(0, Math.floor(num(t0.cnt, 3))); - const MOTE_GAP = 0.5; // [ASSUMED] minimal time between two motes hitting the same enemy (s) - const setTrait = (unit, v) => { unit.mem.bardBase ??= unit.profile.auraRatio; unit.profile.auraRatio = num(v, unit.mem.bardBase); }; - const resetTrait = (unit) => { if (unit.mem.bardBase != null) unit.profile.auraRatio = unit.mem.bardBase; }; - return { - skills: alt(def, { - skchr_cetsyr_1: () => ({ - kind: 'toggle', - trigger: 'SP_FULL', // 自动触发 (no 技能策略: the 吟游者 row is for MANUAL skills): on as soon as it is ready - onStart({ battle, unit }) { setTrait(unit, bb['attack@atk_to_hp_recovery_ratio']); battle.fx('mote', { x: unit.x, y: unit.y, id: unit.id }); }, - onEnd({ unit }) { resetTrait(unit); }, - }), - skchr_cetsyr_2: () => ({ - kind: 'duration', - onStart({ battle, unit }) { - // “微尘”上限+3,立刻获得6枚“微尘” - unit.mem.motes = new Array(baseCnt + 3).fill(-Infinity); - unit.mem.moteHit = new Map(); - battle.fx('reweave', { x: unit.x, y: unit.y, id: unit.id, n: unit.mem.motes.length }); - }, - onTick({ battle, unit }) { // 鼓舞: other allies in range ATK + x % of her ATK - for (const a of battle.alliesInGrid(unit)) if (a !== unit) inspire(battle, a, unit.s.atk * num(bb['attack@atk'], 0.65), unit); - }, - onEnd({ battle, unit }) { - // back to the normal cap: the motes that come back first are kept - unit.mem.motes = (unit.mem.motes || []).slice().sort((a, b) => a - b).slice(0, baseCnt); - unit.mem.moteHit = null; - }, - }), - }), - skill: { - kind: 'duration', - targeting: g ? { rangeGrid: g } : undefined, - onStart({ battle, unit }) { - setTrait(unit, bb['attack@atk_to_hp_recovery_ratio']); - unit.mem.redist = 0; - battle.fx('reweave', { x: unit.x, y: unit.y, id: unit.id }); - }, - onTick({ battle, unit, dt }) { - const others = battle.alliesInGrid(unit).filter((a) => a !== unit); - for (const a of others) inspire(battle, a, unit.s.maxHp * num(bb.max_hp, 0.65), unit, 'hp'); - unit.mem.redist += dt; - if (unit.mem.redist + 1e-9 < redistIv) return; - unit.mem.redist -= redistIv; - const pool = battle.alliesInGrid(unit).filter((a) => !(a.s.flags.noHeal || a.profile?.noHeal) || a === unit); - let hp = 0, max = 0; - for (const a of pool) { hp += a.hp; max += a.s.maxHp; } - if (!(max > 0) || pool.length < 2) return; - const ratio = Math.min(1, hp / max); - for (const a of pool) a.hp = Math.max(1, Math.min(a.s.maxHp, a.s.maxHp * ratio)); - battle.fx('redistribute', { x: unit.x, y: unit.y, id: unit.id, ratio: Math.round(ratio * 1000) / 1000 }); - }, - onEnd({ unit }) { if (unit.mem.bardBase != null) unit.profile.auraRatio = unit.mem.bardBase; }, - }, - talents: [ - { install(battle, unit) { // 过往尘埃: 3 motes; touching an operator ⇒ her trait heal ×1.5 on it for 6 s; mote back after 6 s - unit.mem.motes = new Array(baseCnt).fill(-Infinity); - unit.mem.noInspire = true; // trait: 自身不受鼓舞影响 (another bard's 鼓舞 skips her) - const cooldown = () => (S1 && skillActive(unit) ? num(bb.talent_cool_down, 3) : num(t0.cooldown, 6)); // S1: 重生速度加快 - const takeMote = () => { - const i = unit.mem.motes.findIndex((t) => t <= battle.time + 1e-9); - if (i < 0) return false; - unit.mem.motes[i] = battle.time + cooldown(); - return true; - }; - whileDeployed(battle, unit, 0.25, () => { - if (S2 && skillActive(unit)) { - // S2: motes orbit wider and hit enemies instead of operators - const hitAt = unit.mem.moteHit || (unit.mem.moteHit = new Map()); - const foes = battle.foesInRadius(unit.x, unit.y, num(bb.outside_radius, 2)).filter((e) => canTargetEnemy(unit, e, { canHitFly: true })) - .sort((a, b) => a.id - b.id); - for (const e of foes) { - if ((hitAt.get(e.id) ?? -Infinity) > battle.time + 1e-9) continue; - if (!takeMote()) break; - hitAt.set(e.id, battle.time + MOTE_GAP); - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 2.2), type: 'true', isSkill: true, tags: ['skill', 'mote'] }); - if (e.alive) battle.applyStatus(e, 'bind', { duration: num(bb.unmoveable_duration, 3), source: unit }); - battle.fx('mote', { x: e.x, y: e.y, id: e.id }); - } - return; - } - const keep = S3 && skillActive(unit); // S3: “微尘”不再消失 - for (const a of battle.alliesInRadius(unit.x, unit.y, num(t0.range_radius, 1.15), unit.ownerId)) { - if (a === unit || a.kind !== 'op' || a.findBuff(`cetsyr:mote:${unit.id}`)) continue; - if (!keep && !takeMote()) break; - battle.addBuff(a, { key: `cetsyr:mote:${unit.id}`, duration: num(t0.talent_duration, 6), visible: true, source: unit }); - battle.fx('mote', { x: a.x, y: a.y, id: a.id }); - } - }); - battle.on('heal', (c) => { - if (c.source === unit && c.opts?.aura && c.target.findBuff(`cetsyr:mote:${unit.id}`)) c.amount *= num(t0['attack@trait_mul'], 1.5); - }, { owner: unit }); - } }, - { install(battle, unit) { // 魔王残响: every ally takes −10 % damage from 萨卡兹 enemies (while she is in the squad) - battle.on('hit', (c) => { - if (c.target.side === 'ally' && c.target.ownerId === unit.ownerId && enemyHasTag(c.source, 'sarkaz')) c.dmg.mul *= 1 - num(t1.damage_resistance, 0.1); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - const a = num(mb.atk, 0), cnt = num(mb.cnt, 2); // module (elite): ≥ 2 other operators in her normal range ⇒ ATK +8 % - if (!a) return; - whileDeployed(battle, unit, AURA, () => { - const set = new Set(unit.baseRangeKeys || []); - const n = battle.allies(unit.ownerId).filter((o) => o !== unit && o.kind === 'op' && set.has(o.tileR * COLS + o.tileC)).length; - toggleBuff(battle, unit, 'cetsyr:module', n >= cnt, { atkPct: a }); - }); - }, - }; - }, - - // ===== 华法琳 (physician) S1 紧急包扎 — charges: a heal on an ally below 50 % adds 15 % of its max HP; 血液样本回收 - // S2 不稳定血浆 (she and one random other ally in range: ATK +45 %/+60 %, lose 3 % max HP per s, 15 s) - chess_char_4_26_a: (bb, chess, def) => { - const t0 = tbb(def, 0); - const tb = def.traitBb || {}; - const S1 = isSel(def, 'skchr_bldsk_1'); - const plasma = (battle, unit, a) => battle.addBuff(a, { - key: 'bldsk:plasma', duration: num(bb.duration, 15) + 1e-6, interval: Math.max(0.1, num(bb.interval, 1)), mods: { atkPct: num(bb.atk) }, visible: true, source: unit, - onTick: ({ unit: t }) => battle.loseHp(t, t.s.maxHp * num(bb.hp_ratio, 0.03), { tags: ['skill', 'plasma'] }), // (no source: not damage dealt) - }); - return { - skills: alt(def, { - skchr_bldsk_2: () => ({ - kind: 'instant', heal: true, - onStart({ battle, unit }) { - plasma(battle, unit, unit); - const pick = battle.rng.pick(battle.alliesInGrid(unit).filter((a) => a !== unit)); - if (pick) plasma(battle, unit, pick); - battle.fx('bloodBattle', { x: unit.x, y: unit.y, id: unit.id, target: pick ? pick.id : null }); - }, - }), - }), - skill: { - kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', - heal: true, - trigger: { rule: 'CUSTOM_RANGE', grid: [] }, // never auto-cast by the engine: fired by the kit on a < 50 % heal target - }, - talents: [{ install(battle, unit) { // 血液样本回收: an enemy falls in range ⇒ +2 SP to her and a random ally in range - battle.on('death', (c) => { - const e = c.unit; - if (c.reason !== 'killed' || e.side !== 'enemy' || !unit.alive || !unit.deployed || !bodyInKeys(e, keySet(unit))) return; - unit.skill?.gainSp(num(t0['bldsk_t_1[self].sp'], 2), 'talent'); - // SP cannot be gained while a timed skill runs (engine gainSp ignores it): such allies are not picked - const pick = battle.rng.pick(battle.alliesInGrid(unit).filter((a) => a !== unit && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive' && !(a.skill.active && a.skill.isTimed))); - if (pick) { pick.skill.gainSp(num(t0['bldsk_t_1[rand].sp'], 2), 'talent'); battle.fx('spGift', { x: pick.x, y: pick.y, id: pick.id }); } - }, { owner: unit }); - } }], - install(battle, unit) { - installLowHpHealBonus(battle, unit, tb); - if (!S1) return; - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit) return; - const t = c.targets[0]; - const sk = unit.skill; - if (!t || t.side !== 'ally' || t.hpRatio >= 0.5 || !sk || !sk.ready || unit.s.flags.silence) return; - if (sk.activate('DEFAULT')) unit.mem.bandage = t; - }, { owner: unit, priority: 5 }); - battle.on('attack', (c) => { - if (c.attacker !== unit || !unit.mem.bandage) return; - const t = unit.mem.bandage; - unit.mem.bandage = null; - unit.mem.skipSp = true; // the skill heal recovers no attack SP (AK) - if (t.alive) { battle.heal(unit, t, t.s.maxHp * num(bb.hp_ratio, 0.15)); battle.fx('bandage', { x: t.x, y: t.y, id: t.id }); } - }, { owner: unit }); - battle.on('spGain', (c) => { if (c.unit === unit && c.reason === 'attack' && unit.mem.skipSp) { unit.mem.skipSp = false; c.amount = 0; } }, { owner: unit }); - }, - }; - }, -}; - -/** Physician module trait (录武官, 华法琳 elites): heals on allies below hp_ratio are ×heal_scale. */ -function installLowHpHealBonus(battle, unit, tb) { - const s = num(tb.heal_scale, 0), r = num(tb.hp_ratio, 0); - if (!(s > 0) || !(r > 0)) return; - battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && c.target.hpRatio < r) c.amount *= s; }, { owner: unit }); -} - -// DESIGN §5.6 documents `(bb, chess)`; content/index.js also passes the normalised def — rebuild it when absent. -export default Object.freeze(Object.fromEntries(Object.entries(kits).map(([id, f]) => [id, (bb, chess, def) => f(bb || {}, chess, def || normalizeChess(chess))]))); +export default Object.freeze({ ...TIER_KITS[3] }); diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index 9116b5a0..224472c7 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -1,2597 +1,6 @@ -// server/sim/content/kits/tier5.js — hand-authored kits for every tier-5 chess (DESIGN §7, docs/SIM.md §7.2). -// -// export default { [baseChessId]: (bb, chess, def) => Kit }. `bb` = skill blackboard at the chess's level (normal Lv4 / -// elite Lv7), `chess` = data/chess.json record (talents with their own bb — elite records already carry the module talent -// upgrades — trait.bb incl. the module trait upgrade, stats, tokens), `def` = normalised def (skill.rangeGrid, …). -// Every number comes from those blackboards; the few constants below exist only in the skill TEXT (no blackboard key). -// -// Conventions shared by the kits of this file: -// - `base_attack_time` in skill blackboards is an absolute delta in seconds (AK attribute ADDITION: 烛煌 1.6 s − 1.3 s, -// 号角 2.8 s − 1.2 s, 白面鸮 2.85 s − 1.8 s, 寒檀 2.9 s − 2.4 s); it is converted into `batPct` of the chess's base BAT. -// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge, -// × elementalTakenMul = 元素脆弱), with `element` set for the client colour. -// - Mechanics missing from the chess text follow the PRTS 备注 of the base operator (verified 2026-09-28): 号角 S3 overload -// is the second half of the 24 s duration; 圣约送葬人's extra attack consumes no ammo; 夕 S1 splash 1.7; 烛煌 revive stun -// radius 1.7, S3 splash 1.7 and its refilled ammo capped at the skill's ammo; 寒檀 icicles splash 1.5 and cycle left -// row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit at 1.15 (30°/s, hit radius 0.4); 铃兰 T2 is an -// aura (sluggish enemies in range are 脆弱 while sluggish); 缇缇's chain sleep picks the highest-aggro enemy within -// 1.5; 乌尔比安 lands on the anchor tile > the tile beyond > his own tile; 引星棘刺 throws at the farthest forward tile -// when no enemy is in range; 夕 T2 summons only on a deployable target tile. -// - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field. -// SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins. -// - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an -// empty grid) plus their own activation — 塞雷娅 S2 (`autoCast`: an injured ally in the heal area of the skill; her -// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal). 塞雷娅 S1 (自动触发) -// uses the engine's DEFAULT rule with `allies` / `hpAtMost` (about to attack + an ally of the skill area at ≤ half HP: -// the heal replaces that attack). Every other -// kit keeps the data rule (tools/build-data.mjs resolveTrigger, the official 技能策略: DEFAULT = about to attack + an -// enemy in the INITIAL range; SKILL_RANGE for a MANUAL skill's own 技能范围; the class rows — 重装 TAKE_DAMAGE … — for -// every MANUAL skill; AUTO skills never take a class row). -// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor' -// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r}, -// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'crit', -// 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'. +// server/sim/content/kits/tier5.js — re-export shim (0.2.0 refactor, R4): the tier-5 kits live one per file in ops/, +// registered by index.js; their shared helpers and notes are in shared/tier5.js (README.md). This module keeps the +// old imports working until the shims are removed after the 0.2.0 refactor wave. +import { TIER_KITS } from './index.js'; -import { COLS } from '../../constants.js'; -import { bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js'; -import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js'; -import { frontOf, rotateOffset, toLocal } from '../../dir.js'; -import { mitigate, hasHp, isHpLoss } from '../../damage.js'; - -// ---- text-only constants (the official blackboards carry no key for these) -------------------------------------- -/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */ -const HALF_HP = 0.5; -/** 夕 S1 "下一次攻击溅射范围扩大" — expanded splash radius (tiles; the splash-caster default is 1.1). PRTS: "溅射半径扩大至1.7". */ -const DUSK_SPLASH_RADIUS = 1.7; -/** 隐德来希 S3 "每次攻击对心烛至少造成35%攻击力的伤害". */ -const CANDLE_MIN_ATK = 0.35; -/** 隐德来希 S3 心烛 enemy key (hidden talent bbStr take_extra_enemy_key; not in data/enemies.json). */ -const CANDLE_KEY = 'enemy_5601_entlec'; -/** 烛煌 绝处重燃 "使附近的敌人晕眩" radius (tiles). PRTS 备注: 1.7. */ -const NEARBY_RADIUS = 1.7; -/** 烛煌 S3 "攻击变为群体攻击": splash radius around the target (tiles). PRTS 技能3 备注 "攻击溅射半径1.7". */ -const BLAZE_S3_SPLASH = 1.7; -/** 玛恩纳 游侠 "周围存在3名及以上敌人" radius (8-neighbourhood). */ -const AROUND_RADIUS = 1.5; -/** 安洁莉娜 S3 失重: weight (massLevel) reduction while weightless. PRTS: "重量下降一个等级". */ -const WEIGHTLESS_MASS = 1; -/** 寒檀 S2 icicle splash radius (PRTS 备注 "冰凌溅射半径1.5"). */ -const ICICLE_RADIUS = 1.5; -/** 寒檀 S2 icicle fall time (s) between the attack and the impact. [ASSUMED] */ -const ICICLE_DELAY = 0.3; -/** 寒檀 S2 icicle lines, lateral offsets in her facing-RIGHT frame: ① the line on her left, ② on her right, ③ her own, then ①. */ -const ICICLE_ROWS = [1, -1, 0]; -/** 魔王 T1 微尘: collision radius of one mote (PRTS 备注 0.4); orbit radius / speed come from the talent bb. */ -const MOTE_HIT_RADIUS = 0.4; -/** - * Abyssal Hunters (【深海猎人】 = character_table groupId `abyssal`; data/chess.json has no groupId, the charIds come from - * docs/research/03-operators.json): 幽灵鲨, 斯卡蒂, 歌蕾蒂娅, 乌尔比安, 归溟幽灵鲨. 浊心斯卡蒂 (char_1012_skadi2) has groupId - * null in the official data — she is NOT an Abyssal Hunter for these talents. - */ -const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']); -/** Never auto-fires (CUSTOM_RANGE with an empty trigger grid): the kit activates the skill itself. */ -const NEVER = Object.freeze({ rule: 'CUSTOM_RANGE', grid: Object.freeze([]) }); -const AURA_IV = 0.25; -const AURA_DUR = 0.5; -// alternate skills (loadouts) — text-only numbers -/** "周围" (8-neighbourhood) as a radius: 隐德来希 S2 血镰, 缇缇 S2 sleep ward, 乌尔比安 S1 anchor, 引星棘刺 S3 area. */ -const RING1 = 1.5; -/** 夕 S3 "攻击范围与溅射范围扩大" — the expanded splash radius, the same 1.7 as S1 (PRTS). [ASSUMED for S3] */ -const DUSK_S3_SPLASH = DUSK_SPLASH_RADIUS; -/** 号角 S2 过载: the second half of the ammo (PRTS 备注 of S3 "技能进行到一半时"; S2 counts shots). [ASSUMED] */ -const HORN_S2_OVERLOAD_AT = 0.5; - -// ---- helpers ------------------------------------------------------------------------------------------------------ -const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); -const on = (u) => !!u && u.alive && u.deployed && !u.removed; -/** In the unit's current range: an ally by its tile, an enemy by its body (a huge one's every tile — sim/body.js). */ -const inRange = (unit, x) => !!unit.rangeKeySet && bodyInKeys(x, unit.rangeKeySet); -const talent = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i)?.bb ?? {}; -const talentRec = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i) ?? null; -const traitBb = (chess) => chess?.trait?.bb ?? {}; -const skillGrid = (chess, def) => def?.skill?.rangeGrid ?? chess?.skill?.rangeGrid ?? null; -const maxCharges = (chess, def) => num(def?.skill?.maxCharges, num(chess?.skill?.maxChargeTime, 1)); -/** Blackboard `base_attack_time` (seconds) → batPct of this chess's base attack time. */ -const batPct = (sec, chess) => { const b = num(chess?.stats?.bat, 1) || 1; const v = num(sec) / b; return v ? Math.max(-0.9, v) : 0; }; -/** Drop zero / non-finite entries (a zero mod is noise in the buff list). */ -const mods = (m) => { const o = {}; for (const k of Object.keys(m)) { const v = m[k]; if (typeof v === 'number' && Number.isFinite(v) && v !== 0) o[k] = v; } return o; }; -const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); -const nationOf = (u) => u?.def?.raw?.nationId ?? null; -const inFaction = (u, bond, nations) => !!u?.def && ((u.def.bonds || []).includes(bond) || nations.includes(nationOf(u))); -const isLaterano = (u) => inFaction(u, 'lateranoShip', ['laterano']); -const isKazimierz = (u) => inFaction(u, 'kazimierzShip', ['kazimierz']); -const isKjerag = (u) => inFaction(u, 'kjeragShip', ['kjerag']); -const isSargonMinos = (u) => inFaction(u, 'sargonShip', ['sargon', 'minos']); -const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId); -const isOp = (u) => u && u.kind === 'op'; - -// ---- operator loadouts (DESIGN §16) ------------------------------------------------------------------------------- -// A kit function is resolved per SELECTED skill: `bb` / `chess.skill` / `def.skill` are the selected skill's. The default -// skill keeps its `skill` spec; every other selectable skill is a `skills[skillId]` entry. Kit `install` / talents run -// under every skill, so default-skill machinery in them is guarded by the selected skill id (`sid`). -/** Id of the selected skill of a chess record / def. */ -const selectedId = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null; -/** `skills` map whose entries are built only when read (each spec is a function of the SELECTED skill's bb). */ -function lazySkills(builders) { - const o = {}; - for (const [id, build] of Object.entries(builders)) Object.defineProperty(o, id, { enumerable: true, get: build }); - return o; -} -/** Spec kind of an instant skill (charges when the selected skill can charge). */ -const instantKind = (chess, def) => (maxCharges(chess, def) > 1 ? 'charges' : 'instant'); -/** Skill-range targeting override of the selected skill (undefined without a skill range). */ -const skillRange = (chess, def, extra = null) => { - const g = skillGrid(chess, def); - return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined; -}; -/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */ -function crowdAspd(battle, unit, key, aspd, cnt) { - if (!aspd || !(cnt > 0)) return; - whileOn(battle, unit, 0.2, () => { - if (battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).length >= cnt) battle.addBuff(unit, { key, duration: 0.3, mods: { aspd } }); - }); -} -/** "攻击范围内存在元素损伤爆发的敌人时,技力自然恢复速度+X/秒" (modules PRI-Y). */ -function burstSpUp(battle, unit, key, sp) { - if (!(sp > 0)) return; - whileOn(battle, unit, AURA_IV, () => { - if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).some((e) => e.s.flags.burstLock)) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } }); - }); -} -/** "攻击范围内存在敌人时,技力自然恢复速度+X/秒" (modules DEC-X). */ -function enemySpUp(battle, unit, key, sp) { - if (!(sp > 0)) return; - whileOn(battle, unit, AURA_IV, () => { - if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } }); - }); -} - -/** First living deployed ally with the same operator (charId) — used to keep same-name talents from stacking. */ -function leaderOf(battle, unit) { - const cid = unit.def?.charId; - for (const a of battle.allyUnits) if (on(a) && a.def?.charId === cid) return a; - return null; -} - -/** Enemy `e` attacks `t` in melee (not a ranged shot), mirroring ai.js enemyAttack. */ -function meleeAttacker(e, t) { - const melee = e.profile?.melee ?? e.def?.applyWay === 'MELEE'; - const r = num(e.base?.rangeRadius, 0); - return melee || !(r > 0) || (e.blockedBy === t && r < 1); -} - -/** Enemy not moving along its route (blocked, stunned, rooted, waiting…). */ -const isStill = (e) => !!(e.blockedBy || !e.moving || e.s.flags.stun || e.s.flags.noMove || !(e.s.moveSpeed > 0)); - -/** Elemental HP damage (元素伤害): engine type 'elemental' (× elementalTakenMul in the pipeline, never trueTakenMul). */ -function elementHit(battle, src, tgt, amount, tag, element = null) { - if (!tgt || !tgt.alive || !(amount > 0)) return 0; - return battle.dealDamage(src, tgt, { amount, type: 'elemental', element, canDodge: false, tags: ['element', tag] }); -} - -/** - * Activate the unit's skill whenever it is ready and `cond()` holds (NEVER-trigger skills). Consecutive casts of a - * multi-charge skill are spaced by at least one attack interval (like the attack-driven DEFAULT rule). - */ -function autoCast(battle, unit, cond) { - battle.on('tick', () => { - const sk = unit.skill; - if (!sk || sk.noSkill || !on(unit) || !unit.canAct || !sk.ready || unit.s.flags.silence) return; - if (sk.active && sk.isTimed) return; - if (battle.time - sk.lastStart < unit.s.interval - 1e-9) return; - if (cond()) sk.activate('DEFAULT'); - }, { owner: unit }); -} - -/** Periodic check while the unit is on the field. */ -function whileOn(battle, unit, sec, fn) { - battle.every(sec, () => { if (on(unit)) fn(); }, { owner: unit }); -} - -/** "同类效果取最高" SP-recovery aura: refresh `aura:spRecovery` on matching allies unless a higher one is present. */ -function spAura(battle, unit, value, filter) { - if (!(value > 0)) return; - whileOn(battle, unit, AURA_IV, () => { - for (const a of battle.alliesFor(unit)) { - if (!filter(a)) continue; - const cur = a.findBuff('aura:spRecovery'); - if (cur && num(cur.mods?.spRecoveryFlat) > value + 1e-9) continue; - battle.addBuff(a, { key: 'aura:spRecovery', duration: AURA_DUR, mods: { spRecoveryFlat: value } }); - } - }); -} - -/** Module PRI-X (本源术师): damage vs enemies in an element burst ×damage_scale. */ -function burstDamageUp(battle, unit, mul) { - if (!(mul > 1)) return; - battle.on('hit', (c) => { - if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return; - if (c.target.s.flags.burstLock) c.dmg.mul *= mul; - }, { owner: unit }); -} - -/** Modules PHY-X / GUA-X: heals on allies below hp_ratio ×heal_scale. */ -function lowHpHealUp(battle, unit, tb) { - const mul = num(tb.heal_scale), thr = num(tb.hp_ratio); - if (!(mul > 1) || !(thr > 0)) return; - battle.on('heal', (c) => { - if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally') return; - if (c.target.hpRatio < thr) c.amount *= mul; - }, { owner: unit }); -} - -/** - * Modules "攻击范围扩大" (夕 SPC-X, 白面鸮 RIN-X): the range becomes the SELECTED module's own grid — the data's - * range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + ONE centre tile [0,3] — like tier4's - * 莫斯提马 / 莱恩哈特 / 白面鸮 (integration review: a flat forward +1 added a whole column, 3 tiles, and widened the skill - * ranges too). It replaces the unit's own range, so the DEFAULT trigger's initial range follows; skills with their own - * range keep it. Without such a grid in the data: one extra forward tile [ASSUMED]. - */ -function moduleRangeUp(battle, unit, chess) { - if (!chess?.isGolden || !/攻击范围扩大/.test(String(chess?.trait?.moduleDesc ?? ''))) return; - const m = chess.module; - const rec = m && m.active && m.id ? (chess.modules || []).find((x) => x && x.uniEquipId === m.id) : null; - const g = (rec?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid; - if (g) { - unit.rangeGrid = g.map((p) => [p[0], p[1]]); - battle.refreshRange(unit); - return; - } - // a permanent (persist, never-expiring) rangeExtend: the engine counts it in the initial range of the DEFAULT skill - // trigger ("敌人进入初始攻击范围") as well - battle.addBuff(unit, { key: 't5:moduleRange', mods: { rangeExtend: 1 }, persist: true, allowDead: true }); -} - -/** Permanent (talent) stat buff that survives death/redeploy. */ -function permBuff(battle, unit, key, m) { - const mm = mods(m); - if (Object.keys(mm).length) battle.addBuff(unit, { key, mods: mm, persist: true, allowDead: true }); -} - -/** Enemies inside an absolute-key grid of `grid` offsets around `unit` (best targets first). */ -function enemiesInGrid(battle, unit, grid, n = 0, { ignoreStealth = false } = {}) { - const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], unit.tileR, unit.tileC, unit.dir, 0); - let list; - if (ignoreStealth) { - const set = new Set(keys); - list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && bodyInKeys(e, set)); - } else list = battle.enemiesInKeys(keys, unit, { canHitFly: true }); - sortEnemyTargets(battle, unit, list, null); - return n > 0 && list.length > n ? list.slice(0, n) : list; -} - -/** Straight-road length through each tile (max of the horizontal and vertical runs of ground-passable tiles). */ -const ROAD_CACHE = new WeakMap(); -function straightRun(battle, r, c) { - let m = ROAD_CACHE.get(battle); - if (!m) { - m = new Map(); - const g = battle.grid, R = battle.rect; - const pass = (rr, cc) => g.groundPassable(rr, cc, true); - for (let rr = R.r0; rr <= R.r1; rr++) { - for (let cc = R.c0; cc <= R.c1; cc++) { - if (!pass(rr, cc)) continue; - let h = 1, v = 1; - for (let k = cc - 1; k >= R.c0 && pass(rr, k); k--) h++; - for (let k = cc + 1; k <= R.c1 && pass(rr, k); k++) h++; - for (let k = rr - 1; k >= R.r0 && pass(k, cc); k--) v++; - for (let k = rr + 1; k <= R.r1 && pass(k, cc); k++) v++; - m.set(rr * COLS + cc, Math.max(h, v)); - } - } - ROAD_CACHE.set(battle, m); - } - return m.get(r * COLS + c) ?? 0; -} - -// ================================================================================================================== -// kits - -const KITS = { - // --------------------------------------------------------------------------------------------------------------- - // 圣约送葬人 — S2 近身铳斗 (ammo, attack SP): ATK/DEF +, block +1, melee hits dodged with prob (+ammo refill). - // T1 受选之人: extra attack chance (+prob_add per ammo spent in the skill). T2 铳弹共感: +ammo per Laterano op. - // S1 遗嘱执行 (ammo 8, attack SP): skill range, ATK +, ignores def_penetrate_fixed DEF. - // S3 圣约决裁 (ammo 16): skill range, BAT +0.5 s, ATK +; +attack@atk ATK per bullet spent (≤ attack@max_stack_cnt), - // reaper self-heal ×trait_ratio; at the end every enemy attacked during the skill takes attack@final_atk_scale × ATK phys. - // Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range. - chess_char_5_01_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); - const sid = selectedId(chess, def); - const prob = num(bb.prob), refill = num(bb.recover_cnt, 1); - return { - skills: lazySkills({ - skchr_excu2_1: () => ({ - kind: 'ammo', ammo: num(bb['attack@trigger_time'], 8), - mods: mods({ atkPct: num(bb.atk), defIgnoreFlat: num(bb.def_penetrate_fixed) }), - targeting: skillRange(chess, def), - }), - skchr_excu2_3: () => ({ - kind: 'ammo', ammo: num(bb['attack@trigger_time'], 16), - mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), - targeting: skillRange(chess, def), - attack: { onEachHit({ unit, target }) { if (target && target.side === 'enemy') unit.mem.verdict?.add(target); } }, - onStart({ unit }) { - unit.mem.verdict = new Set(); - // 特性的回复生命效果提高至2倍 (the reaper profile reads profile.selfHeal on every attack) - if (unit.profile) { unit.mem.verdictHeal0 ??= num(unit.profile.selfHeal, 50); unit.profile.selfHeal = unit.mem.verdictHeal0 * num(bb.trait_ratio, 1); } - }, - onEnd({ battle, unit }) { - const hit = unit.mem.verdict; - unit.mem.verdict = null; - if (unit.profile && unit.mem.verdictHeal0 != null) unit.profile.selfHeal = unit.mem.verdictHeal0; - if (hit && unit.alive) { - for (const e of hit) { - if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@final_atk_scale']), type: 'phys', isSkill: true, tags: ['skill', 'verdict'] }); - } - battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1.5, skill: 'excu2' }); - } - battle.removeBuff(unit, 'excu2:verdict'); - }, - }), - }), - skill: { - kind: 'ammo', ammo: num(bb['attack@trigger_time'], 12), - mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt) }), - }, - // REA-Y: its trait part is DISPLAY-only (official battle_equip_table uniequip_003_excu2: target DISPLAY, the ASPD - // text "{value}" = 12; the ASPD rider itself is the talent −1 part). data/chess.json merges that display blackboard - // into trait.bb ({value: 12}), which the reaper profile would read as its per-hit heal: the heal stays the base - // trait's (50). - trait: tm['trigger_cnt[equip]'] != null && chess?.traitBase?.bb?.value != null ? { selfHeal: num(chess.traitBase.bb.value, 50) } : undefined, - talents: [ - { install(battle, unit) { // 受选之人 - let spent = 0; - const reset = (c) => { if (c.unit === unit) spent = 0; }; - battle.on('skillStart', reset, { owner: unit }); - battle.on('skillEnd', reset, { owner: unit }); - battle.on('ammoUsed', (c) => { if (c.unit === unit) spent++; }, { owner: unit }); - // PRTS 备注: "触发时可额外回复1点攻击回复技力,不额外消耗弹药" — the extra attack is an attack (attack SP, trait - // heal) but consumes no ammo (engine forceAttack `noAmmo`: no bullet, no ammoUsed for any listener — the - // 7-bullet 特质 counter, items, bonds and the prob_add counter above) - battle.on('attack', (c) => { - if (c.attacker !== unit || unit.mem.extraAttack || !on(unit)) return; - const p = num(t0.prob) + (unit.skill?.active ? num(t0.prob_add) * spent : 0); - if (!(p > 0) || !battle.rng.chance(Math.min(1, p))) return; - const dep = unit.deploySeq; - battle.after(0, () => { - if (!on(unit) || unit.deploySeq !== dep || !unit.canAct || unit.s.flags.disarm) return; - unit.mem.extraAttack = true; - let hit = false; - try { hit = battle.forceAttack(unit, null, { noAmmo: true }); } finally { unit.mem.extraAttack = false; } - if (hit) battle.fx('extraAttack', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 铳弹共感 - battle.on('skillStart', (c) => { - if (c.unit !== unit || c.skill.kind !== 'ammo') return; - const n = battle.allies().filter((a) => isOp(a) && isLaterano(a)).length; - const stacks = Math.min(num(t1.add_count_max_stack, 4), n); - if (stacks > 0) c.skill.addAmmo(stacks * num(t1.add_count, 1)); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - crowdAspd(battle, unit, 'excu2:module', num(tm.attack_speed), num(tm['trigger_cnt[equip]'])); - if (sid === 'skchr_excu2_3') { // S3: +attack@atk ATK per bullet spent - battle.on('ammoUsed', (c) => { - if (c.unit !== unit || !unit.skill?.active) return; - battle.addBuff(unit, { key: 'excu2:verdict', refresh: 'stack', maxStacks: Math.max(1, num(bb['attack@max_stack_cnt'], 30)), mods: mods({ atkPct: num(bb['attack@atk']) }) }); - }, { owner: unit }); - } - if (sid && sid !== 'skchr_excu2_2') return; - // S2: melee attacks dodged with `prob`, each dodge refills ammo - battle.on('hit', (c) => { - if (c.target !== unit || !unit.skill?.active || !c.source || c.source.side !== 'enemy' || !c.dmg.isAttack) return; - if (!meleeAttacker(c.source, unit) || !(prob > 0) || !battle.rng.chance(prob)) return; - c.dmg.cancel = true; - battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); - // a dodge like the engine's (listeners of the `dodge` hook — items/bonds — see it too) - if (battle.hasHook('dodge')) battle.emit('dodge', { source: c.source, target: unit, dmg: c.dmg }); - unit.skill.addAmmo(refill); - }, { owner: unit }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 缇缇 — S3 旧日绽放 (25 s): ATK +, 2 targets, sleeps non-sleeping targets; during the skill every enemy waking up (or - // knocked out asleep) takes arts damage growing with the sleep time (linear min_atk_scale → max_atk_scale over `sleep` s, - // PRTS 备注) and sends the highest-aggro enemy within range_radius to sleep; other allies in range fall asleep (小睡) - // instead of dying. T1 凝固的时光: can attack sleeping enemies, +15 % ATK arts vs non-moving - // enemies, sleeping enemies take 30 % ATK arts/s. T2 勇气的报偿: Sargon/Minos ops above 50 % HP get +20 ASPD. - // S1 缓蚀 (duration): ATK +, attack@prob chance per attack to sleep the target attack@sleep s. - // S2 封护 (duration): no attacks, ATK +; she and the lowest-HP-ratio operator in her range sleep until the skill ends - // (沉睡 = invulnerable), enemies around either of them keep falling asleep; T1 ×talent_scale. - chess_char_5_02_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1); - const sid = selectedId(chess, def); - const hitSleep = num(bb['attack@sleep'], num(bb.sleep, 5)); - const minS = num(bb.min_atk_scale, 1), maxS = num(bb.max_atk_scale, minS); - const chainSleep = num(bb.sleep, hitSleep), radius = num(bb.range_radius, 1.5), chainN = Math.max(1, num(bb.max_target, 1)); - // S2 封护: "第一天赋效果提升至N倍" while it runs - const t0Scale = (unit) => (sid === 'skchr_titi_2' && unit.skill?.active ? num(bb.talent_scale, 1) : 1); - const ward = (battle, unit, a) => { - const entered = !a.buffs.some((b) => (b.status ?? b.key) === 'sleep'); - battle.addBuff(a, { key: 'titi:ward', flags: { sleep: true }, visible: true, status: 'sleep', data: { src: unit } }); - battle.releaseBlocked(a); - if (battle.hasHook('statusApplied')) battle.emit('statusApplied', { source: unit, target: a, status: 'sleep', duration: Infinity, entered }); - }; - return { - skills: lazySkills({ - skchr_titi_1: () => ({ - kind: 'duration', mods: mods({ atkPct: num(bb.atk) }), - attack: { - onHit({ battle, unit, target }) { - if (!target || !target.alive || target.s.flags.sleep || !battle.rng.chance(num(bb['attack@prob']))) return; - battle.applyStatus(target, 'sleep', { duration: num(bb['attack@sleep']), source: unit }); - }, - }, - }), - skchr_titi_2: () => ({ - kind: 'duration', mods: mods({ atkPct: num(bb.atk) }), - attack: { noAttack: true }, - onStart({ battle, unit }) { - const pick = battle.alliesInGrid(unit).filter((a) => a !== unit && isOp(a) && a.hp > 0) - .sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0] ?? null; - unit.mem.titiWard = pick ? [unit, pick] : [unit]; - for (const a of unit.mem.titiWard) ward(battle, unit, a); - unit.mem.titiWardAcc = AURA_IV; - battle.fx('sleepGuard', { x: unit.x, y: unit.y, id: unit.id }); - }, - onTick({ battle, unit, dt }) { - unit.mem.titiWardAcc += dt; - if (unit.mem.titiWardAcc < AURA_IV - 1e-9) return; - unit.mem.titiWardAcc = 0; - for (const a of unit.mem.titiWard || []) { - if (!a.alive || !a.deployed || !a.findBuff('titi:ward')) continue; - for (const e of battle.foesInRadius(a.x, a.y, RING1)) if (e.alive) battle.applyStatus(e, 'sleep', { duration: AURA_DUR, source: unit }); - } - }, - onEnd({ battle, unit }) { - for (const a of unit.mem.titiWard || []) { - const b = a.findBuff('titi:ward'); - if (b && b.data.src === unit) battle.removeBuff(a, b); - } - unit.mem.titiWard = null; - }, - }), - }), - trait: { hitSleep: true }, // 凝固的时光 (a): she targets and damages sleeping enemies (沉睡 = 无敌 for everyone else) - skill: { - kind: 'duration', - mods: mods({ atkPct: num(bb.atk) }), - attack: { - maxTargets: Math.max(1, num(bb['attack@max_target'], 2)), - onHit({ battle, unit, target }) { - if (!target || !target.alive || target.s.flags.sleep) return; - battle.applyStatus(target, 'sleep', { duration: hitSleep, source: unit }); - }, - }, - onEnd({ battle, unit }) { - for (const a of battle.allyUnits) { - const b = a.findBuff('titi:allySleep'); - if (b && b.data.src === unit) battle.removeBuff(a, b); - } - }, - }, - talents: [ - { install(battle, unit) { // 凝固的时光 — (a) is the `hitSleep` trait above - // (b) +extra_atk_scale arts vs non-moving enemies - battle.on('damaged', (c) => { - if (c.source !== unit || !c.dmg?.isAttack || c.dmg.isSplash || c.type === 'element') return; - const e = c.target; - if (e.side !== 'enemy' || !e.alive || !isStill(e)) return; - battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.extra_atk_scale) * t0Scale(unit), type: 'arts', tags: ['talent', 'titiStill'] }); - }, { owner: unit }); - // (c) sleeping enemies take damage_atk_scale × ATK arts per second - whileOn(battle, unit, 1, () => { - const amount = unit.s.atk * num(t0.damage_atk_scale) * t0Scale(unit); - for (const e of battle.enemies) { - if (e.alive && !e.hidden && e.s.flags.sleep) battle.dealDamage(unit, e, { amount, type: 'arts', tags: ['talent', 'titiDream'] }); - } - }); - } }, - { install(battle, unit) { // 勇气的报偿: 精力充沛 - whileOn(battle, unit, AURA_IV, () => { - for (const a of battle.allies()) { - if (isOp(a) && isSargonMinos(a) && a.hpRatio > num(t1.hp_ratio, 0.5)) battle.addBuff(a, { key: 'titi:vigor', duration: AURA_DUR, mods: { aspd: num(t1.attack_speed) } }); - } - }); - } }, - ], - install(battle, unit) { - if (sid && sid !== 'skchr_titi_3') return; - // S3: wake-up burst chain (sleep applied by anyone counts) - const asleep = new Map(); - // PRTS 备注: "向1.5半径内除该单位外仇恨值最高的敌方单位施加沉睡" — highest aggro (taunt) first, then the nearest - const sleepOthers = (from) => { - const cands = battle.foesInRadius(from.x, from.y, radius).filter((o) => o !== from && !o.s.flags.sleep && !o.mem?.candleOwner) - .sort((a, b) => (b.s.taunt || 0) - (a.s.taunt || 0) || dist(a, from) - dist(b, from) || a.spawnSeq - b.spawnSeq); - let n = 0; - for (const o of cands) { - if (n >= chainN) break; - if (battle.applyStatus(o, 'sleep', { duration: chainSleep, source: unit })) n++; - } - }; - battle.on('statusApplied', (c) => { - if (c.status === 'sleep' && c.target.side === 'enemy' && !asleep.has(c.target)) asleep.set(c.target, battle.time); - }, { owner: unit }); - battle.on('tick', () => { - if (!asleep.size) return; - for (const [e, t0s] of asleep) { - if (!e.alive) { asleep.delete(e); continue; } - if (e.s.flags.sleep) continue; - asleep.delete(e); - if (!unit.skill?.active || !on(unit)) continue; - const scale = minS + (maxS - minS) * Math.min(1, (battle.time - t0s) / Math.max(0.1, chainSleep)); - battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'titiWake'] }); - battle.fx('wake', { x: e.x, y: e.y, id: e.id, scale: Math.round(scale * 100) / 100 }); - sleepOthers(e); - } - }, { owner: unit }); - battle.on('kill', (c) => { - const v = c.victim; - if (v.side !== 'enemy' || !asleep.has(v)) return; - asleep.delete(v); - if (unit.skill?.active && on(unit)) sleepOthers(v); - }, { owner: unit }); - // S3: other allies in range fall asleep instead of dying, until healed to full or the skill ends - battle.on('fatal', (c) => { - const a = c.unit; - if (c.prevented || a.side !== 'ally' || a === unit) return; - const guard = a.findBuff('titi:allySleep'); - if (guard) { if (guard.data.src === unit) c.prevented = true; return; } - if (!isOp(a) || !unit.skill?.active || !on(unit) || !unit.rangeKeySet?.has(a.tileR * COLS + a.tileC)) return; - c.prevented = true; - const entered = !a.buffs.some((b) => (b.status ?? b.key) === 'sleep'); - battle.addBuff(a, { - key: 'titi:allySleep', flags: { sleep: true }, visible: true, status: 'sleep', data: { src: unit }, interval: 0.2, - onTick: ({ unit: x, buff }) => { if (x.hp >= x.s.maxHp - 1e-6) battle.removeBuff(x, buff); }, - }); - // other content (特质 "干员进入沉睡时") observes it like any sleep status - if (battle.hasHook('statusApplied')) battle.emit('statusApplied', { source: unit, target: a, status: 'sleep', duration: Infinity, entered }); - battle.fx('sleepGuard', { x: a.x, y: a.y, id: a.id }); - }, { owner: unit, priority: 5 }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 烛煌 — S3 众恶的焚场 (ammo): skill range 4-11 (the targets; the DEFAULT cast still needs an enemy in 3-1), ATK +, - // BAT −1.3 s, "攻击变为群体攻击" = one target + a BLAZE_S3_SPLASH (1.7) splash around it (PRTS 备注 "攻击溅射半径1.7", - // 中点判定 — so the fire reaches past the diamond; it used to hit every enemy inside it instead, community report E1 - // after 0.1.0), +attack@atk_scale ATK elemental damage to every enemy of the attack in a burn burst (main and splash), - // dealt BEFORE the attack's own damage ("于攻击造成伤害前判定元素爆发并造成元素伤害": a 'hit' hook — so a hit that kills - // or starts the burst keeps / does not get it); loses 3 % max HP/s; any burn burst on the field refills ammo_recover - // bullets, never above the skill's ammo ("补充后的弹药数量无法超过上限"). Each landed bolt shows its splash (fx 'splash' r 1.7). - // T1 熔点引爆: burn burst anywhere → 350 % ATK elemental damage to it + heal 12 % max HP. T2 绝处重燃: downed instead of - // dying (6000 shield, no attack, no heal, 3 %/s regen) → revives at full HP and stuns nearby enemies. - // Module (elite): ×damage_scale vs enemies in an element burst. - // S1 炙手之援 (instant): the operator with the highest max HP in her range carries a fire aura for max_duration s — - // every `interval` s the enemies within range_radius of it take atk_scale × her ATK arts + element_multiplier × that - // damage as 灼燃损伤. S2 沸血燎原 (duration): skill range, BAT +0.9 s, ATK +, 3 targets; each attack costs - // attack@hp_ratio of her max HP and leaves a burning tile under every target: ground enemies on it move −50 % and take - // atk_scale × ATK arts + element_damage_scale × that as 灼燃损伤 per second (the tiles burn until the skill ends - // [ASSUMED: no duration in the data]). Module PRI-Y (elite): SP +0.2/s while an enemy in range is in an element burst. - chess_char_5_03_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1); - const sid = selectedId(chess, def); - const lose = num(bb.lose_hp_scale); - const burnTiles = (battle, unit) => { - const tiles = unit.mem.blazeTiles; - if (!tiles || !tiles.size) return; - unit.mem.blazeAcc2 = (unit.mem.blazeAcc2 ?? 0) + AURA_IV; - const dmgTick = unit.mem.blazeAcc2 >= 1 - 1e-9; - if (dmgTick) unit.mem.blazeAcc2 -= 1; - for (const e of battle.enemies) { - if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, tiles)) continue; - battle.addBuff(e, { key: `blaze2:ground:${unit.id}`, duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + num(bb.move_speed)) } }); - if (!dmgTick) continue; - const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeGround'] }); - if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_damage_scale), tags: ['skill', 'blazeGround'] }); - } - }; - return { - skills: lazySkills({ - skchr_blaze2_1: () => ({ - kind: instantKind(chess, def), - onStart({ battle, unit }) { - const t = battle.alliesInGrid(unit).filter((a) => isOp(a)).sort((a, b) => b.s.maxHp - a.s.maxHp || a.id - b.id)[0]; - if (!t) return; - const r = num(bb.range_radius, 1.5); - battle.addBuff(t, { - key: `blaze2:aid:${unit.id}`, duration: num(bb.max_duration, 20), interval: Math.max(0.1, num(bb.interval, 1)), visible: true, data: { src: unit }, - onTick: ({ unit: x }) => { - for (const e of battle.foesInRadius(x.x, x.y, r)) { - const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeAid'] }); - if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_multiplier), tags: ['skill', 'blazeAid'] }); - } - }, - }); - battle.fx('aoe', { x: t.x, y: t.y, id: unit.id, r, skill: 'blaze2Aid' }); - }, - }), - skchr_blaze2_2: () => ({ - kind: 'duration', - mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), - targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 3)) }), - attack: { - onEachHit({ unit, target }) { - if (target && target.side === 'enemy') unit.mem.blazeTiles?.add(Math.round(target.y) * COLS + Math.round(target.x)); - }, - }, - onStart({ unit }) { unit.mem.blazeTiles = new Set(); unit.mem.blazeAcc2 = 0; }, - onAttack({ battle, unit }) { battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio']), { source: unit }); }, - onEnd({ unit }) { unit.mem.blazeTiles = null; }, - }), - }), - skill: { - kind: 'ammo', ammo: num(bb['attack@trigger_time'], 18), - mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - attack: { - splashRadius: BLAZE_S3_SPLASH, - // the fire of each landed bolt (the screen rings splash attacks by sub-profession; 本源术师 is not one): once - // per attack at the main target / its spot — from the profile it was fired with, so the last bolt too - onHit({ battle, unit, target, x, y }) { - battle.fx('splash', { x, y, ...(target && target.alive ? { id: target.id } : {}), src: unit.id, r: BLAZE_S3_SPLASH, element: 'burn' }); - }, - }, - onStart({ unit }) { unit.mem.blazeAcc = 0; }, - onTick({ battle, unit, dt }) { - if (!(lose > 0)) return; - unit.mem.blazeAcc = (unit.mem.blazeAcc ?? 0) + dt; - while (unit.mem.blazeAcc >= 1 && unit.alive) { - unit.mem.blazeAcc -= 1; - battle.loseHp(unit, unit.s.maxHp * lose, { source: unit }); - } - }, - }, - talents: [ - { install(battle, unit) { // 熔点引爆 - battle.on('elementBurst', (c) => { - if (c.element !== 'burn' || !on(unit) || c.target.side !== 'enemy') return; - elementHit(battle, unit, c.target, unit.s.atk * num(t0.ep_damage_scale), 'blazeMelt', 'burn'); - // 绝处重燃: "倒地期间…无法被治疗" — the meltdown heal does not reach her while downed - if (!unit.mem.downed) battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio), { self: true }); - battle.fx('meltdown', { x: c.target.x, y: c.target.y, id: c.target.id }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 绝处重燃 - battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.downed = false; }, { owner: unit }); - battle.on('fatal', (c) => { - if (c.unit !== unit || c.prevented || unit.mem.downed) return; - c.prevented = true; - unit.mem.downed = true; - if (unit.skill?.active) unit.skill.end('downed'); - battle.releaseBlocked(unit); - const dep = unit.deploySeq; - battle.addBuff(unit, { - key: 'blaze2:downed', visible: true, shield: num(t1.dynamic), interval: 0.2, - flags: { disarm: true, noHeal: true }, - mods: mods({ hpRegenRatio: num(t1.hp_recovery_per_sec_by_max_hp_ratio), blockCnt: -99 }), - onTick: ({ unit: u, buff }) => { - if (u.deploySeq !== dep || u.hp < u.s.maxHp - 1e-6) return; - battle.removeBuff(u, buff); - u.mem.downed = false; - for (const e of battle.foesInRadius(u.x, u.y, NEARBY_RADIUS)) if (e.alive) battle.applyStatus(e, 'stun', { duration: num(t1.stun), source: u }); - battle.fx('revive', { x: u.x, y: u.y, id: u.id, r: NEARBY_RADIUS }); - }, - }); - battle.fx('downed', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit, priority: -50 }); - } }, - ], - install(battle, unit) { - burstDamageUp(battle, unit, num(tb.damage_scale)); - burstSpUp(battle, unit, 'blaze2:module', num(tm.sp_recovery_per_sec)); - if (sid === 'skchr_blaze2_2') whileOn(battle, unit, AURA_IV, () => burnTiles(battle, unit)); - if (sid && sid !== 'skchr_blaze2_3') return; - // S3: each hit of her skill attack (main and splash) on an enemy in a burn burst deals the bonus first (PRTS 备注). - // Gated on the attack's own isSkill (captured when the bolt was fired), not on the live skill: her bolts land after - // the last bullet's end('ammo') / an early end (downed), and those still carry it. Only S3 runs here (sid gate). - battle.on('hit', (c) => { - const d = c.dmg, t = c.target; - if (c.source !== unit || !d.isAttack || !d.isSkill || d.cancel) return; - if (t.side === 'enemy' && t.alive && t.findBuff('burnBurst')) elementHit(battle, unit, t, unit.s.atk * num(bb['attack@atk_scale']), 'blazeBurn', 'burn'); - }, { owner: unit }); - const maxAmmo = num(bb['attack@trigger_time'], 18); - battle.on('elementBurst', (c) => { // S3: burn bursts refill ammo, up to the skill's ammo - const sk = unit.skill; - if (c.element !== 'burn' || !sk?.active || sk.kind !== 'ammo' || !on(unit)) return; - const n = Math.min(num(bb.ammo_recover), maxAmmo - sk.ammoLeft); - if (n > 0) sk.addAmmo(n); - }, { owner: unit }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 华法琳 — S1 紧急包扎 (charges, attack SP): the next heal on a target below half HP adds hp_ratio × its max HP. - // T1 血液样本回收: an enemy dying in range → +2 SP to herself and to a random ally in range. - // Module (elite): heals on allies below 50 % ×1.15. - chess_char_5_04_a: (bb, chess) => { - const t0 = talent(chess, 0), tb = traitBb(chess); - const ratio = num(bb.hp_ratio); - return { - skill: { - kind: 'charges', heal: true, trigger: NEVER, - onStart({ unit }) { unit.mem.bldskBonus = unit.mem.bldskTarget ?? null; }, - }, - talents: [{ install(battle, unit) { // 血液样本回收 - battle.on('kill', (c) => { - const v = c.victim; - if (v.side !== 'enemy' || !on(unit) || !inRange(unit, v)) return; - unit.skill?.gainSp(num(t0['bldsk_t_1[self].sp']), 'talent'); - const others = battle.alliesInGrid(unit).filter((a) => a !== unit && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive'); - const pick = battle.rng.pick(others); - if (pick) pick.skill.gainSp(num(t0['bldsk_t_1[rand].sp']), 'talent'); - }, { owner: unit }); - } }], - install(battle, unit) { - // the skill only fires for a heal target below half HP: checked right before the heal - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit) return; - unit.mem.bldskNoSp = false; - const sk = unit.skill; - if (!sk || !sk.ready || unit.s.flags.silence) return; - const t = c.targets[0]; - if (!t || t.side !== 'ally' || !(t.hpRatio < HALF_HP)) return; - unit.mem.bldskTarget = t; - // the heal made by the skill recovers no attack SP (engine rule for skill attacks: cost N ⇒ every N+1 heals) - if (sk.activate('DEFAULT')) unit.mem.bldskNoSp = true; - unit.mem.bldskTarget = null; - }, { owner: unit }); - battle.on('spGain', (c) => { - if (c.unit !== unit || c.reason !== 'attack' || !unit.mem.bldskNoSp) return; - unit.mem.bldskNoSp = false; - c.amount = 0; - }, { owner: unit, priority: 100 }); - battle.on('heal', (c) => { // merged into the same heal - if (c.source !== unit || !unit.mem.bldskBonus || c.target !== unit.mem.bldskBonus) return; - unit.mem.bldskBonus = null; - // the bonus is part of the same heal: the healer's and the target's healing multipliers apply to it too - c.amount += c.target.s.maxHp * ratio * num(unit.s.healingDealtMul, 1) * num(c.target.s.healingTakenMul, 1); - battle.fx('healAoe', { x: c.target.x, y: c.target.y, id: c.target.id, r: 0.5 }); - }, { owner: unit, priority: 10 }); - lowHpHealUp(battle, unit, tb); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 乌尔比安 — S3 必须开辟的通路 (25 s, CUSTOM_RANGE row ahead): max HP/ATK +, throws an anchor forward that stops on the - // first enemy or at max distance — on his own tile while he blocks: 135 % ATK phys + 6 s stun around it - // (projectile_range); moves onto the anchor tile when deployable and not his own (a 从不混淆的方向 marker keeps his tile) - // and returns at skill end — both 【移动】, i.e. free redeploys (Battle.moveRedeploy; the return empties his SP). - // T1 本性的坚守: heal 100 (160 below 50 %) on every hit taken. T2 血脉的哺养: per kill +120 max HP / +30 ATK (×9), - // other Abyssal Hunters +50 %. Module (elite): healing received ×1.2. - // S1 必须促成的接触 (instant): the anchor lands on the best enemy of the skill range (beyond his own range, unblocked - // first) and drags up to max_target enemies around it (RING1) in front of him (中等力度), atk_scale × ATK phys each; - // a 捕网 — ground enemies only ([ASSUMED] like 雪雉's "不对空" net). S3's anchor blast has no such note: it hits air - // units too [ASSUMED]. - // S2 必须维系的界限 (toggle, 持续时间无限): T1 ×talent_scale, block +1, max HP +, ATK +. - chess_char_5_05_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); - const sid = selectedId(chess, def); - const t0Scale = sid === 'skchr_ulpia_2' ? num(bb.talent_scale, 1) : 1; - const tokenId = chess?.skill?.overrideTokenKey ?? def?.skill?.raw?.overrideTokenKey ?? 'token_10039_ulpia_block'; - const trig = def?.skill?.trigger?.grid ?? chess?.skill?.trigger?.customRangeGrid ?? [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6]]; - const reach = Math.max(1, ...trig.filter((p) => p[0] === 0).map((p) => p[1])); - const radius = num(bb.projectile_range, 1.5); - return { - skills: lazySkills({ - skchr_ulpia_1: () => ({ - kind: instantKind(chess, def), - onStart({ battle, unit }) { - // "向前方扔出船锚": the anchor goes beyond his own reach first (pulling what he already hits is moot), then - // unblocked ground enemies, then the usual target order - const list = enemiesInGrid(battle, unit, skillGrid(chess, def) ?? unit.rangeGrid); - const reach = (e) => inRange(unit, e); - // the anchor is a 捕网 (PRTS 备注 "实际效果为捕网而非拖拽"); 雪雉's 捕网 is "不对空" (PRTS 雪雉 备注) — [ASSUMED] the same - // for this one: it never lands on or catches air units (FLY, 近地悬浮, 浮空) - const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying); - if (!main) return; - const near = battle.foesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep) - .sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq) - .slice(0, Math.max(1, num(bb.max_target, 2))); - const force = num(bb.force, 1); - battle.fx('anchor', { x: main.x, y: main.y, id: unit.id, fromX: unit.x, fromY: unit.y, r: RING1 }); - for (const e of near) { - // "中等力度地拖拽至面前": the 捕网's pull uses the 拖拽 rules (PRTS 推与拉 §捕网 "力的大小:同拖拽") — - // Battle.pullToFront, the official 力度 − 重量 pull - battle.pullToFront(e, unit, force); - if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchorPull'] }); - } - }, - }), - skchr_ulpia_2: () => ({ - kind: 'toggle', - mods: mods({ atkPct: num(bb.atk), hpPct: num(bb.max_hp), blockCnt: num(bb.block_cnt) }), - }), - }), - skill: { - kind: 'duration', - mods: mods({ hpPct: num(bb.max_hp), atkPct: num(bb.atk) }), - onStart({ battle, unit }) { - // PRTS 备注 ② — the anchor's target: his own tile while he blocks an enemy (e.g. just after a 突袭 landing), - // else the nearest tile ahead in the skill range (straight along his direction) with an enemy on it, else the - // farthest one. His own tile is a candidate only while he blocks ("自身所在地块(仅阻挡敌人时)"); not taken: the - // reading of the range's own tile (6-1 starts at [0,0]) as distance 0 for the second rule (a flyer over him) - let stop = 0; - if (!unit.blocking.some((e) => e.alive && e.blockedBy === unit)) { - for (let d = 1; d <= reach; d++) { - const [r, c] = frontOf(unit.tileR, unit.tileC, unit.dir, d); - // the anchor stops at the field edge and in front of a ground obstacle (crates, roadblocks) - if (!battle.grid.inRect(r, c) || battle.grid.isObstacle(r, c)) break; - stop = d; - if (battle.enemiesInKeys([r * COLS + c], unit, { canHitFly: true }).length) break; - } - } - const [sr, sc] = frontOf(unit.tileR, unit.tileC, unit.dir, stop); - const fromX = unit.x, fromY = unit.y; - battle.fx('anchor', { x: sc, y: sr, id: unit.id, fromX, fromY, r: radius }); - for (const e of battle.foesInRadius(sc, sr, radius)) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchor'] }); - if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit }); - } - // ③ 【移动】 — only a change of tile moves him and leaves the 从不混淆的方向 ("若目标地块不为当前地块,会在原地部署"): an - // anchor on his own tile leaves him where he stands, no marker, nothing to return from [ASSUMED: the "tile one - // beyond the landing" is not tried when the landing is his own tile] - if (stop === 0 || !unit.alive) return; - // PRTS 备注: landing tile > the tile one beyond it > his own tile (a deployable, free, unreserved melee tile) - const ok = ([r, c]) => (r !== unit.tileR || c !== unit.tileC) && battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c); - const dest = [[sr, sc], frontOf(unit.tileR, unit.tileC, unit.dir, stop + 1)].find(ok); - if (dest == null) return; - const home = [unit.tileR, unit.tileC]; - // the 【移动】 is a redeploy on the new tile (Battle.moveRedeploy: a new deployment, deploy effects fire again, - // no exit) that keeps the running skill — "【移动】后仅继承下列效果:技能进度、第二天赋叠加层数"; the rest of - // his buffs are kept too (owner's deviation, DESIGN §22.3). The marker is deployed after the move (备注 ③) - if (!battle.moveRedeploy(unit, dest[0], dest[1]) || !unit.alive || !unit.skill?.active) return; - const marker = battle.spawnToken(unit, tokenId, home[0], home[1], { untargetable: true, kit: { skill: null, trait: { noAttack: true } } }); - unit.mem.anchorHome = { r: home[0], c: home[1], marker }; - battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); - }, - onEnd({ battle, unit }) { - const h = unit.mem.anchorHome; - unit.mem.anchorHome = null; - if (!h) return; - if (h.marker && h.marker.alive) battle.retreat(h.marker, { reason: 'expired', permanent: true }); - if (unit.alive && unit.deployed) { - // ④ 【返回】: a 【移动】 back to his tile "【返回】时将清空技力,但仍可以享受后续由其他效果提供的技力" — the SP is - // emptied before the deploy effects of the return run (迅捷作战粮, 黄沙罗盘 … still give theirs, so do skillEnd - // effects after it: 迅捷). Here in onEnd the skill's mods are still on (skills.js end) [ASSUMED: officially the - // return comes "技能结束后"; none of the deploy effects that can reach him reads his stats] - const fromX = unit.x, fromY = unit.y; - if (battle.moveRedeploy(unit, h.r, h.c, { clearSp: true })) battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); - } - }, - }, - talents: [ - { install(battle, unit) { // 本性的坚守 - battle.on('damaged', (c) => { - if (c.target !== unit || !on(unit) || unit.hp <= 0 || !(c.amount > 0) || c.type === 'element' || isHpLoss(c.dmg)) return; // (not a 流失) - const v = (unit.hpRatio < num(t0.hp_ratio, 0.5) ? num(t0.value2) : num(t0.value1)) * (unit.skill?.active ? t0Scale : 1); - if (v > 0) battle.heal(unit, unit, v, { self: true }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 血脉的哺养 - battle.on('kill', (c) => { - if (c.killer !== unit || c.victim.side !== 'enemy' || !on(unit)) return; - battle.addBuff(unit, { key: 'ulpia:blood', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 9)), mods: mods({ hpFlat: num(t1.max_hp), atkFlat: num(t1.atk) }) }); - const share = mods({ hpFlat: num(t1['ulpia_t_1[abyssal].max_hp']), atkFlat: num(t1['ulpia_t_1[abyssal].atk']) }); - if (!Object.keys(share).length) return; - for (const a of battle.allies()) { - if (a !== unit && isOp(a) && isAbyssal(a)) battle.addBuff(a, { key: 'ulpia:bloodShare', refresh: 'stack', maxStacks: Math.max(1, num(t1['ulpia_t_1[abyssal].max_stack_cnt'], 9)), mods: share }); - } - }, { owner: unit }); - } }, - ], - install(battle, unit) { - if (num(tb.heal_scale) > 0) permBuff(battle, unit, 'ulpia:module', { healingTakenMul: num(tb.heal_scale) }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 隐德来希 — S3 灵与欲的惜别 (20 s): skill range, ATK +, ASPD +100; summons a 心烛 for up to 3 highest-HP ground enemies - // in range (60 % of their current HP, same DEF/RES); his hits on a candle deal at least 35 % ATK and the original loses - // the same HP; only he can damage candles (other allies' attacks are redirected to the original). - // T1 萃血: each hit steals 75 max HP (≤ 1350) and applies 200 arts/s for 5 s. T2 重盈: once below 25 % HP, heal 50 % - // max HP and take −10 % phys damage afterwards. Reaper trait (heal per hit, module 60) comes from the profession. - // S1 玫影觅迹 (instant, attack SP): next attack atk_scale × ATK, twice. S2 绯红壁合 (duration): no attacks; blood sickles - // on herself and on one other ground unit (the ally with the most enemies around it) cut every enemy around them (RING1) - // for atk_scale × ATK phys every `interval` s — ground enemies only unless the carrier has taken off (PRTS 备注). - // Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range. - chess_char_5_06_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); - const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1); - const nCandles = Math.max(1, num(bb['attack@max_target'], 3)); - const candleKey = talentRec(chess, 2)?.bbStr?.take_extra_enemy_key ?? CANDLE_KEY; - const aroundN = (battle, a) => battle.foesInRadius(a.x, a.y, RING1).length; - return { - skills: lazySkills({ - skchr_etlchi_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), hits: 2 } }), - skchr_etlchi_2: () => ({ - kind: 'duration', attack: { noAttack: true }, - onStart({ battle, unit }) { - const other = battle.alliesFor(unit).filter((a) => a !== unit && a.ground && a.hp > 0) - .sort((a, b) => aroundN(battle, b) - aroundN(battle, a) || dist(a, unit) - dist(b, unit) || a.id - b.id)[0] ?? null; - unit.mem.sickles = other ? [unit, other] : [unit]; - unit.mem.sickleAcc = 0; - for (const a of unit.mem.sickles) battle.fx('aoe', { x: a.x, y: a.y, id: unit.id, r: RING1, skill: 'etlchiSickle' }); - }, - onTick({ battle, unit, dt }) { - const iv = Math.max(0.1, num(bb.interval, 0.5)); - unit.mem.sickleAcc += dt; - while (unit.mem.sickleAcc >= iv - 1e-9) { - unit.mem.sickleAcc -= iv; - for (const a of unit.mem.sickles || []) { - if (!a.alive || !a.deployed) continue; - // PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空); - // 起飞 = an airborne skywalker (蒂比's skills: flag `liftoff`; still a 地面单位, so she can carry one) - const air = !!a.s.flags.liftoff; - for (const e of battle.foesInRadius(a.x, a.y, RING1)) { - if (e.isFlying && !air) continue; - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] }); - } - } - } - }, - onEnd({ unit }) { unit.mem.sickles = null; }, - }), - }), - skill: { - kind: 'duration', - mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - onStart({ battle, unit }) { - unit.mem.candles = []; - const picks = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false }) - .filter((e) => !e.isFlying && !e.mem?.candleOwner && !e.isBoss) - .sort((a, b) => b.hp - a.hp || a.spawnSeq - b.spawnSeq).slice(0, nCandles); - for (const e of picks) { - const r = Math.round(e.y), c = Math.round(e.x); - // 心烛 is not in data/enemies.json: an inline record (neutral, never attacks, never moves, not counted) - const def = { - name: '心烛', rank: 'NORMAL', applyWay: 'NONE', motion: 'WALK', notCountInTotal: true, lifePointReduce: 0, - stats: { maxHp: Math.max(1, e.hp * hpScale), atk: 0, def: e.s.def * defScale, magicResistance: e.s.res * resScale, moveSpeed: 0, massLevel: num(e.base.massLevel, 1) }, - }; - const cd = battle.spawnEnemy(candleKey, { - def, pos: [e.y, e.x], route: { motion: 'WALK', start: [r, c], end: [r, c], checkpoints: [] }, - countInTotal: false, ownerPlayerId: e.ownerId, - }); - if (!cd) continue; - cd.name = '心烛'; - Object.assign(cd.base, { maxHp: Math.max(1, e.hp * hpScale), atk: 0, def: e.s.def * defScale, res: e.s.res * resScale, moveSpeed: 0 }); - cd.profile = { ...(cd.profile || {}), noAttack: true }; - cd.markDirty(); - cd.hp = cd.s.maxHp; - cd.mem.candleOwner = unit; - cd.mem.candleOf = e; - cd.mem.noLeak = true; // never a leak at the time limit (Battle._timeout) - battle.addBuff(cd, { key: 'etlchi:candle', flags: { unblockable: true, noMove: true } }); - unit.mem.candles.push(cd); - battle.fx('candle', { x: cd.x, y: cd.y, id: cd.id, of: e.id }); - } - }, - onEnd({ battle, unit }) { - for (const cd of unit.mem.candles || []) if (cd.alive) battle.kill(cd, null); - unit.mem.candles = []; - }, - }, - talents: [ - { install(battle, unit) { // 萃血 - const steal = num(t0['attack@steal_hp']), cap = num(t0['attack@steal_hp_max']); - const dot = num(t0.magic_value), dotDur = num(t0.dot_duration, 5), iv = num(t0.interval, 1); - battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.stolen = 0; }, { owner: unit }); - battle.on('damaged', (c) => { - if (c.source !== unit || !c.dmg?.isAttack || c.type === 'element') return; - const e = c.target; - if (e.side !== 'enemy' || !e.alive || e.mem?.candleOwner || !on(unit)) return; - if (dot > 0) { - battle.addBuff(e, { key: `etlchi:dot:${unit.id}`, duration: dotDur, interval: iv, refresh: 'extend', - onTick: ({ unit: x }) => battle.dealDamage(unit, x, { amount: dot, type: 'arts', tags: ['talent', 'dot'] }) }); - } - const stolen = num(unit.mem.stolen); - // (a boss sharing the match HP pool keeps its max HP: the pool is owned by the match) - const n = Math.min(steal, cap - stolen, Math.floor(e.s.maxHp - 1)); // never below 1 max HP - if (n > 0 && !e.bossPool) { - unit.mem.stolen = stolen + n; - // the victim keeps an explicit running total (a 'stack' refresh would rescale every stack by the last n) - const key = `etlchi:steal:${unit.id}`; - const lost = num(e.findBuff(key)?.data?.total) + n; - const hp0 = e.hp; - battle.addBuff(e, { key, mods: { hpFlat: -lost }, data: { total: lost } }); - // 偷取生命上限: the victim's current HP only drops where it exceeds the new maximum (the engine keeps ratios) - if (e.alive) e.hp = Math.min(hp0, e.s.maxHp); - battle.addBuff(unit, { key: 'etlchi:gain', mods: { hpFlat: unit.mem.stolen } }); - } - }, { owner: unit }); - } }, - { install(battle, unit) { // 重盈 - battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.reborn = false; }, { owner: unit }); - battle.on('damaged', (c) => { - if (c.target !== unit || unit.mem.reborn || !on(unit) || unit.hp <= 0 || !(unit.hpRatio < num(t1.hp_ratio))) return; - unit.mem.reborn = true; - battle.heal(unit, unit, unit.s.maxHp * num(t1['etlchi_t_2[heal].hp_ratio']), { self: true }); - if (num(t1.damage_resistance) > 0) battle.addBuff(unit, { key: 'etlchi:reborn', mods: { physTakenMul: 1 - num(t1.damage_resistance) } }); - battle.fx('reborn', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - crowdAspd(battle, unit, 'etlchi:module', num(tb.attack_speed), num(tb.cnt)); - const mine = (e) => e && e.mem && e.mem.candleOwner === unit; - // "心烛只受隐德来希攻击的影响": other sources' element gauges never fill a candle either - battle.on('elementHit', (c) => { if (mine(c.target) && c.source !== unit) c.dmg.cancel = true; }, { owner: unit }); - battle.on('beforeStatus', (c) => { if (mine(c.target) && c.source !== unit) c.cancel = true; }, { owner: unit }); - battle.on('hit', (c) => { - if (!mine(c.target)) return; - if (c.source !== unit) { c.dmg.cancel = true; return; } - if (c.dmg.type !== 'phys' && c.dmg.type !== 'arts') return; - const ss = unit.s; - const mit = mitigate(c.dmg.amount, c.dmg.type, c.target.s, { - defIgnorePct: c.dmg.defIgnorePct + ss.defIgnorePct, defIgnoreFlat: c.dmg.defIgnoreFlat + ss.defIgnoreFlat, - resIgnorePct: c.dmg.resIgnorePct + ss.resIgnorePct, resIgnoreFlat: c.dmg.resIgnoreFlat + ss.resIgnoreFlat, - }); - const floor = ss.atk * CANDLE_MIN_ATK; - if (mit < floor) { c.dmg.type = 'true'; c.dmg.amount = floor; c.dmg.canDodge = false; } - }, { owner: unit, priority: -20 }); - battle.on('damaged', (c) => { - if (!mine(c.target) || !(c.amount > 0) || c.type === 'element') return; - const o = c.target.mem.candleOf; - if (o && o.alive) battle.loseHp(o, c.amount, { source: unit }); - }, { owner: unit }); - battle.on('death', (c) => { // the original fell or leaked: its candle goes out - if (c.unit.side !== 'enemy' || c.unit.mem?.candleOwner) return; - for (const cd of unit.mem.candles || []) if (cd.alive && cd.mem.candleOf === c.unit) battle.kill(cd, null); - }, { owner: unit }); - battle.on('beforeAttack', (c) => { // other allies hit the original instead of the candle - if (c.attacker === unit || c.attacker.side !== 'ally' || !(unit.mem.candles && unit.mem.candles.length)) return; - if (!c.targets.some(mine)) return; - const out = []; - for (const t of c.targets) { - if (!mine(t)) { if (!out.includes(t)) out.push(t); continue; } - const o = t.mem.candleOf; - if (o && o.alive && !out.includes(o) && !c.targets.includes(o)) out.push(o); - } - c.targets = out; - }, { owner: unit }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 史尔特尔 — S3 黄昏 (toggle, 持续时间无限): full heal, ATK +, range +2, 3 targets, max HP +5000 (flat), HP loss ramping - // to 20 % max HP/s over 60 s. T1 熔火: ignores 20 RES. T2 余烬: lethal damage keeps HP ≥ 1 for 8 s (不死 + 禁疗), then she - // withdraws. - // Module (elite): ASPD +8 while not blocking. - // S1 烈焰魔剑 (instant, attack SP): next attack atk_scale × ATK; a kill refills all SP at once. - // S2 熔核巨影 (duration): ATK +, range +1, 2 targets; an attack that hits a single enemy is ×critical atk_scale. - // Module AFT-Y (elite): the enemies she blocks are 法术脆弱 +10 %. - chess_char_5_07_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); - const sid = selectedId(chess, def); - const iv = Math.max(0.05, num(bb.interval, 0.2)), peak = num(bb.hp_ratio), ramp = Math.max(0.1, num(bb.duration, 60)); - const maxHp = num(bb.max_hp); - return { - skills: lazySkills({ - skchr_surtr_1: () => ({ - kind: instantKind(chess, def), - attack: { - atkScale: num(bb.atk_scale, 1), - onHit({ unit, target }) { - // 将目标击倒则立即恢复所有技力 (the skill is still pending here: the refill is the next charge) - if (target && target.side === 'enemy' && !target.alive && unit.skill) unit.skill.gainSp(unit.skill.spCost, 'skill'); - }, - }, - }), - skchr_surtr_2: () => ({ - kind: 'duration', mods: mods({ atkPct: num(bb.atk) }), - targeting: mods({ rangeExtend: Math.round(num(bb.ability_range_forward_extend)), maxTargets: num(bb['attack@max_target']) }), - }), - }), - skill: { - kind: 'toggle', - mods: mods({ atkPct: num(bb.atk), ...(Math.abs(maxHp) > 5 ? { hpFlat: maxHp } : { hpPct: maxHp }) }), - targeting: mods({ rangeExtend: Math.round(num(bb.ability_range_forward_extend)), maxTargets: num(bb['attack@max_target']) }), - onStart({ battle, unit }) { - unit.mem.twilightT = 0; - unit.mem.twilightAcc = 0; - // "立即恢复所有生命" — PRTS 技能3 备注 "(无视禁疗)": it reaches her during 余烬 too - battle.heal(unit, unit, unit.s.maxHp, { self: true, ignoreHealFree: true }); - battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'surtr' }); - }, - onTick({ battle, unit, dt }) { - if (!(peak > 0)) return; - unit.mem.twilightT += dt; - unit.mem.twilightAcc += dt; - while (unit.mem.twilightAcc >= iv && unit.alive) { - unit.mem.twilightAcc -= iv; - const rate = peak * Math.min(1, unit.mem.twilightT / ramp); - if (rate > 0) battle.loseHp(unit, unit.s.maxHp * rate * iv, { source: unit, silent: true }); - } - }, - }, - talents: [ - { install(battle, unit) { permBuff(battle, unit, 'surtr:magma', { resIgnoreFlat: num(t0.magic_resist_penetrate_fixed) }); } }, - { install(battle, unit) { // 余烬 - // PRTS 天赋备注: "持有不死的情况下不会触发此天赋" (a 不死 that prevented the blow first: `c.prevented` — 坚固维式重锤's - // lock, PRIO_REVIVE −100, runs after this −60 on the same first lethal blow [ASSUMED order], so it never starts - // while 余烬 is unused). "触发本天赋后,获得禁疗与不死": 不死 = every later lethal blow is prevented (`mem.ember`); - // 禁疗 (异常效果 HEAL_FREE "无法成为治疗类能力的目标,且受到的治疗量变为0", an HP-regen attribute excepted) = flags - // noHeal (no heal pick, no heal from others) + healFree (her own heals too; S3's start heal "无视禁疗"), shown as - // the status 'healFree' until she leaves. "强制退出战场视为撤回干员": a retreat (Battle.retreat drops the buff) — she - // lies down where she stood and redeploys there, like every operator that leaves the field (PRTS 卫戍协议/帮助 - // "干员退场后…原地留下一个“倒地干员”…自动部署至该位置"; Battle.isDown, GitHub #60). - const wait = num(t1['surtr_t_2[withdraw].interval'], 8); - battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.ember = false; }, { owner: unit }); - battle.on('fatal', (c) => { - if (c.unit !== unit || c.prevented) return; - c.prevented = true; - if (unit.mem.ember) return; - unit.mem.ember = true; - const dep = unit.deploySeq; - battle.addBuff(unit, { key: 'surtr:ember', status: 'healFree', flags: { noHeal: true, healFree: true } }); - battle.fx('ember', { x: unit.x, y: unit.y, id: unit.id }); - battle.after(wait, () => { if (unit.alive && unit.deploySeq === dep) battle.retreat(unit, { reason: 'retreat' }); }, { owner: unit }); - }, { owner: unit, priority: -60 }); - } }, - ], - install(battle, unit) { - const aspd = num(tb.attack_speed); - if (aspd) { - whileOn(battle, unit, 0.2, () => { - if (!unit.blocking.length) battle.addBuff(unit, { key: 'surtr:module', duration: 0.3, mods: { aspd } }); - else battle.removeBuff(unit, 'surtr:module'); - }); - } - const fragile = num(tb.damage_scale, 1) - 1; // AFT-Y: 自身阻挡的敌人受到10%的法术脆弱效果 - if (fragile > 0) { - whileOn(battle, unit, 0.2, () => { - for (const e of unit.blocking) if (e.alive) battle.applyStatus(e, 'artsFragile', { duration: 0.3, value: fragile, source: unit }); - }); - } - if (sid === 'skchr_surtr_2') { // S2: 仅攻击到一个敌人时对其攻击力提升至140% - const solo = num(bb['attack@surtr_s_2[critical].atk_scale'], 1); - battle.on('beforeAttack', (c) => { if (c.attacker === unit) unit.mem.surtrSolo = !!unit.skill?.active && c.targets.filter((t) => t && t.alive).length === 1; }, { owner: unit, priority: -100 }); - battle.on('hit', (c) => { - if (c.source === unit && unit.mem.surtrSolo && unit.skill?.active && c.dmg.isAttack && !c.dmg.isSplash) c.dmg.amount *= solo; - }, { owner: unit }); - } - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 号角 — S3 终极防线 (24 s in total, a two-segment gauge — PRTS 备注: "技能进行到一半时触发额外效果"): ATK +, BAT −1.2 s - // for the first 12 s, then 过载 for the remaining damage_duration (12) s: ATK changes to +50 %, HP loss ramping to 12 % - // max HP/s at the end. T1 军事要塞: all Defenders ATK +20 % while she is on the field. - // T2 血战: once per deployment, lethal damage → full heal, max HP −50 %, ASPD +18, DEF +18 %. - // Module (elite): ×1.1 vs blocked enemies. - // S1 照明榴弹 (instant / 2 charges elite, 自动触发 ⇒ DEFAULT): next attack atk_scale × ATK; a ranged (unblocked) one also - // splashes projectile_range and lights the impact for projectile_delay_time s (enemies within projectile_range lose - // 隐匿). S2 暴风号令 (ammo 10): every attack attack@s2.atk_scale × ATK phys splash; 过载 for the second half of the - // ammo [ASSUMED like S3]: + attack@s2.magic_atk_scale × ATK arts to every enemy hit (manual close never happens in the - // auto battle). Module FOR-Y (elite): ASPD +10 while not blocking. - // S2 and S3 cast with an enemy in range (data DEFAULT, rawRule TAKE_DAMAGE): the owner's deliberate deviation from the - // official 重装 TAKE_DAMAGE row (DESIGN §21.29, tools/build-data.mjs TRIGGER_DEVIATIONS). - chess_char_5_08_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1); - const sid = selectedId(chess, def); - const s2Ammo = Math.max(1, num(bb['attack@s2.trigger_time'], 10)); - const total = Math.max(0.1, num(def?.skill?.duration, num(chess?.skill?.duration, 24))); - const ovAtk = num(bb['horn_s_3[overload_start].atk'], num(bb.atk)); - const ovIv = Math.max(0.05, num(bb['horn_s_3[overload_start].interval'], 0.2)); - const ovPeak = num(bb['horn_s_3[overload_start].hp_ratio']); - const ovRamp = Math.max(0.1, Math.min(total, num(bb['horn_s_3[overload_start].damage_duration'], total / 2))); - const main = total - ovRamp; - const blockedMul = num(tb.atk_scale); - return { - skills: lazySkills({ - skchr_horn_1: () => ({ - kind: instantKind(chess, def), // 自动触发 "下次攻击": the data rule DEFAULT (an AUTO skill takes no 技能策略) - attack: { - atkScale: num(bb.atk_scale, 1), - onHit({ battle, unit, x, y }) { - if (!unit.mem.hornFlare) return; - unit.mem.hornFlare = false; - (unit.mem.flares ??= []).push({ x, y, until: battle.time + num(bb.projectile_delay_time, 6) }); - battle.fx('zone', { x, y, id: unit.id, r: num(bb.projectile_range, 1.7), duration: num(bb.projectile_delay_time, 6) }); - }, - }, - }), - skchr_horn_2: () => ({ - kind: 'ammo', ammo: s2Ammo, - attack: { atkScale: num(bb['attack@s2.atk_scale'], 1) }, - onStart({ unit }) { unit.mem.hornS2Over = false; }, - onEnd({ unit }) { unit.mem.hornS2Over = false; }, - }), - }), - skill: { - kind: 'duration', duration: total, - mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), - onStart({ unit }) { unit.mem.hornT = 0; unit.mem.hornAcc = 0; unit.mem.overload = false; }, - onTick({ battle, unit, dt }) { - unit.mem.hornT += dt; - if (!unit.mem.overload && unit.mem.hornT >= main - 1e-9) { - unit.mem.overload = true; - const delta = ovAtk - num(bb.atk); - battle.addBuff(unit, { key: 'horn:overload', visible: true, mods: mods({ atkPct: delta }) }); - battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); - } - if (!unit.mem.overload || !(ovPeak > 0)) return; - unit.mem.hornAcc += dt; - while (unit.mem.hornAcc >= ovIv && unit.alive) { - unit.mem.hornAcc -= ovIv; - const rate = ovPeak * Math.min(1, (unit.mem.hornT - main) / ovRamp); - if (rate > 0) battle.loseHp(unit, unit.s.maxHp * rate * ovIv, { source: unit, silent: true }); - } - }, - onEnd({ battle, unit }) { unit.mem.overload = false; battle.removeBuff(unit, 'horn:overload'); }, - }, - trait: blockedMul > 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedMul : 1) } : undefined, - talents: [ - { install(battle, unit) { // 军事要塞 - const atk = num(t0.atk); - if (!atk) return; - whileOn(battle, unit, AURA_IV, () => { - for (const a of battle.allies()) if (isOp(a) && a.def?.profession === 'TANK') battle.addBuff(a, { key: 'horn:fortress', duration: AURA_DUR, mods: { atkPct: atk } }); - }); - } }, - { install(battle, unit) { // 血战 - battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.bloodBattle = false; }, { owner: unit }); - battle.on('fatal', (c) => { - if (c.unit !== unit || c.prevented || unit.mem.bloodBattle) return; - c.prevented = true; - unit.mem.bloodBattle = true; - battle.addBuff(unit, { key: 'horn:bloodBattle', visible: true, mods: mods({ hpMul: 1 - num(t1.max_hp), aspd: num(t1.attack_speed), defPct: num(t1.def) }) }); - unit.hp = Math.max(1, unit.hp); - battle.heal(unit, unit, unit.s.maxHp * num(t1.hp_ratio, 1), { self: true }); - battle.fx('bloodBattle', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit, priority: -40 }); - } }, - ], - install(battle, unit) { - const aspd = num(tm.attack_speed); // FOR-Y: 不阻挡敌人时…攻击速度+10 - if (aspd) whileOn(battle, unit, 0.2, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'horn:moduleY', duration: 0.3, mods: { aspd } }); }); - if (sid === 'skchr_horn_1') { - // the pending shot: a ranged (unblocked) attack widens its splash and lights the impact - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit || !unit.skill?.pending || !c.profile) return; - unit.mem.hornFlare = !c.profile._fortressMelee; - if (unit.mem.hornFlare) c.profile.splashRadius = Math.max(num(c.profile.splashRadius), num(bb.projectile_range, 1.7)); - }, { owner: unit, priority: -100 }); - battle.every(AURA_IV, () => { - const fl = unit.mem.flares; - if (!fl || !fl.length) return; - unit.mem.flares = fl.filter((f) => f.until > battle.time + 1e-9); - const r = num(bb.projectile_range, 1.7); - for (const f of unit.mem.flares) for (const e of battle.enemies) { - if (e.alive && !e.hidden && bodyInRadius(e, f.x, f.y, r)) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit }); - } - }, { owner: unit }); - } - if (sid === 'skchr_horn_2') { - const magic = num(bb['attack@s2.magic_atk_scale']); - battle.on('beforeAttack', (c) => { // 过载: the second half of the ammo - if (c.attacker !== unit || !unit.skill?.active || !c.profile) return; - const shot = s2Ammo - num(unit.skill.ammoLeft) + 1; - const over = shot > s2Ammo * HORN_S2_OVERLOAD_AT + 1e-9; - if (over && !unit.mem.hornS2Over) battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id }); - unit.mem.hornS2Over = over; - if (!over || !(magic > 0)) return; - // decided at the shot (a shell still in flight when the skill ends keeps it): the effective profile of this - // attack (a per-attack copy while the skill runs) carries the extra arts splash on every enemy it hits - const prev = c.profile.onEachHit; - c.profile.onEachHit = (b, u, victim, hc) => { - if (prev) prev(b, u, victim, hc); - if (victim && victim.alive && victim.side === 'enemy') b.dealDamage(unit, victim, { amount: unit.s.atk * magic, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'hornOverload'] }); - }; - }, { owner: unit, priority: -100 }); - } - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 魔王 — bard (aura heal 10 % ATK/s). S3 编织重构现世 (30 s): skill range, aura 65 %, motes never vanish, other allies in - // range get 鼓舞 = +65 % of her max HP, HP of everyone in range is equalised every 2 s. - // T1 过往尘埃: 3 motes orbit her; one colliding with an operator vanishes and gives it ×1.5 aura healing for 6 s, then - // respawns after 6 s. - // T2 魔王残响: allies take −10 % damage from Sarkaz enemies. Module (elite): ≥2 other ops in range → ATK +8 %. - chess_char_5_09_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1); - const baseRatio = num(tb['attack@atk_to_hp_recovery_ratio'], 0.1); - const skillRatio = num(bb['attack@atk_to_hp_recovery_ratio'], baseRatio); - const inspire = num(bb.max_hp); - const shareIv = Math.max(0.1, num(bb['attack@cetsyr_s_3[cal_hp_ratio].interval'], 2)); - const inspireAll = (battle, unit) => { - if (!(inspire > 0)) return; - const hp = unit.s.maxHp * inspire; - for (const a of battle.alliesInGrid(unit)) { - if (a !== unit) battle.addBuff(a, { key: 'cetsyr:inspire', duration: AURA_DUR, visible: true, status: 'inspire', mods: { hpFlat: hp }, data: { src: unit } }); - } - }; - return { - skill: { - kind: 'duration', - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - onStart({ battle, unit }) { - if (unit.profile) unit.profile.auraRatio = skillRatio; - unit.mem.shareAcc = 0; - unit.mem.inspireAcc = 0; - inspireAll(battle, unit); - battle.fx('healAoe', { x: unit.x, y: unit.y, id: unit.id, r: 2.5 }); - }, - onTick({ battle, unit, dt }) { - unit.mem.inspireAcc += dt; - if (unit.mem.inspireAcc >= AURA_IV) { unit.mem.inspireAcc = 0; inspireAll(battle, unit); } - unit.mem.shareAcc += dt; - if (unit.mem.shareAcc < shareIv - 1e-9) return; - unit.mem.shareAcc -= shareIv; - const group = battle.alliesInGrid(unit).filter((a) => a.alive && a.kind !== 'device'); - let hp = 0, max = 0; - for (const a of group) { hp += a.hp; max += a.s.maxHp; } - if (!(max > 0) || group.length < 2) return; - const ratio = hp / max; - for (const a of group) a.hp = Math.max(1, Math.min(a.s.maxHp, a.s.maxHp * ratio)); - battle.fx('hpShare', { x: unit.x, y: unit.y, id: unit.id, ratio: Math.round(ratio * 1000) / 1000 }); - }, - onEnd({ battle, unit }) { - if (unit.profile) unit.profile.auraRatio = baseRatio; - for (const a of battle.allyUnits) { - const b = a.findBuff('cetsyr:inspire'); - if (b && b.data.src === unit) battle.removeBuff(a, b); - } - }, - }, - talents: [ - { install(battle, unit) { // 过往尘埃: cnt motes orbit her (radius range_radius, dynamic_spd °/s, PRTS hit radius 0.4) - const cnt = Math.max(0, Math.floor(num(t0.cnt, 3))), orbit = num(t0.range_radius, 1.15); - const spd = (num(t0.dynamic_spd, 30) * Math.PI) / 180; - const cd = num(t0.cooldown, 6), dur = num(t0.talent_duration, 6), mul = num(t0['attack@trait_mul'], 1.5); - const motes = new Array(cnt).fill(0); // per orbit slot: the time its mote is back (vanished → respawn after cd) - const reach = orbit + MOTE_HIT_RADIUS + 0.01; - battle.on('deploy', (c) => { if (c.unit === unit) motes.fill(0); }, { owner: unit }); - whileOn(battle, unit, AURA_IV, () => { - if (!cnt) return; - const keep = !!unit.skill?.active; // S3: "微尘"不再消失 - // "微尘"碰撞友方干员: operators only; a mote never collides with an operator already under a mote effect - const cands = battle.alliesInRadius(unit.x, unit.y, reach, null).filter((a) => a !== unit && isOp(a) && !a.findBuff('cetsyr:mote')); - if (!cands.length) return; - const t = battle.time - num(unit.deployedAt); - for (let k = 0; k < cnt; k++) { - if (motes[k] > battle.time + 1e-9) continue; - const ang = spd * t + (2 * Math.PI * k) / cnt; - // the orbit turns with his direction: angle 0 = straight ahead, π/2 = his left hand - const [fr, fc] = unit.fwd, [lr, lc] = rotateOffset(1, 0, unit.dir); - const mx = unit.x + orbit * (fc * Math.cos(ang) + lc * Math.sin(ang)), my = unit.y + orbit * (fr * Math.cos(ang) + lr * Math.sin(ang)); - let hit = null, hd = Infinity; - for (const a of cands) { - if (a.findBuff('cetsyr:mote')) continue; - const d = Math.hypot(a.x - mx, a.y - my); - if (d <= MOTE_HIT_RADIUS + 1e-9 && d < hd) { hd = d; hit = a; } - } - if (!hit) continue; - battle.addBuff(hit, { key: 'cetsyr:mote', duration: dur, visible: true, data: { src: unit, mul } }); - if (!keep) motes[k] = battle.time + cd; - battle.fx('mote', { x: hit.x, y: hit.y, id: hit.id }); - } - }); - battle.on('heal', (c) => { - if (c.source !== unit || !c.opts?.aura) return; - const b = c.target.findBuff('cetsyr:mote'); - if (b) c.amount *= num(b.data.mul, 1); - }, { owner: unit }); - } }, - { install(battle, unit) { // 魔王残响 - const dr = num(t1.damage_resistance); - if (!(dr > 0)) return; - battle.on('hit', (c) => { - if (!c.target || c.target.side !== 'ally' || !c.source || c.source.side !== 'enemy') return; - if (!(c.source.def?.tags || []).includes('sarkaz') || leaderOf(battle, unit) !== unit) return; - c.dmg.mul *= 1 - dr; - }, { owner: unit }); - } }, - ], - install(battle, unit) { // module: ≥ cnt other ops in the (base) range → ATK +8 % - const atk = num(tm.atk), need = num(tm.cnt, 2); - if (!chess?.isGolden || !atk) return; - whileOn(battle, unit, AURA_IV, () => { - const keys = new Set(unit.baseRangeKeys || []); - let n = 0; - for (const a of battle.allies()) if (a !== unit && isOp(a) && keys.has(a.tileR * COLS + a.tileC)) n++; - if (n >= need) battle.addBuff(unit, { key: 'cetsyr:module', duration: AURA_DUR, mods: { atkPct: atk } }); - }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 铃兰 — S3 狐火渺然 (27/29 s): no attacks, skill range, every enemy in range is sluggish, allies in range heal 9/11 % - // ATK per second, T2 ×scale_delta_to_one. T1 技力光环·辅助: Supporters +0.4 SP/s (highest wins). - // T2 画地为牢: sluggish enemies in range also take +20 % damage for the same time. Module (elite): +0.2 SP/s with an - // enemy in range. - chess_char_5_10_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); - const boost = num(bb.scale_delta_to_one, 1); - const healRatio = num(bb['attack@atk_to_hp_recovery_ratio']); - const fragile = num(t1.damage_scale, 1) - 1; - return { - // S1 全力以赴 (duration): ATK +, ASPD +. S2 儿时的舞乐 (toggle, 持续时间无限): ATK +, 2 targets. - skills: lazySkills({ - skchr_lisa_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }), - skchr_lisa_2: () => ({ kind: 'toggle', mods: mods({ atkPct: num(bb.atk) }), targeting: { maxTargets: Math.max(1, num(bb['attack@max_target'], 2)) } }), - }), - skill: { - kind: 'duration', - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - attack: { noAttack: true }, - onStart({ unit }) { unit.mem.foxSlow = AURA_IV; unit.mem.foxHeal = 0; }, - onTick({ battle, unit, dt }) { - unit.mem.foxSlow += dt; - if (unit.mem.foxSlow >= AURA_IV) { - unit.mem.foxSlow = 0; - for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) battle.applyStatus(e, 'sluggish', { duration: AURA_DUR, source: unit }); - } - unit.mem.foxHeal += dt; - if (unit.mem.foxHeal >= 1) { - unit.mem.foxHeal -= 1; - if (!(healRatio > 0)) return; - for (const a of battle.alliesInGrid(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, unit.s.atk * healRatio, { aura: true }); - } - }, - }, - talents: [ - { install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec), (a) => isOp(a) && a.def?.profession === 'SUPPORT'); } }, - { install(battle, unit) { // 画地为牢 — PRTS 备注: an aura; every enemy in her range that is 停顿 (whoever caused it) - // carries the talent's 脆弱 while it stays 停顿 and in range. It is the standard 脆弱 status (same-name: highest wins). - if (!(fragile > 0)) return; - whileOn(battle, unit, AURA_IV, () => { - const v = fragile * (unit.skill?.active ? boost : 1); - for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) { - if (e.alive && e.findBuff('sluggish')) battle.applyStatus(e, 'fragile', { duration: AURA_DUR, value: v, source: unit }); - } - }); - } }, - ], - install(battle, unit) { // module - const sp = num(tm.sp_recovery_per_sec); - if (!chess?.isGolden || !(sp > 0)) return; - whileOn(battle, unit, AURA_IV, () => { - if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) battle.addBuff(unit, { key: 'lisa:module', duration: AURA_DUR, mods: { spRecoveryFlat: sp } }); - }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 塞雷娅 — S2 药物配置 (instant, time SP): heals every ally in the skill range for heal_scale × ATK (cast when one is - // injured). T1 莱茵充能护服: every 20 s on the field ATK +5 % / DEF +4 % (×5). T2 精神回复: +1 SP to every ally she heals. - // Module (elite): heals on allies below 50 % ×1.15. - // S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack when an ally of the skill area 周围 is at ≤ half HP): - // that attack is instead a heal of the lowest such ally for heal_scale × ATK. - // S3 钙质化 (duration; the 重装 strategy TAKE_DAMAGE): allies in the skill area heal - // attack@heal_scale × ATK per second; enemies there take arts ×demkni_s_3.damage_scale and move −60 %. - // Module GUA-Y (elite): damage taken −15 %. - chess_char_5_11_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); - const sid = selectedId(chess, def); - const grid = skillGrid(chess, def) ?? [[0, 0]]; - const zone = (battle, unit) => { - const at = unit.tileR * COLS + unit.tileC; - if (unit.mem.sariaAt !== at) { unit.mem.sariaAt = at; unit.mem.sariaKeys = new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)); } - const keys = unit.mem.sariaKeys; - return battle.allies().filter((a) => keys.has(a.tileR * COLS + a.tileC) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal))); - }; - const healable = (a, unit) => a.kind !== 'device' && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal)); - return { - skills: lazySkills({ - skchr_demkni_1: () => ({ - // 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次 - // 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic - // rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮, - // no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack) - kind: instantKind(chess, def), - trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP }, - targeting: { rangeGrid: grid }, - attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(bb.heal_scale, 1), projectile: 'none' }, - onHit({ battle, target }) { if (target) battle.fx('healAoe', { x: target.x, y: target.y, id: target.id, r: 0.5 }); }, - }), - skchr_demkni_3: () => ({ - kind: 'duration', - onStart({ unit }) { unit.mem.calcAcc = 0; unit.mem.calcAura = AURA_IV; }, - onTick({ battle, unit, dt }) { - unit.mem.calcAura += dt; - if (unit.mem.calcAura >= AURA_IV - 1e-9) { - unit.mem.calcAura = 0; - const m = { artsTakenMul: num(bb['demkni_s_3.damage_scale'], 1), moveMul: Math.max(0, 1 + num(bb['demkni_s_3.move_speed'])) }; - for (const e of battle.unitsInGrid(unit, grid, { side: 'enemy' })) battle.addBuff(e, { key: 'saria:calcify', duration: AURA_DUR, visible: true, mods: m }); - } - unit.mem.calcAcc += dt; - if (unit.mem.calcAcc < 1 - 1e-9) return; - unit.mem.calcAcc -= 1; - const amt = unit.s.atk * num(bb['attack@heal_scale']); - if (amt > 0) for (const a of battle.unitsInGrid(unit, grid, { side: 'ally' })) if (healable(a, unit) && a.hp < a.s.maxHp - 1e-6) battle.heal(unit, a, amt); - }, - }), - }), - skill: { - kind: 'instant', heal: true, trigger: NEVER, - onStart({ battle, unit }) { - const amt = unit.s.atk * num(bb.heal_scale, 1); - for (const a of zone(battle, unit)) if (a.hp < a.s.maxHp - 1e-6) battle.heal(unit, a, amt); - battle.fx('healAoe', { x: unit.x, y: unit.y, id: unit.id, r: 2.5 }); - }, - }, - talents: [ - { install(battle, unit) { // 莱茵充能护服 - const iv = Math.max(1, num(t0.interval, 20)), max = Math.max(1, num(t0.max_stack_cnt, 5)); - whileOn(battle, unit, 1, () => { - const n = Math.min(max, Math.floor((battle.time - unit.deployedAt + 1e-6) / iv)); - const cur = unit.findBuff('saria:suit'); - if (n > 0 && (!cur || cur.stacks !== n)) battle.addBuff(unit, { key: 'saria:suit', stacks: n, maxStacks: max, mods: mods({ atkPct: num(t0.atk), defPct: num(t0.def) }) }); - }); - } }, - { install(battle, unit) { // 精神回复 - const sp = num(t1.sp); - if (!(sp > 0)) return; - battle.on('heal', (c) => { - if (c.source !== unit || c.opts?.regen || !(c.amount > 0) || !c.target.skill) return; - c.target.skill.gainSp(sp, 'talent'); - }, { owner: unit, priority: -10 }); - } }, - ], - install(battle, unit) { - // S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured - if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6)); - lowHpHealUp(battle, unit, tb); - if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 夕 — S1 工笔入化 (2 charges): next attack 180 % ATK with an expanded splash. T1 化境: a kill by 夕 or 小自在 → ATK +2 % - // (×15). T2 点睛: first attack after deploying summons 小自在 (25 s) on the target's tile (deployable ground). - // Module (elite): 攻击范围扩大. - // S2 泼墨淋漓 (duration): skill range, ATK +, ASPD +, hits every enemy in range (one hit each: no splash on top); - // arts damage ×damage_scale on enemies below hp_ratio. S3 写意胜形 (duration): skill range, BAT +0.4 s, prefers - // unblocked enemies, splash 1.7, ATK +; every attack summons 小自在 on the target's tile (deployable ground) or moves / - // refreshes the one already out (deployLimit 1: "召唤/刷新一个"), 25 s (the 点睛 token duration). - chess_char_5_12_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1); - const sid = selectedId(chess, def); - const tokenId = talentRec(chess, 1)?.tokenKey ?? (chess?.tokens || [])[0] ?? 'token_10015_dusk_drgn'; - const tokenDur = num(t1['attack@tokenduration'], 25); - const freeGround = (battle, r, c) => battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c); - // 小自在 expiry follows tk.mem.duskUntil (a refresh pushes it back) - const expire = (battle, tk) => { - if (!tk.alive) return; - const left = num(tk.mem.duskUntil) - battle.time; - if (left > 1e-6) battle.after(left, () => expire(battle, tk), { owner: tk }); - else battle.retreat(tk, { reason: 'expired', permanent: true }); - }; - /** Summon 小自在 on (r, c), or move / refresh hers when one is out. */ - const summon = (battle, unit, r, c) => { - const mine = battle.allyUnits.find((x) => x.kind === 'token' && x.ownerUnit === unit && x.defId === tokenId && x.alive && x.deployed); - if (mine) { - if (mine.tileR !== r || mine.tileC !== c) { - const fromX = mine.x, fromY = mine.y; - if (!freeGround(battle, r, c) || !battle.relocate(mine, r, c)) return null; - battle.fx('teleport', { x: mine.x, y: mine.y, id: mine.id, fromX, fromY }); - } - mine.mem.duskUntil = battle.time + tokenDur; - return mine; - } - if (!freeGround(battle, r, c)) return null; - const tk = battle.spawnToken(unit, tokenId, r, c); - if (!tk) return null; - tk.mem.duskUntil = battle.time + tokenDur; - battle.after(tokenDur, () => expire(battle, tk), { owner: tk }); - battle.fx('summon', { x: tk.x, y: tk.y, id: tk.id, token: tokenId }); - return tk; - }; - return { - skills: lazySkills({ - skchr_dusk_2: () => ({ - kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), - targeting: skillRange(chess, def, { allInRange: true }), - attack: { splashRadius: 0 }, - }), - skchr_dusk_3: () => ({ - kind: 'duration', mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), - targeting: skillRange(chess, def), - attack: { splashRadius: DUSK_S3_SPLASH }, - }), - }), - skill: { kind: 'charges', attack: { atkScale: num(bb.atk_scale, 1), splashRadius: DUSK_SPLASH_RADIUS } }, - talents: [ - { install(battle, unit) { // 化境 - battle.on('kill', (c) => { - const k = c.killer; - if (!k || c.victim.side !== 'enemy' || !(k === unit || (k.kind === 'token' && k.ownerUnit === unit)) || !on(unit)) return; - battle.addBuff(unit, { key: 'dusk:realm', refresh: 'stack', maxStacks: Math.max(1, num(t0.max_stack_cnt, 15)), mods: { atkPct: num(t0.atk) } }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 点睛 - battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.duskSummoned = false; }, { owner: unit }); - battle.on('attack', (c) => { - if (c.attacker !== unit || unit.mem.duskSummoned) return; - const t = c.targets.find((x) => x.side === 'enemy'); - if (!t) return; - unit.mem.duskSummoned = true; - // "在目标位置(可部署地面)召唤": only on the target's own tile when it is free deployable ground — PRTS 备注: - // otherwise nothing is summoned, and the talent is spent for this deployment either way - summon(battle, unit, Math.round(t.y), Math.round(t.x)); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - moduleRangeUp(battle, unit, chess); - if (sid === 'skchr_dusk_2') { - const cut = num(bb.hp_ratio, HALF_HP), mul = num(bb.damage_scale, 1); - battle.on('hit', (c) => { - if (c.source !== unit || !unit.skill?.active || c.dmg.type !== 'arts' || !c.target || c.target.side !== 'enemy') return; - if (c.target.hpRatio < cut) c.dmg.mul *= mul; - }, { owner: unit }); - } - if (sid === 'skchr_dusk_3') { - // 优先攻击未阻挡的敌人 - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit || !unit.skill?.active || !c.targets.length || !c.targets[0].blockedBy) return; - const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); - sortEnemyTargets(battle, unit, list, null); - const alt = list.find((e) => !e.blockedBy); - if (alt) c.targets = [alt, ...c.targets.slice(1).filter((x) => x !== alt)]; - }, { owner: unit, priority: 10 }); - battle.on('attack', (c) => { - if (c.attacker !== unit || !unit.skill?.active) return; - const t = c.targets.find((x) => x.side === 'enemy'); - if (t) summon(battle, unit, Math.round(t.y), Math.round(t.x)); - }, { owner: unit }); - } - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 归溟幽灵鲨 — dollkeeper (professions.js installDollkeeper: the 替身 form, its switch animations, 阻回). Her <替身> makes - // no normal attack (PRTS 特性备注 "<替身>不进行普通攻击") and so casts no skill (kit trait `dollNoAttack`). - // S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1 (PRTS 备注: 不死 — a lethal hit does not switch her meanwhile); - // when it ends she switches to the 替身 at once (PRTS 修正 "技能结束后立刻切换为<替身>", the text's 视为被击倒: no lethal - // HP loss, so no 不死 / 复活 effect takes it). T1 拥抱自我: the 替身 slows nearby enemies −40 % and deals 40 % ATK - // arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only) — once it fights (not during its switch - // animation). T2 阿戈尔的深邃: Abyssal Hunters in the team max HP +20 %. Module (elite): substitute ATK +15 %. - // S1 生存的技巧 (duration): swaps HP ratios with the other operator of the skill area (周围) with the lowest HP ratio, - // ATK +. S3 生存的重压 (duration): BAT +1 s, hits every blocked enemy, ATK +, max HP +; an attacked enemy whose HP ratio - // is ≥ hers takes attack@atk_scale_ex × ATK phys more, otherwise she loses attack@hp_ratio of her max HP. - // Module PUM-Y (elite): substitute max HP +20 %. - chess_char_5_13_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); - const sid = selectedId(chess, def); - const aroundGrid = chess?.trait?.rangeGrid ?? [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]; - return { - skills: lazySkills({ - skchr_ghost2_1: () => ({ - kind: 'duration', mods: mods({ atkPct: num(bb.atk) }), - onStart({ battle, unit }) { - const t = battle.unitsInGrid(unit, skillGrid(chess, def) ?? aroundGrid, { side: 'ally' }).filter((a) => a !== unit && isOp(a) && a.hp > 0) - .sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0]; - if (!t) return; - const mine = unit.hpRatio, theirs = t.hpRatio; - unit.hp = Math.max(1, Math.min(unit.s.maxHp, unit.s.maxHp * theirs)); - t.hp = Math.max(1, Math.min(t.s.maxHp, t.s.maxHp * mine)); - battle.fx('hpShare', { x: t.x, y: t.y, id: unit.id, to: t.id }); - }, - }), - skchr_ghost2_3: () => ({ - kind: 'duration', - mods: mods({ atkPct: num(bb.atk), hpPct: num(bb.max_hp), batPct: batPct(bb.base_attack_time, chess) }), - attack: { - hitAllBlocked: true, - onEachHit({ battle, unit, target, kind }) { - if (kind !== 'main' || !target || target.side !== 'enemy') return; - if (unit.mem.ghostHeavy?.get(target)) { - if (target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb['attack@atk_scale_ex']), type: 'phys', isSkill: true, tags: ['skill', 'ghost2Weight'] }); - } else if (unit.alive) battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio']), { source: unit }); - }, - }, - }), - }), - skill: { - kind: 'duration', - mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), - // "技能结束后立刻切换为<替身>": nothing when the skill ended with her (death) or by her switch to the 替身, or she - // already is one - onEnd({ battle, unit, reason }) { - if (reason === 'death' || reason === 'substitute' || !unit.alive || !unit.deployed || unit.trait.doll) return; - battle.emit('dollSwitch', { unit, reason: 'skill', done: false }); - }, - }, - trait: { dollNoAttack: true }, - talents: [ - { install(battle, unit) { // 拥抱自我 (the 替身 fighting: not during a switch animation) - const slow = num(t0.move_speed), scale = num(t0.atk_scale); - whileOn(battle, unit, AURA_IV, () => { - if (!unit.trait.doll || unit.trait.dollSwitching || !slow) return; - for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } }); - }); - whileOn(battle, unit, 1, () => { - if (!unit.trait.doll || unit.trait.dollSwitching || !(scale > 0)) return; - for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] }); - }); - } }, - { install(battle, unit) { // 阿戈尔的深邃 - const hp = num(t1.max_hp); - if (!hp) return; - battle.on('battleStart', () => { - for (const a of battle.getPlayer(unit.ownerId)?.units ?? []) if (isOp(a) && isAbyssal(a)) battle.addBuff(a, { key: 'ghost2:abyss', mods: { hpPct: hp }, persist: true, allowDead: true }); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - // S2: HP never below 1 while it runs - if (!sid || sid === 'skchr_ghost2_2') battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 }); - if (sid === 'skchr_ghost2_3') { // S3: 生命比例高于或等于自身 — judged when the attack starts - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit || !unit.skill?.active) return; - unit.mem.ghostHeavy = new Map(c.targets.filter(Boolean).map((t) => [t, t.hpRatio >= unit.hpRatio - 1e-9])); - }, { owner: unit, priority: -100 }); - } - const hpUp = num(tb.max_hp); // PUM-Y: 替身…生命值提升 - if (hpUp) whileOn(battle, unit, 0.2, () => { if (unit.trait.doll) battle.addBuff(unit, { key: 'ghost2:moduleY', duration: 0.3, mods: { hpPct: hpUp } }); }); - const atk = num(tb.atk); - if (atk) { - whileOn(battle, unit, 0.2, () => { - if (unit.trait.doll) battle.addBuff(unit, { key: 'ghost2:module', duration: 0.3, mods: { atkPct: atk } }); - }); - } - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 凛御银灰 — S2 御敌的锋锐 (2 charges; PRTS 备注 "可对空"): the 6 front-most enemies in the skill range take 260 % ATK - // phys, cold + reveal for 4 s; the waiting (knocked-out) op nearest to 风雪之眼 by cost gets −11 redeploy cost (right - // side guard/caster/sniper first); waiting ops left of the eye (cost < eye cost) cast the AoE with their own ATK when - // they redeploy (≤2). - // T1 开放性开局: ops left of the eye and himself: +4 SP at deployment, redeploy time −20 %. - // T2 雪境先驱: Kjerag ops freeze-immune, DEF +60, 1.5 % max HP/s regen; doubled after 15 s on the field. - // The eye follows the selected skill (its overrideTokenKey: S1 eagle1 cost 16, S2 eagle2 14, S3 eagle3 19). - // S1 周旋的谋略 (instant): +cost DP at once; the waiting op nearest to the eye gets −[deck].cost redeploy cost (same pick - // as S2) and a barrier of [deck].shield × his max HP when it redeploys. - // S3 变革已至 (duration 48 s): skill range; enemies in it lose 隐匿; every attack hits every enemy of the range on the - // target's line (his facing) for bird_atk_scale × ATK phys + 脆弱 (damage_scale − 1, weak[limit] s [ASSUMED duration]); - // +[start_cost] DP at once, then +[cost].cost DP every [cost].interval s; the first activation of the battle swaps the - // base costs of the most (guard/caster/sniper first) and least expensive waiting ops. "风雪之眼变为可部署" is not - // modelled (nobody deploys by hand in the auto battle). - chess_char_5_14_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1); - const grid = skillGrid(chess, def) ?? [[0, 0], [0, 1]]; - const eyeId = chess?.skill?.overrideTokenKey ?? talentRec(chess, 0)?.tokenKey ?? 'token_10057_svash2_eagle2'; - const n = Math.max(1, num(bb.max_target, 6)), maxStacks = Math.max(1, num(bb.max_stack_cnt, 2)); - const PREF = ['WARRIOR', 'CASTER', 'SNIPER']; - /** Waiting (knocked-out) ops of his player split around the eye cost, and the one nearest to the eye (right side first). */ - const waitingArea = (battle, unit) => { - const eye = unit.mem.eyeCost ?? 0; - const waiting = battle.allyUnits.filter((a) => isOp(a) && a !== unit && a.ownerId === unit.ownerId && !a.alive && !a.removed); - const right = waiting.filter((a) => a.base.cost >= eye).sort((a, b) => a.base.cost - b.base.cost || a.id - b.id); - const left = waiting.filter((a) => a.base.cost < eye).sort((a, b) => b.base.cost - a.base.cost || a.id - b.id); - const pick = right.find((a) => PREF.includes(a.def?.profession)) ?? right[0] ?? left[0] ?? null; - return { waiting, right, left, pick }; - }; - const cutCost = (a, v) => { - if (!a || !(v > 0)) return; - a.mem.svashCostBase ??= a.base.cost; - a.base.cost = Math.max(0, a.base.cost - v); - }; - const slash = (battle, caster, atk) => { - // PRTS 备注: the slash ignores 隐匿 and hits flying enemies (and its reveal makes them targetable afterwards) - for (const e of enemiesInGrid(battle, caster, grid, n, { ignoreStealth: true })) { - battle.dealDamage(caster, e, { amount: atk * num(bb.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'svash2'] }); - if (!e.alive) continue; - battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: caster }); - battle.applyStatus(e, 'reveal', { duration: num(bb.cold), source: caster }); - } - battle.fx('aoe', { x: caster.x + caster.fwd[1] * 1.5, y: caster.y + caster.fwd[0] * 1.5, id: caster.id, r: 2, skill: 'svash2' }); - }; - return { - skills: lazySkills({ - // (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1) - skchr_svash2_1: () => ({ - kind: instantKind(chess, def), - trigger: 'SP_FULL', - onStart({ battle, unit }) { - battle.addDp(unit.ownerId, num(bb.cost)); - const { pick } = waitingArea(battle, unit); - cutCost(pick, num(bb['svash2_s_1[deck].cost'])); - if (pick) pick.mem.svashShield = unit.s.maxHp * num(bb['svash2_s_1[deck].shield']); - battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'svash2Plan' }); - }, - }), - skchr_svash2_3: () => ({ - kind: 'duration', - targeting: skillRange(chess, def), - attack: { - atkScale: num(bb.bird_atk_scale, 1), - onEachHit({ battle, unit, target }) { - if (!target || !target.alive || target.side !== 'enemy') return; - const v = num(bb.damage_scale, 1) - 1; - if (v > 0) battle.applyStatus(target, 'fragile', { duration: num(bb['weak[limit]'], 2), value: v, source: unit }); - }, - }, - onStart({ battle, unit }) { - battle.addDp(unit.ownerId, num(bb['svash2_s_3[start_cost].cost'])); - unit.mem.svashDpAcc = 0; - unit.mem.svashReveal = AURA_IV; - if (!unit.mem.svashSwapped) { // 首次开启时交换…基础部署费用 - unit.mem.svashSwapped = true; - const { waiting } = waitingArea(battle, unit); - const baseCost = (a) => a.mem.svashCostBase ?? a.base.cost; - const byCost = waiting.slice().sort((a, b) => baseCost(b) - baseCost(a) || a.id - b.id); - const hi = byCost.find((a) => PREF.includes(a.def?.profession)) ?? byCost[0]; - const lo = byCost.filter((a) => a !== hi).pop(); - if (hi && lo && baseCost(hi) !== baseCost(lo)) { - const ch = baseCost(hi), cl = baseCost(lo); - for (const [a, v] of [[hi, cl], [lo, ch]]) { - if (a.mem.svashCostBase != null) { a.base.cost = Math.max(0, v - (a.mem.svashCostBase - a.base.cost)); a.mem.svashCostBase = v; } else a.base.cost = v; - } - } - } - }, - onTick({ battle, unit, dt }) { - const iv = Math.max(0.1, num(bb['svash2_s_3[cost].interval'], 2)); - unit.mem.svashDpAcc += dt; - while (unit.mem.svashDpAcc >= iv - 1e-9) { unit.mem.svashDpAcc -= iv; battle.addDp(unit.ownerId, num(bb['svash2_s_3[cost].cost'], 1)); } - unit.mem.svashReveal += dt; - if (unit.mem.svashReveal < AURA_IV - 1e-9) return; - unit.mem.svashReveal = 0; - for (const e of enemiesInGrid(battle, unit, grid, 0, { ignoreStealth: true })) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit }); - }, - }), - }), - skill: { - kind: 'charges', // DEFAULT (data): cast right before an attack on an enemy in his initial (melee) range - onStart({ battle, unit }) { - slash(battle, unit, unit.s.atk); - // cost cut: nearest to the eye, right side guard/caster/sniper preferred - const { left, pick } = waitingArea(battle, unit); - cutCost(pick, num(bb.cost)); - for (const a of left) a.mem.svashCasts = Math.min(maxStacks, num(a.mem.svashCasts) + 1); - }, - }, - talents: [ - { install(battle, unit) { // 开放性开局 - const sp = num(t0.sp), mul = num(t0.respawn_time, 1); - const left = (a) => isOp(a) && a.ownerId === unit.ownerId && (a === unit || a.base.cost < num(unit.mem.eyeCost)); - battle.on('battleStart', () => { - if (!on(unit) || !(sp > 0)) return; - for (const a of battle.allies(unit.ownerId)) if (left(a) && a.skill) a.skill.gainSp(sp, 'talent'); - }, { owner: unit }); - battle.on('deploy', (c) => { - const a = c.unit; - if (c.initial || !left(a) || !(a === unit || on(unit))) return; - if (sp > 0 && a.skill) a.skill.gainSp(sp, 'talent'); - }, { owner: unit }); - battle.on('death', (c) => { - const a = c.unit; - if (!left(a) || a.removed || !(a === unit || on(unit)) || !Number.isFinite(a.respawnAt) || !(mul > 0 && mul < 1)) return; - a.respawnAt = a.deathAt + (a.respawnAt - a.deathAt) * mul; - }, { owner: unit }); - } }, - { install(battle, unit) { // 雪境先驱 - const defv = num(t1.def), regen = num(t1.hp_recovery_per_sec_by_max_hp_ratio), after = num(t1.interval, 15); - whileOn(battle, unit, AURA_IV, () => { - const m = battle.time - unit.deployedAt >= after - 1e-9 ? 2 : 1; - for (const a of battle.allies()) { - if (isOp(a) && isKjerag(a)) battle.addBuff(a, { key: 'svash2:snow', duration: AURA_DUR, mods: mods({ defFlat: defv * m, hpRegenRatio: regen * m }) }); - } - }); - battle.on('beforeStatus', (c) => { - if (c.status === 'freeze' && c.target.side === 'ally' && c.target.findBuff('svash2:snow')) c.cancel = true; - }, { owner: unit }); - } }, - ], - install(battle, unit) { - const tok = battle.tokenDef(eyeId, unit); // the owner's skill variant (DESIGN §16) - unit.mem.eyeCost = num(tok?.stats?.cost, 14); - if (selectedId(chess, def) === 'skchr_svash2_3') { - // S3: every attack hits the enemies of the skill range on the target's line (his facing frame): the line of - // the target's position; another enemy is on it when its body is (a huge one: any tile — body.js) - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit || !unit.skill?.active || !c.targets.length) return; - const main = c.targets[0]; - const lat = (r, cc) => toLocal(r - unit.tileR, cc - unit.tileC, unit.dir)[0]; - const l0 = lat(Math.round(main.y), Math.round(main.x)); - const onLine = (e) => bodyKeys(e).some((k) => lat(Math.floor(k / COLS), k % COLS) === l0); - const line = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).filter((e) => e !== main && onLine(e)); - if (line.length) c.targets = [main, ...line]; - }, { owner: unit, priority: 10 }); - } - // waiting-area effects resolve when the op (re)deploys - battle.on('deploy', (c) => { - const a = c.unit; - if (!isOp(a) || a.ownerId !== unit.ownerId) return; - if (a.mem.svashCostBase != null) { a.base.cost = a.mem.svashCostBase; a.mem.svashCostBase = null; } - if (num(a.mem.svashShield) > 0 && !c.initial) { // S1: 部署后获得…屏障 - battle.addBuff(a, { key: 'svash2:barrier', shield: a.mem.svashShield, visible: true }); - a.mem.svashShield = 0; - } - const casts = num(a.mem.svashCasts); - if (casts > 0 && !c.initial) { - a.mem.svashCasts = 0; - for (let i = 0; i < casts; i++) slash(battle, a, a.s.atk); - } - }, { owner: unit }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 引星棘刺 — S2 解构涌潮 (instant / 2 charges elite): throws an alchemy unit at the target: for 12/15 s ground enemies - // around it get healing ×0.5, take 120/140 % ATK arts per second, allies heal 12/15 % ATK per second; the unit drifts - // along the throw direction and its radius grows. T1 心相: ATK +10 %, +3 s when another op is in range. - // T2 视界: allies ASPD +5, enemies −5 (doubled on straight roads ≥ 6 tiles). Module (elite): +0.1 SP/s with a unit out. - // S1 度算浪波 (instant): an alchemy unit thrown at the ally in her range with the lowest HP ratio: for - // projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and heal - // hp_recovery_per_sec_ratio × ATK per second. S3 “我的海疆”: passive — her range is the skill range; active - // (instant) — alchemy units on the max_target_token operators with the lowest block count: for projectile_delay_time s - // the enemies around each of them (RING1, following it) get ATK/DEF/RES −, one strongest instance (不叠加), and take - // atk_scale × ATK arts per second, everything ramping +per_interval each `interval` s up to max_stack_cnt steps. - chess_char_5_15_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); - const sid = selectedId(chess, def); - const baseDur = num(bb.projectile_delay_time, num(bb.remaining_time, 12)); - const r0 = num(bb.projectile_range, 1.1), grow = num(bb.value), speed = num(bb.projectile_move_speed); - const healMul = Math.max(0, num(bb.heal_scale, 1)); - const extend = (battle, unit) => (battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0); - // S3 ramp: value after `steps` intervals, clamped towards the max ("效果逐渐提升…15秒后达到最大") - const ramp = (base, per, max, steps) => { - const v = num(base) + num(per) * steps; - return num(max) < num(base) ? Math.max(num(max), v) : Math.min(num(max, v), v); - }; - /** One tick (AURA_IV) of an S1 guard zone / S3 sea zone. */ - const tickSpecial = (battle, unit, z, seaHits) => { - if (z.type === 'guard') { - const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1; - const allies = battle.alliesFor(unit).filter(inZone); - for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) }); - if (z.acc >= 1 - 1e-9) { - z.acc -= 1; - const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio); - if (heal > 0) for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true }); - } - return; - } - // sea: follows its operator while it stands on the field; the first burn lands with the unit, then one per - // `interval` (so the 15th step — the max — is reached 15 s after the throw, for the debuff and the damage alike) - if (z.anchor && z.anchor.alive && z.anchor.deployed) { z.x = z.anchor.x; z.y = z.anchor.y; } - const iv = Math.max(0.1, num(bb.interval, 1)); - const steps = Math.min(Math.max(0, num(bb.max_stack_cnt, 15)), Math.floor((z.t - AURA_IV + 1e-9) / iv)); - const foes = battle.foesInRadius(z.x, z.y, RING1); - for (const e of foes) seaHits.set(e, Math.max(seaHits.get(e) ?? -1, steps)); - if (z.acc >= iv - 1e-9) { - z.acc -= iv; - const scale = ramp(bb.atk_scale, bb.atk_scale_per_interval, bb.max_atk_scale, steps); - z.burn = { foes, scale }; // dealt after the debuffs of this tick are on - } - }; - return { - skills: lazySkills({ - skchr_thorn2_1: () => ({ - kind: instantKind(chess, def), - // an AUTO skill (skill_table 自动触发) that throws at an ally: it fires as soon as its SP is full, with or - // without an enemy — the basic DEFAULT rule waited for an attack, so with no enemy around it sat at 7/7 - // (GitHub #124 「引星棘刺一技能不会在满技力时自动释放」) - trigger: { rule: 'SP_FULL' }, - onStart({ battle, unit }) { - const t = battle.alliesInGrid(unit).filter((a) => a.hp > 0).sort((a, b) => a.hpRatio - b.hpRatio || b.blocking.length - a.blocking.length || dist(a, unit) - dist(b, unit) || a.id - b.id)[0]; - if (!t) return; - const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit) }; - (unit.mem.zones ??= []).push(z); - battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: RING1, duration: z.dur }); - }, - }), - skchr_thorn2_3: () => ({ - kind: instantKind(chess, def), - onStart({ battle, unit }) { - const ops = battle.allies(unit.ownerId).filter((a) => isOp(a) && a.hp > 0) - .sort((a, b) => a.s.blockCnt - b.s.blockCnt || dist(a, unit) - dist(b, unit) || a.id - b.id) - .slice(0, Math.max(1, num(bb.max_target_token, 3))); - const dur = num(bb.projectile_delay_time, 19) + extend(battle, unit); - for (const a of ops) { - (unit.mem.zones ??= []).push({ type: 'sea', anchor: a, x: a.x, y: a.y, t: 0, acc: Math.max(0.1, num(bb.interval, 1)) - AURA_IV, dur }); - battle.fx('zone', { x: a.x, y: a.y, id: unit.id, r: RING1, duration: dur }); - } - }, - }), - }), - skill: { - kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant', - onStart({ battle, unit }) { - const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); - sortEnemyTargets(battle, unit, list, null); - let t = list.find((e) => !e.isFlying) ?? list[0]; - if (!t) { - // PRTS 备注: no enemy in range → thrown at the farthest tile straight ahead inside her range - let best = null; - for (const k of unit.rangeKeys || []) { - const r = Math.floor(k / COLS), c = k % COLS; - if (!battle.grid.inRect(r, c)) continue; - const [lat, d] = toLocal(r - unit.tileR, c - unit.tileC, unit.dir); - if (lat !== 0) continue; - if (d > 0 && (!best || d > best.d)) best = { d, x: c, y: r }; - } - if (!best) return; - t = best; - } - const extra = battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0; - // it drifts away from her deployment tile ("移动方向始终为远离棘刺部署位置中心的方向") - const dx = t.x - unit.x, dy = t.y - unit.y, len = Math.hypot(dx, dy) || 1; - const z = { x: t.x, y: t.y, vx: (dx / len) * speed, vy: (dy / len) * speed, t: 0, acc: 0, dur: baseDur + extra }; - (unit.mem.zones ??= []).push(z); - battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: r0, duration: z.dur }); - }, - }, - talents: [ - { install(battle, unit) { permBuff(battle, unit, 'thorn2:mind', { atkPct: num(t0.atk) }); } }, - { install(battle, unit) { // 视界 - const cnt = num(t1.cnt, 6); - const ally = num(t1.attack_speed_ally), allyX = num(t1.attack_speed_ally_extra), foe = num(t1.attack_speed_enemy), foeX = num(t1.attack_speed_enemy_extra); - whileOn(battle, unit, AURA_IV, () => { - for (const a of battle.alliesFor(unit)) { - const v = ally + (straightRun(battle, a.tileR, a.tileC) >= cnt ? allyX : 0); - if (v) battle.addBuff(a, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } }); - } - for (const e of battle.enemies) { - if (!e.alive || e.hidden) continue; - const v = foe + (straightRun(battle, Math.round(e.y), Math.round(e.x)) >= cnt ? foeX : 0); - if (v) battle.addBuff(e, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } }); - } - }); - } }, - ], - install(battle, unit) { - const sp = num(tm.sp_recovery_per_sec); - if (sid === 'skchr_thorn2_3') { // S3 被动效果:攻击范围扩大 (her own range, so also the DEFAULT trigger range) - const g = skillGrid(chess, def); - if (g) { unit.rangeGrid = g; battle.refreshRange(unit); } - } - // alchemy units keep working after she falls (they are already thrown) - battle.every(AURA_IV, () => { - const zones = unit.mem.zones; - if (!zones || !zones.length) return; - const R = battle.rect; - const seaHits = new Map(); - for (const z of zones) { - z.t += AURA_IV; - z.acc += AURA_IV; - if (z.type) { tickSpecial(battle, unit, z, seaHits); continue; } - z.x = Math.max(R.c0, Math.min(R.c1, z.x + z.vx * AURA_IV)); - z.y = Math.max(R.r0, Math.min(R.r1, z.y + z.vy * AURA_IV)); - const r = r0 + grow * z.t; - const foes = battle.foesInRadius(z.x, z.y, r).filter((e) => !e.isFlying); - for (const e of foes) battle.addBuff(e, { key: 'thorn2:rot', duration: AURA_DUR, mods: { healingTakenMul: healMul } }); - if (z.acc >= 1 - 1e-9) { - z.acc -= 1; - for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] }); - const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr); - if (heal > 0) for (const a of battle.alliesInRadius(z.x, z.y, r, null)) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true }); - battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t) }); - } - } - for (const [e, steps] of seaHits) { // S3 debuff: 不叠加 — the strongest zone wins - if (!e.alive) continue; - battle.addBuff(e, { key: 'thorn2:sea', duration: AURA_DUR, visible: true, mods: mods({ - atkPct: ramp(bb.atk, bb.atk_per_interval, bb.max_atk, steps), - defPct: ramp(bb.def, bb.def_per_interval, bb.max_def, steps), - resMul: 1 + ramp(bb.magic_resistance, bb.magic_resistance_per_interval, bb.max_magic_resistance, steps), - }) }); - } - for (const z of zones) { - if (!z.burn) continue; - for (const e of z.burn.foes) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * z.burn.scale, type: 'arts', isSkill: true, tags: ['skill', 'mySea'] }); - z.burn = null; - } - unit.mem.zones = zones.filter((z) => z.t < z.dur - 1e-9); - if (chess?.isGolden && sp > 0 && on(unit) && unit.mem.zones.length) battle.addBuff(unit, { key: 'thorn2:module', duration: AURA_DUR, mods: { spRecoveryFlat: sp } }); - }, { owner: unit }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 白面鸮 — ringhealer. S2 脑啡肽 (33/36 s): skill range, BAT −1.8/−1.9 s. T1 技力光环: all allies +0.3 SP/s (highest - // wins). Module (elite): 攻击范围扩大. - chess_char_5_16_a: (bb, chess, def) => { - const t0 = talent(chess, 0); - return { - skill: { - kind: 'duration', heal: true, - mods: mods({ batPct: batPct(bb.base_attack_time, chess) }), - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - }, - talents: [{ install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec), () => true); } }], - install(battle, unit) { moduleRangeUp(battle, unit, chess); }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 山 — S2 横扫架势 (stance toggle): DEF −30 %, range = own tile, ATK +35 %, block +1, hits every blocked enemy, 4 % max HP/s - // regen. T1 巨力重拳: 20 % chance ×1.6 ATK and target ATK −15 % for 3 s. T2 强壮肉体: DEF +10 %, 15 % phys dodge. - // Module (elite): ASPD +10 above 50 % HP. - // S1 左勾扫拳 (instant, attack SP): next attack atk_scale × ATK on max_target enemies. S3 震地碎岩击 (duration): skill - // range, BAT +0.7 s, ATK +, double hits on up to attack@max_target enemies, each pushed (中等力度); T1 chance → - // talent@prob. Module FGT-X (elite): 15 % physical dodge. - chess_char_5_17_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); - const sid = selectedId(chess, def); - const critScale = num(t0.atk_scale, 1); - const critProb = (unit) => (sid === 'skchr_f12yin_3' && unit.skill?.active ? num(bb['talent@prob'], num(t0.prob)) : num(t0.prob)); - return { - skills: lazySkills({ - skchr_f12yin_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), maxTargets: Math.max(1, num(bb.max_target, 2)) } }), - skchr_f12yin_3: () => ({ - kind: 'duration', - mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), - targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 3)) }), - attack: { - hits: 2, - onEachHit({ battle, unit, target, kind }) { - if (kind !== 'main' || !target || !target.alive || target.side !== 'enemy') return; - // "中等力度地推动" (PRTS 备注: on the 2nd hit of each attack — onEachHit runs after both): a radial push by - // the official 力度 − 重量 distance (Battle.push) - battle.push(target, num(bb['attack@force'], 1), { from: unit }); - }, - }, - }), - }), - skill: { - kind: 'toggle', - mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }), - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - attack: { hitAllBlocked: true }, - }, - trait: { - dmgMul: (b, u) => (u.mem.crit ? critScale : 1), - afterHit: (b, u, t) => { - if (!u.mem.crit || !t || !t.alive || !num(t0.atk)) return; - b.addBuff(t, { key: 'f12yin:punch', duration: num(t0.duration, 3), mods: { atkPct: num(t0.atk) }, visible: true, status: 'weaken' }); - }, - }, - talents: [ - { install(battle, unit) { // 巨力重拳: one roll per attack - battle.on('beforeAttack', (c) => { - if (c.attacker !== unit) return; - const p = critProb(unit); - unit.mem.crit = p > 0 && battle.rng.chance(p); - if (unit.mem.crit) battle.fx('crit', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - } }, - { install(battle, unit) { permBuff(battle, unit, 'f12yin:body', { defPct: num(t1.def), dodgePhys: num(t1.prob) }); } }, - ], - install(battle, unit) { - // FGT-X: 拥有15%的物理闪避 (an own dodge source: rolls independently of 强壮肉体) - if (num(tb.prob) > 0) permBuff(battle, unit, 'f12yin:moduleX', { dodgePhys: num(tb.prob) }); - const aspd = num(tb.attack_speed); - if (!aspd) return; - whileOn(battle, unit, 0.2, () => { if (unit.hpRatio > HALF_HP) battle.addBuff(unit, { key: 'f12yin:module', duration: 0.3, mods: { aspd } }); }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 百炼嘉维尔 — S2 链锯强袭 (33/36 s): skill range, ATK/DEF +; hits on unblocked enemies drag them in front of her. - // T1 战地巨斧: ATK/DEF +10 %, +4 % per extra blocked enemy. T2 医学背景: healing received +20 % (+40 % below 50 %). - // Module (elite): ×1.1 vs blocked enemies. - chess_char_5_18_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess); - const force = num(bb['attack@force'], 1); - const blockedMul = num(tb.atk_scale); - return { - skill: { - kind: 'duration', - mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt) }), - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - attack: { - onHit({ battle, unit, target }) { - if (!target || !target.alive || target.side !== 'enemy' || target.blockedBy) return; - battle.pullToFront(target, unit, force); // the official 力度 − 重量 pull (Battle.pullToFront) - }, - }, - }, - trait: blockedMul > 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedMul : 1) } : undefined, - talents: [ - { install(battle, unit) { // 战地巨斧 - const apply = () => { - const extra = Math.max(0, unit.blocking.length - 1); - if (unit.mem.axeN === extra && unit.findBuff('gvial2:axe')) return; - unit.mem.axeN = extra; - battle.addBuff(unit, { key: 'gvial2:axe', mods: mods({ atkPct: num(t0.atk) + num(t0.atk_add) * extra, defPct: num(t0.def) + num(t0.def_add) * extra }) }); - }; - battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.axeN = -1; apply(); } }, { owner: unit }); - whileOn(battle, unit, 0.2, apply); - } }, - { install(battle, unit) { // 医学背景 - battle.on('heal', (c) => { - if (c.target !== unit) return; - c.amount *= unit.hpRatio < num(t1.hp_ratio, 0.5) ? num(t1.heal_scale_2, 1) : num(t1.heal_scale_1, 1); - }, { owner: unit }); - } }, - ], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 玛恩纳 — librator (ramp to +200 % ATK while idle). S3 未照耀的荣光 (26 s, CUSTOM_RANGE): skill range, trait ×2, 5 - // targets at 125/150 % ATK phys, air units too (PRTS 备注 "※可对空"; player report B4 after 0.1.0: on a flying wave the - // cast hit nothing for 26 s); enemies in range take +10/11 % of his ATK true damage from every Kazimierz attack. Per - // PRTS 备注 the trait bonus drops by 10 % (per_kill_reduce, absolute: +400 % → +390 %) for each enemy knocked out by - // his own attack (or the damage it carries — not 无动于衷's reflection, not a mark another operator's attack set off), - // settled after that attack, never below +0 %. T1 游侠: ×1.1 ATK on attacks (×1.15 and −15 % damage taken with ≥3 - // enemies around). T2 无动于衷: taunt +1, Kazimierz ops reflect 15 % of his ATK as true damage when attacked. - // S1 未声张的怒火 (duration, SEARCH): attacks attack@atk_scale × ATK, DEF +. S2 未宽解的悲哀 (duration): skill range, - // BAT +0.3 s, attacks attack@atk_scale × ATK twice; a kill of his own attacks during the skill keeps the trait ramp - // when it ends. S1 / S2 have no 对空 note: ground only, like his trait. - chess_char_5_19_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1); - const sid = selectedId(chess, def); - const up = num(bb.trait_up, 1), perKill = num(bb.per_kill_reduce); - // S3: the trait bonus = ramp × trait_up + per_kill_reduce × kills, ≥ 0 — the trait's own ramp buff stays, the - // difference goes into `mlynar:traitUp` (negative once the kills take the bonus below the ramp) - const applyUp = (battle, unit) => { - const ramp = num(unit.trait.ramp); - const extra = unit.mem.mlyUp ? Math.max(0, ramp * up + perKill * num(unit.mem.mlyKills)) - ramp : 0; - if (extra) battle.addBuff(unit, { key: 'mlynar:traitUp', mods: { atkPct: extra } }); - else battle.removeBuff(unit, 'mlynar:traitUp'); - }; - /** - * "仅自身普通攻击(与该次攻击附带的伤害)击倒非角色类单位" (PRTS S2 / S3 备注): `onKill(victim)` for each enemy he - * knocks out, while his skill runs, that his current attack hit (or the mark it set off, tagged 'mlynarOwn') — so a - * kill by the damage that attack carries (天马之枪, the 卡西米尔 bond's true damage, the mark) counts too, in any - * hook order; `onAttack()` after each of his attacks ("加成降低于当次攻击后统一结算"), which also closes the set. - * 无动于衷's reflection and a mark another operator's attack set off happen outside his attack: never counted. - */ - const ownKills = (battle, unit, onKill, onAttack = null) => { - const hit = new Set(); - battle.on('damaged', (c) => { - if (c.source === unit && (c.dmg?.isAttack || (c.dmg?.tags || []).includes('mlynarOwn'))) hit.add(c.target); - }, { owner: unit, priority: 1000 }); - battle.on('kill', (c) => { if (c.killer === unit && hit.has(c.victim) && c.victim.side === 'enemy' && unit.skill?.active) onKill(c.victim); }, { owner: unit }); - battle.on('attack', (c) => { if (c.attacker !== unit) return; hit.clear(); if (onAttack) onAttack(); }, { owner: unit }); - }; - return { - skills: lazySkills({ - skchr_mlynar_1: () => ({ kind: 'duration', mods: mods({ defPct: num(bb.def) }), attack: { atkScale: num(bb['attack@atk_scale'], 1) } }), - skchr_mlynar_2: () => ({ - kind: 'duration', mods: mods({ batPct: batPct(bb.base_attack_time, chess) }), - targeting: skillRange(chess, def), - attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: 2 }, - }), - }), - skill: { - kind: 'duration', - targeting: { ...(skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : {}), canHitFly: true }, - attack: { atkScale: num(bb['attack@atk_scale'], 1), maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) }, - onStart({ battle, unit }) { - unit.mem.mlyUp = true; unit.mem.mlyKills = 0; unit.mem.mlyPending = 0; - applyUp(battle, unit); - battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 2, skill: 'mlynar' }); - }, - onEnd({ battle, unit }) { unit.mem.mlyUp = false; unit.mem.mlyPending = 0; battle.removeBuff(unit, 'mlynar:traitUp'); }, - }, - trait: { dmgMul: (b, u) => (num(u.mem.mlyNear) >= num(t0.cnt, 3) ? num(t0.atk_scale_up, 1) : num(t0.atk_scale_base, 1)) }, - talents: [ - { install(battle, unit) { // 游侠 - const dr = num(t0.damage_resistance); - whileOn(battle, unit, 0.2, () => { - unit.mem.mlyNear = battle.foesInRadius(unit.x, unit.y, AROUND_RADIUS).length; - if (dr > 0 && unit.mem.mlyNear >= num(t0.cnt, 3)) battle.addBuff(unit, { key: 'mlynar:ranger', duration: 0.3, mods: { dmgTakenMul: 1 - dr } }); - }); - } }, - { install(battle, unit) { // 无动于衷 - permBuff(battle, unit, 'mlynar:unmoved', { taunt: num(t1.taunt_level) }); - const scale = num(t1.atk_scale); - if (!(scale > 0)) return; - battle.on('damaged', (c) => { - const a = c.target, src = c.source; - if (a.side !== 'ally' || !isOp(a) || !isKazimierz(a) || !src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return; - if (!on(unit) || leaderOf(battle, unit) !== unit) return; - battle.dealDamage(unit, src, { amount: unit.s.atk * scale, type: 'true', canDodge: false, tags: ['talent', 'reflect'] }); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - if (sid === 'skchr_mlynar_2') { - // 技能期间若击倒敌人,技能结束时特性效果不重置: the librator trait resets the ramp on skillEnd (priority 0) — - // remember it before and put it back after - battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.mlyKeep = false; }, { owner: unit }); - ownKills(battle, unit, () => { unit.mem.mlyKeep = true; }); - battle.on('skillEnd', (c) => { - if (c.unit === unit) unit.mem.mlyRamp = unit.mem.mlyKeep && c.reason !== 'death' ? num(unit.trait.ramp) : null; - }, { owner: unit, priority: 100 }); - battle.on('skillEnd', (c) => { - if (c.unit !== unit || unit.mem.mlyRamp == null) return; - unit.trait.ramp = unit.mem.mlyRamp; - unit.mem.mlyRamp = null; - if (unit.trait.ramp > 0 && unit.alive) battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } }); - }, { owner: unit, priority: -100 }); - } - if (sid && sid !== 'skchr_mlynar_3') return; - ownKills(battle, unit, () => { unit.mem.mlyPending = num(unit.mem.mlyPending) + 1; }, () => { - if (!unit.mem.mlyPending || !unit.mem.mlyUp) return; - unit.mem.mlyKills = num(unit.mem.mlyKills) + unit.mem.mlyPending; - unit.mem.mlyPending = 0; - applyUp(battle, unit); - }); - const extra = num(bb.atk_scale); - if (!(extra > 0)) return; - battle.on('damaged', (c) => { - const src = c.source, e = c.target; - if (!src || src.side !== 'ally' || !isOp(src) || !isKazimierz(src) || !c.dmg?.isAttack || c.type === 'element') return; - if (e.side !== 'enemy' || !e.alive || !unit.skill?.active || !on(unit) || !inRange(unit, e)) return; - const tags = src === unit ? ['skill', 'mlynarMark', 'mlynarOwn'] : ['skill', 'mlynarMark']; - battle.dealDamage(unit, e, { amount: unit.s.atk * extra, type: 'true', canDodge: false, isSkill: true, tags }); - }, { owner: unit }); - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 安洁莉娜 — slower (no normal attack while the skill is off). S3 秘杖·反重力模式 (14/18 s, SEARCH): every enemy on the - // field is weightless, skill range, ATK +, 4/5 targets. T1 加速力场: all allies ASPD +7. T2 兼职工作: while the skill is - // off, all allies regenerate 20 HP/s. Module (elite): longer sluggish (trait bb, profession tunables). - // S1 秘杖·速充模式 (duration, attack SP): ATK +; she attacks normally with S1 ("技能未开启时无法普通攻击" is only in the - // S2/S3 texts). S2 秘杖·微粒模式 (duration, SEARCH): attack interval ×base_attack_time ("极大幅度缩短": a positive value - // described as a shortening is the new interval ratio, tier1 batMod convention), each attack damage_scale × ATK arts. - // Module DEC-X (elite): SP +0.2/s with an enemy in range. - chess_char_5_20_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); - const sid = selectedId(chess, def); - const ratioBat = (v) => (num(v) > 0 && num(v) < 1 ? num(v) - 1 : batPct(v, chess)); - const weightless = (battle, unit) => { - const left = Math.max(0.1, num(unit.skill?.timeLeft, 1)); - for (const e of battle.enemies) { - if (!e.alive || e.findBuff('aglina:weightless')) continue; - // 失重: weight −1 level (engine mod `massFlat`; massLevel never drops below 0) - battle.addBuff(e, { key: 'aglina:weightless', status: 'weightless', visible: true, duration: left, data: { src: unit }, mods: { massFlat: -WEIGHTLESS_MASS } }); - } - }; - return { - skills: lazySkills({ - skchr_aglina_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk) }) }), - skchr_aglina_2: () => ({ kind: 'duration', mods: mods({ batPct: ratioBat(bb.base_attack_time) }), attack: { atkScale: num(bb.damage_scale, 1) } }), - }), - skill: { - kind: 'duration', - mods: mods({ atkPct: num(bb.atk) }), - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - attack: { maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) }, - onStart({ battle, unit }) { unit.mem.aglAcc = 0; weightless(battle, unit); battle.fx('weightless', { x: unit.x, y: unit.y, id: unit.id }); }, - onTick({ battle, unit, dt }) { unit.mem.aglAcc += dt; if (unit.mem.aglAcc >= AURA_IV) { unit.mem.aglAcc = 0; weightless(battle, unit); } }, - onEnd({ battle, unit }) { - for (const e of battle.enemies) { const b = e.findBuff('aglina:weightless'); if (b && b.data.src === unit) battle.removeBuff(e, b); } - }, - }, - trait: sid === 'skchr_aglina_1' ? undefined : { noAttackUnlessSkill: true }, - install(battle, unit) { enemySpUp(battle, unit, 'aglina:module', num(tm.sp_recovery_per_sec)); }, - talents: [ - { install(battle, unit) { // 加速力场 - const aspd = num(t0.attack_speed); - if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); }); - } }, - { install(battle, unit) { // 兼职工作 - const hp = num(t1.hp_recovery_per_sec); - // an HP-regeneration attribute, so 禁疗 does not stop it — PRTS 备注 "生命回复的提供方式为增加目标的'生命回复速度'属性, - // 不受治疗加成和禁疗影响" (异常效果 禁疗: "增减生命回复速度…的效果不会被识别为治疗类能力"); a 孤立 unit (炎佑) is not - // selected (Battle.alliesFor) - if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); }); - } }, - ], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 寒檀 — S2 “女巫之泪” (15 s): skill range, BAT −2.4 s; attacks become icicles on random tiles of the range (cycling left - // row → right row → own row): 65/75 % ATK arts + 1 s cold to every enemy within 1.5 of the impact. T1 生于冰寒: after 20 s on the field ATK +15 % and 抵抗 (negative - // statuses on her last half as long). - chess_char_5_21_a: (bb, chess, def) => { - const t0 = talent(chess, 0); - const scale = num(bb['attack@atk_scale'], 1), cold = num(bb['attack@cold']); - const resist = Math.min(1, Math.max(0, -num(t0.one_minus_status_resistance))); // share of the duration removed - return { - // S1 迅捷打击·γ型 (duration): ATK +, ASPD +. - skills: lazySkills({ - 'skcom_quickattack[3]': () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }), - }), - skill: { - kind: 'duration', - mods: mods({ batPct: batPct(bb.base_attack_time, chess) }), - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - attack: { noAttack: true }, - onStart({ unit }) { unit.mem.iceCd = 0; unit.mem.iceRow = 0; }, - onTick({ battle, unit, dt }) { - unit.mem.iceCd -= dt; - if (unit.mem.iceCd > 0 || !unit.canAct || unit.s.flags.disarm) return; - if (!battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) return; - unit.mem.iceCd = unit.s.interval; - // PRTS 备注: icicles fall on a random tile of ① the row on her left, ② the row on her right, ③ her own row, … - let k = null; - for (let i = 0; i < ICICLE_ROWS.length && k == null; i++) { - const idx = (num(unit.mem.iceRow) + i) % ICICLE_ROWS.length; - const lat = ICICLE_ROWS[idx]; - const keys = (unit.rangeKeys || []).filter((x) => toLocal(Math.floor(x / COLS) - unit.tileR, x % COLS - unit.tileC, unit.dir)[0] === lat); - if (!keys.length) continue; - k = battle.rng.pick(keys); - unit.mem.iceRow = (idx + 1) % ICICLE_ROWS.length; - } - if (k == null) return; - const r = Math.floor(k / COLS), c = k % COLS; - unit.lastAttackAt = battle.time; - unit.stats.attacks++; // "攻击变为…召唤冰凌": every icicle is one of her attacks - battle.fx('iceSpike', { x: c, y: r, id: unit.id, r: ICICLE_RADIUS }); - battle.after(ICICLE_DELAY, () => { - const hit = []; - for (const e of battle.foesInRadius(c, r, ICICLE_RADIUS)) { - battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'iceSpike'] }); - if (e.alive && cold > 0) battle.applyStatus(e, 'cold', { duration: cold, source: unit }); - if (e.alive) hit.push(e); - } - // an icicle is an attack: "攻击时" effects (items, bonds) see it with the enemies it hit - if (hit.length && battle.hasHook('attack')) battle.emit('attack', { attacker: unit, targets: hit, isSkill: true }); - }, { owner: unit }); - }, - }, - talents: [{ install(battle, unit) { // 生于冰寒 - const after = num(t0.interval, 20); - // + 抵抗 (engine `resist` status: new control statuses last half as long, never compounding with other sources) - whileOn(battle, unit, 0.5, () => { - if (battle.time - unit.deployedAt < after - 1e-9 || unit.findBuff('sntlla:born')) return; - battle.addBuff(unit, { key: 'sntlla:born', visible: true, mods: mods({ atkPct: num(t0.atk) }) }); - if (resist > 0) battle.applyStatus(unit, 'resist', { value: resist, source: unit }); - }); - } }], - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 妮芙 — S2 怵然震爆 (1/2 charges): next attack 270/300 % ATK arts + 4/5 s fear on the target and an equal arts splash - // (projectile_range), every hit adding 18/22 % of its damage as apoptosis; targets in an apoptosis burst raise T1 to 70/85 %. - // T1 失魂: hitting an enemy in an apoptosis burst → 40 % ATK elemental damage per second until the burst ends. - // T2 窥心钥: an apoptosis burst in range → ATK +2 % (×10). Module (elite): ×damage_scale vs enemies in a burst. - // S1 笞心击 (duration): ATK +; attacks add attack@ep_damage_ratio of their damage as 凋亡损伤 and, on a target in an - // apoptosis burst, attack@extra_ep_damage_scale × ATK elemental damage. S3 心防溃决 (duration): skill range, ATK +, - // ASPD +, 2 targets; attacks on a target in an apoptosis burst deal elemental damage (split_atk_scale × ATK, no arts). - // Module PRI-Y (elite): SP +0.2/s while an enemy in range is in an element burst. - chess_char_5_22_a: (bb, chess, def) => { - const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1); - const sid = selectedId(chess, def); - const ep = num(bb.ep_damage_ratio, num(bb['attack@ep_damage_ratio'])), skillSoul = num(bb.element_atk_scale), soulBase = num(t0.element_atk_scale); - const soul = (battle, unit, e, scale) => { - const burst = e.findBuff('apoptosisBurst'); - if (!burst || !(scale > 0)) return; - const key = `nymph:soul:${unit.id}`; - const cur = e.findBuff(key); - if (cur) { cur.data.scale = Math.max(num(cur.data.scale), scale); return; } - battle.addBuff(e, { - key, duration: Math.max(0.1, burst.timeLeft), interval: 1, data: { scale }, - onTick: ({ unit: x, buff }) => { - if (!x.findBuff('apoptosisBurst')) { battle.removeBuff(x, buff); return; } - elementHit(battle, unit, x, unit.s.atk * num(buff.data.scale), 'nymphSoul', 'apoptosis'); - }, - }); - battle.fx('soul', { x: e.x, y: e.y, id: e.id }); - }; - return { - skills: lazySkills({ - skchr_nymph_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk) }) }), - skchr_nymph_3: () => ({ - kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), - targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 2)) }), - attack: { atkScale: num(bb['attack@split_atk_scale'], 1) }, - }), - }), - skill: { - kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant', - attack: { - atkScale: num(bb.atk_scale, 1), splashRadius: num(bb.projectile_range, 1.5), - onHit({ battle, unit, target, x, y }) { - if (target && target.alive) battle.applyStatus(target, 'fear', { duration: num(bb.fear), source: unit }); - battle.fx('aoe', { x, y, id: unit.id, r: num(bb.projectile_range, 1.5), skill: 'nymph' }); - }, - }, - }, - talents: [ - { install(battle, unit) { // 失魂 (+ the skill's apoptosis rider and 70 % upgrade) - battle.on('damaged', (c) => { - if (c.source !== unit || c.type === 'element' || c.target.side !== 'enemy' || !c.target.alive || !c.dmg) return; - // (S1 has no attack override: its attacks are the skill's while it runs) - const skillAtk = c.dmg.isSkill || (sid === 'skchr_nymph_1' && !!unit.skill?.active); - // (not on a killing blow: the target is at 0 HP here — no burst on the corpse) - if (skillAtk && c.dmg.isAttack && ep > 0 && c.amount > 0 && hasHp(c.target)) { - battle.dealDamage(unit, c.target, { type: 'element', element: 'apoptosis', amount: c.amount * ep, tags: ['skill', 'nymph'] }); - } - if (c.dmg.isAttack && c.target.alive) soul(battle, unit, c.target, c.dmg.isSkill ? Math.max(skillSoul, soulBase) : soulBase); - }, { owner: unit }); - } }, - { install(battle, unit) { // 窥心钥 - battle.on('elementBurst', (c) => { - if (c.element !== 'apoptosis' || c.target.side !== 'enemy' || !on(unit) || !inRange(unit, c.target)) return; - battle.addBuff(unit, { key: 'nymph:key', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 10)), mods: { atkPct: num(t1.atk) } }); - }, { owner: unit }); - } }, - ], - install(battle, unit) { - burstDamageUp(battle, unit, num(tb.damage_scale)); - burstSpUp(battle, unit, 'nymph:module', num(tm.sp_recovery_per_sec)); - if (sid === 'skchr_nymph_1') { // S1: extra elemental damage on burst targets (the 凋亡 rider is T1's `ep`) - const extra = num(bb['attack@extra_ep_damage_scale']); - battle.on('damaged', (c) => { - if (c.source !== unit || !unit.skill?.active || !c.dmg?.isAttack || c.type === 'element' || c.type === 'elemental') return; - const e = c.target; - if (e.side !== 'enemy' || !e.alive || !e.findBuff('apoptosisBurst') || !(extra > 0)) return; - elementHit(battle, unit, e, unit.s.atk * extra, 'nymphLash', 'apoptosis'); - }, { owner: unit }); - } - if (sid === 'skchr_nymph_3') { // S3: attacks on a burst target become elemental damage - battle.on('hit', (c) => { - if (c.source !== unit || !unit.skill?.active || !c.dmg.isAttack || !c.target || !c.target.findBuff('apoptosisBurst')) return; - if (c.dmg.type !== 'arts' && c.dmg.type !== 'phys') return; - c.dmg.type = 'elemental'; - c.dmg.element = 'apoptosis'; - c.dmg.canDodge = false; - (c.dmg.tags ||= []).push('nymphBreak'); - }, { owner: unit, priority: -10 }); - } - }, - }; - }, - - // --------------------------------------------------------------------------------------------------------------- - // 录武官 — S2 一点关窍 (25 s): ATK +; healed allies get a 10 s buff healing 80/120 HP on every hit taken. - // T1 学成于聚: an op in range starting a skill → +1 SP and ASPD +16 for 8 s. Module (elite): heals below 50 % ×1.15. - // S1 触类旁通 (2/3 charges): the next heal restores heal_scale × ATK and heals max_target allies. - chess_char_5_23_a: (bb, chess, def) => { - const t0 = talent(chess, 0), tb = traitBb(chess); - const dur = num(bb['attack@buff_duration'], 10), value = num(bb['attack@fixed_heal_value']); - return { - skills: lazySkills({ - skchr_reckpr_1: () => ({ kind: instantKind(chess, def), heal: true, attack: { healScale: num(bb.heal_scale, 1), maxTargets: Math.max(1, num(bb.max_target, 2)) } }), - }), - skill: { - kind: 'duration', heal: true, - mods: mods({ atkPct: num(bb.atk) }), - onHit({ battle, unit, target }) { - // "治疗干员后为其施加一个增益": operators only (summons are 友方单位, not 干员) - if (!target || !target.alive || !isOp(target) || !(value > 0)) return; - battle.addBuff(target, { key: 'reckpr:guard', duration: dur, visible: true, data: { src: unit, value } }); - }, - }, - talents: [{ install(battle, unit) { // 学成于聚 - battle.on('skillStart', (c) => { - const a = c.unit; - if (a === unit || !isOp(a) || !on(unit) || !inRange(unit, a)) return; - if (num(t0.prob, 1) < 1 && !battle.rng.chance(num(t0.prob, 1))) return; - unit.skill?.gainSp(num(t0.sp), 'talent'); - if (num(t0.attack_speed)) battle.addBuff(unit, { key: 'reckpr:learn', duration: num(t0.duration, 8), mods: { aspd: num(t0.attack_speed) } }); - }, { owner: unit }); - } }], - install(battle, unit) { - battle.on('damaged', (c) => { - const a = c.target; - if (a.side !== 'ally' || !(c.amount > 0) || a.hp <= 0 || c.type === 'element' || isHpLoss(c.dmg)) return; // (not a 流失) - const b = a.findBuff('reckpr:guard'); - if (b && b.data.src === unit) battle.heal(unit, a, num(b.data.value)); - }, { owner: unit }); - lowHpHealUp(battle, unit, tb); - }, - }; - }, -}; - -export default KITS; +export default TIER_KITS[4]; diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index aff23f24..1b0280a4 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -1,3362 +1,7 @@ -// server/sim/content/kits/tier6.js — hand-authored kits for every tier-6 chess (阶 VI, DIY slots excluded) plus the -// hidden chess granted by effects (盟约·辅助干员 chess_char_1_15, band Pith "优等生"; 妮芙 chess_char_6_10 is the hidden -// tier-6 entry). `export default { [baseChessId]: (bb, chess, def) => Kit }` (docs/SIM.md §7.2). -// -// Every number comes from the blackboards (skill bb at the chess level: normal Lv4 / elite Lv7; talents from -// `def.talents[i].bb`, elite module upgrades included; module-only talent parts from `chess.talents[index −1]`; trait -// module upgrades from `def.traitBb`). A few numbers exist only in the official text; they are parsed from it -// (e.g. 锏 "总计10次斩击", 艾雅法拉 "5连发", 仇白 "额外攻击2个目标", 海嗣 duration). The profession defaults of -// professions.js (bard aura, phalanx guard, chain bounces, funnel ramp, bombarder aftershocks, geek drain, tactician -// reinforcement …) are kept and built upon. -// -// Simplifications (one line per id; see also the report of the content phase): -// 1_15 盟约·辅助干员 every damage she deals attaches all three elements, neural → burn → apoptosis (see pithst). -// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties); -// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight]; -// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked -// out / withdrawn after the end, she fires no further shell (those already in the air still land); -// "wanted" needs a continuous 8 s stay in some 拉特兰 range; wanted targets are added to her range tiles. -// 6_02 圣聆初雪 "诱导" (ba.attract 无法被阻挡并向目标位置移动) = the engine `attract` status for attract_time: the enemy -// is unblockable and walks (own speed, grid path) to the nearest ground tile around her, then waits there; -// snow lives on the ground tiles of her current range; the frozen "保护目标" token is not used (she just -// freezes herself). -// 6_03 余 the fire wall is her column; "crossing" = source and target on opposite sides of it; bullet clearing = -// cancelling a ranged enemy hit that crosses it; talent 2 given to all ops = each op regenerates itself -// (elite: and gets the ×1.14 arts vs burning targets); 庇护 = phys/arts taken ×(1−0.25). -// 6_04 浊心斯卡蒂 S3 HP drain is non-lethal (like the geek drain); 鼓舞 = flat ATK added after the target's multipliers; -// 海嗣 is inert (no attack) and only extends her aura (and her DEFAULT trigger); it expires after its -// duration and is re-summoned after its redeploy time when DP ≥ its cost (auto-redeploy emulation). -// 6_05 异客 storm zone radius 1.5 tiles (not in data); strikes chain like her trait (4 targets, trait falloff); -// the storm stops when she leaves the field. -// 6_06 佩佩 splash stun via `damaged` (isSplash) hook; module ×1.15 when ≥3 enemies in the splash area. -// 6_07 维娜 "attack enemies blocked by allies in talent range" = those enemies' tiles are added to her range; -// S3 puts a 黄金盟誓 on every free deployable melee tile of her talent-1 area (fences too), each for -// the skill duration (the token's maxDeployCount 1 is its hand limit — tokens.js). -// 6_08 焰影苇草 灼痕 = marker (ATK −20 %) + the 法术脆弱 status (同名效果取最高); applied during S3 it lasts until the -// skill ends (duration = remaining skill time). -// 6_09 塑心 cannot normal attack; each skill charge is one attack; 精神逆构 multiplies every apoptosis gauge fill on -// enemies in her range (`elementHit`; several 塑心 do not stack — the strongest applies). -// 6_10 妮芙 失魂 = 元素伤害 ('elemental') per second while the target's apoptosis burst lasts. -// 6_11 缪尔赛思 流形 copies the nearest allied operator of its player on the field (no hand/bench in battle; same pick -// as content/tokens.js) when its own skill starts; it copies the listed stats + range + damage type (not -// splash/chain/multi-hit shapes); clones do not split again. MLYSS_WTRMAN trigger = DEFAULT or an enemy -// in a (copied) 流形's range. An uncopied 流形 never attacks and its copy skill waits (ready) until someone -// can be copied. "被击败后25秒后自动刷新": one pending respawn at a time, only while she is on the field -// and no 流形 of hers stands (her knock-out cancels it; her redeploy re-summons it as her 援军). -// 6_12 迷迭香 "溅射范围扩大": S2 radius 1.5 (PRTS 溅射半径一览; ×1.3 [ASSUMED] until 0.1.1); 感知稳定 picks among the -// owner's deployed casters (none ⇒ no buff). -// 6_13 新约能天使 bombardment radius 1 tile (not in data). -// 6_14 流明 S3 heals an abnormal ally even at full HP (forced heal); 抵抗 = the engine `resist` status. -// 6_15 仇白 入隙 reads the target's sluggish/bind statuses; module adds 10 % ATK arts per hit. -// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the -// 脆弱 status (同名效果取最高). -// 6_17 耀骑士临光 "上一名部署干员" = the op of the same owner deployed right before her (deploy order). -// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events) flying PRTS's 技能流程 (spread attack@times s, chase 2.0 → 4.0 -// tiles/s; [ASSUMED] the turn rate is not modelled: straight at the target); every drone is out, so she -// makes no normal attack herself, while each drone on its target attacks like a normal drone (her attack -// interval, ATK × its own funnel ramp; neither attack nor skill damage — PRTS 备注); 头狼 stage 2 -// "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once more; S3 releases one more drone, -// mid-skill too). Base drone count 1. -// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end; -// S3 slashes and pulls air units too (PRTS 备注 "可对空"; a 静态刚体 — every drone of the mode — is hit -// but stays put: Battle._displaceable). -// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist. -// -// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills` -// ({ [skillId]: SkillSpec }, built from the SELECTED skill's `bb`: normal Lv4 / elite Lv7; triggers from that skill's -// data). Talent / install hooks written for the default skill check `onDefaultSkill(chess)` (余 闲云隐市 for all, 维娜 -// S3 targets, 焰影苇草 灼痕 until the skill ends, 流明 S3 bullets, 溯光星源 S3 locks, 耀骑士临光 S3 true damage, 缪尔赛思 -// S3 bind, 浊心斯卡蒂 tide). Module choices read the loadout-resolved record (trait.bb, talents, hidden module parts); -// the module-specific code: 浊心斯卡蒂 新生代, 维娜 秩序圣“球”, 焰影苇草 “独属自己的一隅”, 塑心 音乐家的旅程, 缪尔赛思 -// 落叶四季, 仇白 欲雪时, 耀骑士临光 “骑士家族”, 锏 新合同, 纯烬艾雅法拉 想要留下的生命 (生息演算 / 集成战略-only parts of -// the RA / IS modules are not modelled). Alternate-skill simplifications: -// 6_01 S2 the aim (disarmed meanwhile) spends its bullet when it begins; target = a wanted enemy (lowest DEF first). -// 6_02 S1 casts with an enemy in her range (data SEARCH read as "search in range" [ASSUMED]; the engine SEARCH = -// any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles); -// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on a protection point (goal tile) reaching -// max snow, deploy attributes ignored, whose snow is used up (PRTS 备注; until 0.1.3 any standable tile froze, never the -// gate); spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second. -// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile, in the cast's -// tick (PRTS: 0.13 s after the damage), leaders included unless 自缚. -// 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代 -// "30点伤害减免" = +30 effective DEF (physical) / −30 ÷ (1 − RES) before mitigation (arts). -// 6_06 S2 the ASPD stacks last the whole battle; S1 "异常状态时可以释放" = cleanse + cast (tick check). -// 6_07 S2 passive counts the OTHER allies of the talent-1 area. -// 6_08 S2 carriers = operators of her range (ground first, then lowest HP); a fireball shoots an enemy of the carrier's -// range (else hers). -// 6_09 S2 partner = the highest-ATK other operator of her range (re-picked every 0.5 s); S3 picks by base stats. -// 6_11 S1 one DP every interval, the remainder when it ends; module 落叶四季 "援军阻挡的敌人更容易受到我方的攻击" = the -// 流形 variant's taunt_level (+1) on the enemies it blocks. -// 6_05 S2 / 6_12 S3 base_attack_time scales the base attack time (akdata "乘算负数"); see batOf. -// 6_12 S1 the extra arts hit lands on the main + splash victims (not the aftershocks). -// 6_13 S1 "主动关闭" = skill.stop(); S3 the 投递坐标 (the player's pick) goes next to the first enemy entering her range -// (at the latest when S3 starts); delivered = the knocked-out non-ranged operator of hers with the latest -// respawnAt; the bombardment radius is AIRSTRIKE_RADIUS. -// 6_14 S1 the HoT covers the allies within 1.5 tiles of the healed target; S2 "蓄力" = cast with every charge full. -// 6_15 S1 the bind-end explosion radius is 1.2 tiles [ASSUMED]. -// 6_16 S1 bounces are full hits within attack@projectile_range, never on the same enemy twice in a row. -// 6_18 S1 "非移动敌人" = blocked / not walking / immobilised enemies; S2 every drone ramps on its own target. -// 6_20 S2 the barrier is one pool over her range at the cast, absorbing gauge fills (before 元素抗性). -// -// Summons: skill summons (黄金盟誓, “耀阳”) are spawned without a kit so content/tokens.js's token kit applies (true -// damage / appear burst / lifetime); the fallbacks here only run when no token kit exists (`kit.fromTokens`). -// Owner-coupled behaviour of placeable summons (海嗣 aura/lifetime/redeploy, 流形 copy/steal/split/respawn) lives in -// these kits — tokens.js skips it for summoners with a hand-authored kit (its `managed()` rule). -// Engine notes: "extra targets" (蕾缪安 wanted, 维娜 S3) use the engine's extra range keys (`battle.setExtraRange`, -// merged into every range rebuild); 海嗣 / 流形 extend their summoner's DEFAULT trigger through -// `skill.addTriggerRange`; element-type damage skips the `hit` hook, so element (损伤) amplification uses the -// `elementHit` hook (`onElementHit`: × dmg.mul before the gauge fill). +// server/sim/content/kits/tier6.js — re-export shim (0.2.0 refactor, R4): the tier-6 kits live one per file in ops/, +// registered by index.js; their shared helpers and notes are in shared/tier6.js (README.md). This module keeps the +// old imports working until the shims are removed after the 0.2.0 refactor wave. +import { TIER_KITS } from './index.js'; -import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy, aggroCmp } from '../../targeting.js'; -import { aggregateMods } from '../../buffs.js'; -import { COLS, ROWS, PULL_STOP_RADIUS, CHAIN_RADIUS } from '../../constants.js'; -import { rotateOffset } from '../../dir.js'; -import { bodyDist, bodyInKeys, bodyKeys } from '../../body.js'; -import { hasHp } from '../../damage.js'; -import { summonToken, TOKEN_IDS } from '../tokens.js'; - -// ------------------------------------------------------------------------------------------------------------------ -// helpers - -const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); -const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); - -/** Blackboard value by exact key, else the first key ending with `.key` / `]key` (prefixed official keys). */ -function bv(bb, key, d = 0) { - if (!bb) return d; - const v = bb[key]; - if (typeof v === 'number' && Number.isFinite(v)) return v; - for (const k of Object.keys(bb)) { - if (k.endsWith('.' + key) || k.endsWith(']' + key)) { - const x = bb[k]; - if (typeof x === 'number' && Number.isFinite(x)) return x; - } - } - return d; -} -const tbb = (def, i) => (def && def.talents && def.talents[i] && def.talents[i].bb) || {}; -const tdesc = (def, i) => String((def && def.talents && def.talents[i] && def.talents[i].description) || ''); -/** Module-only (hidden, index −1) talent parts of a chess record, merged (first occurrence wins). */ -function moduleBb(chess) { - const out = {}; - for (const t of (chess && chess.talents) || []) { - if (!t || t.index !== -1 || !t.bb) continue; - for (const [k, v] of Object.entries(t.bb)) if (!(k in out)) out[k] = v; - } - return out; -} -const parseN = (text, re, d) => { const m = String(text ?? '').match(re); return m ? +m[1] : d; }; -const live = (u) => !!u && u.alive && u.deployed && !u.removed && !u.hidden; -const isElite = (e) => !!e && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS'); -const hasBond = (u, id) => !!(u && u.def && Array.isArray(u.def.bonds) && u.def.bonds.includes(id)); -const keyOf = (u) => Math.round(u.y) * COLS + Math.round(u.x); -const opsOf = (battle, ownerId) => battle.allies(ownerId).filter((a) => a.kind === 'op'); -const ANY = Object.freeze({ canHitFly: true }); -const enemiesIn = (battle, unit, keys) => battle.enemiesInKeys(keys || unit.rangeKeys || [], unit, ANY); -const isTok = (t, id, owner) => !!t && t.kind === 'token' && t.defId === id && t.ownerUnit === owner; - -/** - * Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the - * selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default. - * Talent / install hooks written for the default skill check it (they run under every selected skill). - */ -function onDefaultSkill(chess) { - const d = (chess?.skills ?? []).find((s) => s && s.isDefault); - return !d || !chess?.skill || d.skillId === chess.skill.skillId; -} -/** Id of the selected skill (loadout-resolved record, else the def). */ -const selectedSkill = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null; -/** - * Skill blackboard `base_attack_time` → engine batPct, the rule of the AK damage calculator (akdata attributes.js, - * checked against the game): a shortening (v < 0) is a FLAT change of the base attack time in seconds (送葬人 −0.5 on - * 2.3 s ⇒ 1.8 s, the tier1 `batMod` convention), a lengthening (v > 0) scales it (迷迭香 S2 +0.5 ⇒ ×1.5, 佩佩 S3 +0.2 ⇒ - * ×1.2). `mul` = a skill the calculator lists as "攻击间隔缩短,但是是乘算负数" (异客 S2 −0.3 ⇒ ×0.7, 迷迭香 S3 −0.5 ⇒ ×0.5). - */ -function batOf(v, def, mul = false) { - const x = num(v); - if (!x) return 0; - if (x > 0 || mul) return Math.max(-0.9, x); - const bat = num(def?.stats?.bat, 1) || 1; - return Math.max(-0.9, x / bat); -} -/** Spec kind of an instant skill (charges when the data gives it several). */ -const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant'); -/** The selected skill's own range grid (null: the unit's range). */ -const skillGridOf = (def) => (def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null); - -/** 深海猎人 (character_table groupId "abyssal" — checked against the official table; data/chess.json has no group id). */ -const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']); -/** 莱茵生命 (character_table groupId "rhine") members of this season's pool (checked against the official table). */ -const RHINE = new Set(['char_108_silent', 'char_128_plosis', 'char_202_demkni', 'char_249_mlyss', 'char_1047_halo2']); -/** - * 异常状态 (ba.debuff "包括晕眩、寒冷、冻结等") — the control statuses cleansed by 流明 (抵抗 itself is the engine `resist` - * status: RESIST_STATUSES). Stat debuffs (虚弱/脆弱/…) are not 异常状态. - */ -const ABNORMAL = new Set(['stun', 'freeze', 'cold', 'sleep', 'silence', 'fear', 'attract', 'tremble', 'bind', 'levitate', - 'disarm', 'palsy', 'sluggish', 'slow']); -const hasAbnormal = (u) => u.buffs.some((b) => b.status && ABNORMAL.has(b.status)); -/** Remove every 异常状态 of `u`; returns how many were removed. */ -function cleanseAbnormal(battle, u) { - let n = 0; - for (const b of u.buffs.slice()) if (b.status && ABNORMAL.has(b.status)) { battle.removeStatus(u, b.status); n++; } - if (n) battle.fx('cleanse', { x: u.x, y: u.y, id: u.id }); - return n; -} -/** - * "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its - * route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers stay, and so does a - * 自缚 unit (flag `selfBound`: 守墓石像's 转换模式, the 7 huge leaders, the 胄 parts' anchor): PRTS 余 S2 备注 "处于消失状态的/ - * 持有自缚的单位不视为可达目标" (束缚 alone does not exempt it, so not `noMove`). Being a leader is no exemption: PRTS gives - * 卢西恩 and 假想敌:铳 (both sections) 传送抗性 无 — they are teleported like any ground enemy and walk on from his tile (a - * patrol keeps looping: content/bosses.js patrolLoop). 静态刚体 forbids physical movement, not a teleport (the 备注 names - * only 消失 / 自缚 / 免疫传送); every 静态刚体 of the mode flies or is 自缚 anyway. - */ -function teleportEnemy(battle, unit, e) { - if (!e || !e.alive || e.isFlying || e.s.flags.selfBound) return false; - const r = unit.tileR, c = unit.tileC; - if (!battle.grid.groundPassable(r, c)) return false; - const er = Math.round(e.y), ec = Math.round(e.x); - if ((er !== r || ec !== c) && !battle.grid.findPath(er, ec, r, c)) return false; - e.x = c; e.y = r; - battle._unblock(e); - if (e.route) e.route.pts = null; - battle.fx('teleport', { x: c, y: r, id: e.id, src: unit.id }); - return true; -} -const AROUND8 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 1], [-1, -1], [-1, 0], [-1, 1]]); -const N4 = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]); -/** Every offset of the 19×21 field: "攻击范围扩大至整个战场". */ -const WHOLE_FIELD = (() => { - const g = []; - for (let dr = -(ROWS - 1); dr <= ROWS - 1; dr++) for (let dc = -(COLS - 1); dc <= COLS - 1; dc++) g.push([dr, dc]); - return Object.freeze(g); -})(); - -const STATE = new WeakMap(); -/** Per-battle shared state for kits (trackers registered once per battle). */ -function bstate(battle) { - let s = STATE.get(battle); - if (!s) { s = {}; STATE.set(battle, s); } - return s; -} - -/** - * Free deployable tiles of `grid` around `unit` (melee ground tiles unless `ranged`). Skips `battle.isReservedTile` - * (a living unit, or the home tile of a piece that has not deployed yet / waits to redeploy: a summon parked there - * would keep that operator off the field). - */ -function freeTiles(battle, unit, grid, { ranged = false, ground = true } = {}) { - const out = []; - const own = unit.tileR * COLS + unit.tileC; - for (const k of absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) { - if (k === own) continue; - const r = (k / COLS) | 0, c = k % COLS; - if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue; - if (!battle.grid.canStand(r, c, { ranged })) continue; - if (ground && !battle.grid.groundPassable(r, c, true)) continue; - out.push([r, c]); - } - return out; -} - -/** Tile closest to any living enemy (first tile when there is no enemy). */ -function bestTile(battle, tiles) { - if (!tiles.length) return null; - const en = battle.enemies.filter((e) => e.alive && !e.hidden); - if (!en.length) return tiles[0]; - let best = tiles[0], bd = Infinity; - for (const t of tiles) { - let d = Infinity; - for (const e of en) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0])); - if (d < bd - 1e-9) { bd = d; best = t; } - } - return best; -} - -/** Barrier of `total` HP decaying linearly to 0 over `dur` seconds. */ -function decayingShield(battle, u, key, total, dur) { - if (!(total > 0)) return null; - const decays = dur > 0; - return battle.addBuff(u, { - key, shield: total, duration: decays ? dur : Infinity, visible: true, interval: decays ? 0.5 : 0, - onTick: decays ? ({ unit, buff }) => { buff.shield = Math.max(0, buff.shield - (total * 0.5) / dur); unit.markDirty(); } : null, - }); -} - -/** - * Element (损伤) amplification: element gauge fills skip the `hit` hook but fire `elementHit` { source, target, dmg } - * before the fill — multiply `dmg.mul` there (one instance, exact burst threshold). `fn(ctx)` returns the factor. - */ -function onElementHit(battle, unit, fn) { - battle.on('elementHit', (ctx) => { - if (!ctx.dmg || ctx.dmg.type !== 'element' || !ctx.target) return; - const f = fn(ctx); - if (Number.isFinite(f) && f > 0 && f !== 1) ctx.dmg.mul *= f; - }, { owner: unit }); -} - -/** Deal `amount` of element `el` (元素损伤) — nothing on a target at 0 HP (a lethal hit's `damaged` hook, see hasHp). */ -const elementDmg = (battle, src, tgt, el, amount, tags = ['skill']) => - (amount > 0 && hasHp(tgt) ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0); - -/** - * 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK after the target's own multipliers (flat - * compensated), strongest source wins. Units flagged `mem.noInspire` (浊心斯卡蒂 "自身不受鼓舞影响") never receive it. - */ -function inspire(battle, target, val, src, stat = 'atk') { - if (!(val > 0) || !live(target) || target.mem.noInspire) return; - const key = stat === 'def' ? 'inspire:def' : 'inspire'; - const cur = target.findBuff(key); - if (cur && cur.data && cur.data.src !== src.id && cur.data.val > val && cur.timeLeft > 0.1) return; - const { add, mul } = aggregateMods(target.buffs.filter((b) => b.key !== key)); - const f = stat === 'def' - ? Math.max(0, 1 + (add.defPct ?? 0)) * (mul.defMul ?? 1) - : Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1); - const flat = f > 1e-6 ? val / f : val; - battle.addBuff(target, { key, mods: stat === 'def' ? { defFlat: flat } : { atkFlat: flat }, duration: 0.75, refresh: 'replace', source: src, visible: true, data: { src: src.id, val } }); -} - -/** Deploy-order tracker: last op deployed per owner (read before the update by priority-0 deploy handlers). */ -function ensureDeployTracker(battle) { - const S = bstate(battle); - if (S.lastOp) return S.lastOp; - S.lastOp = new Map(); - battle.on('deploy', ({ unit }) => { if (unit.kind === 'op') S.lastOp.set(unit.ownerId, unit); }, { priority: -100 }); - return S.lastOp; -} - -/** - * Pull `e` with 力度 `force` towards the point / unit `unit` ("向自己中心…拖拽", "拉向目标所在位置"), stopping `stop` tiles - * from it — Battle.pull: the official 力度 − 重量 pull (PRTS 推与拉; user playtest #6 item 14). The default stop is the - * 急停 radius 0.6708 around a pulling unit. A pulling ally is also the pull's `center`, so an enemy it blocks itself - * stays where it is held (as in Battle.pullToFront) — e.g. the 流形 S3 pulse then still stuns it. - */ -function pullToward(battle, unit, e, force, stop = PULL_STOP_RADIUS) { - if (!e || !e.alive || !unit) return 0; - return battle.pull(e, num(force), { to: { x: unit.x, y: unit.y }, center: unit.side === 'ally' ? unit : null, stop }); -} - -/** Stat-aura pulse: short buffs refreshed every `period` s on `targets()`. */ -function aura(battle, unit, period, fn) { - battle.every(period, () => { if (live(unit)) fn(); }, { owner: unit }); -} - -// ------------------------------------------------------------------------------------------------------------------ -// 盟约·辅助干员 chess_char_1_15 (巫役) — S1 战术咏唱·双型; 迭代元素 - -/** 迭代元素's elements in the order they are applied: 神经损伤 "(优先)", then 灼燃损伤, then 凋亡损伤. */ -export const PITHST_ELEMENTS = Object.freeze(['neural', 'burn', 'apoptosis']); - -/** - * 迭代元素 "攻击同时附带18%攻击力的神经损伤(优先)、灼燃损伤、凋亡损伤;优先攻击未处于损伤爆发的目标": - * * timing = every damage she deals (PRTS 盟约·辅助干员 corrects "攻击同时" to "造成伤害时"): HP damage > 0 — a dodged - * or fully absorbed hit deals none; her own element fills and the bursts they cause never re-trigger it; a killing - * blow attaches nothing (the target is dead — no burst on the corpse); - * * each such damage attaches ep_damage_ratio × ATK of ALL THREE elements, 神经 first, then 灼燃, then 凋亡 (user - * playtest #5 #3: the kit used to pick one element "not bursting" — but a burst's 爆发冷却 locks every gauge of the - * unit, so it only ever filled 神经). PRTS 元素: when one unit applies several elements that would each fill their - * gauge, the element applied first bursts — from her alone 神经 bursts (the "(优先)"), while her 灼燃 / 凋亡 add to - * the gauges the team builds (余, 塑心 …). [ASSUMED: simultaneous application — the reading of the official text] - * * elite module RIT-X "对精英和领袖敌人造成的元素损伤提升18%" = ep_damage_ratio_boss (0.2124) vs ELITE / BOSS enemies: - * a talent ratio, not an "元素损伤提升" (PRTS 备注), so it changes nothing else she deals (e.g. a 灼燃维式重锤). - * The target pick prefers enemies not in a burst (`priority: 'notBurst'`). - */ -function pithst(bb, chess, def) { - const t0 = tbb(def, 0); - const ratio = num(t0.ep_damage_ratio); - const ratioBoss = num(t0.ep_damage_ratio_boss, ratio); - return { - skill: { - kind: 'duration', - mods: { aspd: num(bb.attack_speed) }, - targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) }, - }, - trait: { priority: 'notBurst' }, - install(battle, unit) { - if (!(ratio > 0)) return; - battle.on('damaged', (ctx) => { - const t = ctx.target, d = ctx.dmg; - // hasHp: a killing blow attaches nothing (the hook runs before battle.kill, see hasHp) - if (ctx.source !== unit || !t || t.side !== 'enemy' || !hasHp(t) || !(ctx.amount > 0)) return; - if (ctx.type === 'element' || ctx.type === 'elemental' || (d && d.tags && d.tags.includes('burst'))) return; - const amount = unit.s.atk * (isElite(t) ? ratioBoss : ratio); - for (const el of PITHST_ELEMENTS) elementDmg(battle, unit, t, el, amount, ['talent', 'pithst']); - }, { owner: unit }); - }, - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 蕾缪安 chess_char_6_01 (神射手) — S3 礼炮·强制追思; 跨境追缉许可; 逃犯引渡手续 - -/** S3 bombardment: one shell every 0.3 s after the skill ends, for at most 10 s (PRTS S3 note, not in the data). */ -const LEMUEN_SHELL_INTERVAL = 0.3; -const LEMUEN_SHELL_WINDOW = 10; -/** - * Shell flight time [ASSUMED]: the first bombardment lands one interval after the skill ends, so every shell of the - * PRTS count (≤ 33 in the 10 s window) lands inside it; the renderer draws the shell over this time (fx bombardShell). - */ -const LEMUEN_SHELL_FLIGHT = 0.3; - -function ensureWanted(battle) { - const S = bstate(battle); - if (S.wanted) return S.wanted; - const W = S.wanted = { lemuens: new Set(), time: new WeakMap() }; - const activeLem = () => [...W.lemuens].filter(live); - battle.every(0.25, () => { - const lems = activeLem(); - if (!lems.length) return; - const need = Math.min(...lems.map((u) => u.mem.wantedInterval)); - const lat = battle.allyUnits.filter((a) => live(a) && a.kind === 'op' && hasBond(a, 'lateranoShip')); - for (const e of battle.enemies) { - if (!e.alive || e.hidden || !isElite(e) || e.findBuff('lemuen:wanted')) continue; - // "停留超过8秒": a continuous stay — leaving every 拉特兰 range restarts the count - if (!lat.some((a) => a.rangeKeySet && bodyInKeys(e, a.rangeKeySet))) { W.time.delete(e); continue; } - const t = (W.time.get(e) ?? 0) + 0.25; - W.time.set(e, t); - if (t >= need - 1e-9) { - battle.addBuff(e, { key: 'lemuen:wanted', visible: true }); - battle.fx('wanted', { x: e.x, y: e.y, id: e.id }); - } - } - }); - battle.on('hit', (ctx) => { - const s = ctx.source, t = ctx.target; - if (!s || !t || s.side !== 'ally' || t.side !== 'enemy' || !hasBond(s, 'lateranoShip')) return; - if (!ctx.dmg.isAttack && !ctx.dmg.isSkill) return; - if (!t.findBuff('lemuen:wanted')) return; - const lems = activeLem(); - if (lems.length) ctx.dmg.mul *= Math.max(...lems.map((u) => u.mem.wantedScale)); - }); - return W; -} - -function lemuen(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const aim = Math.max(0.05, num(bb['attack@aim_interval'], 0.5)); - const d1 = num(bb['attack@dist_1'], 0.8); - const d2 = num(bb['attack@dist_2'], num(bb['attack@projectile_range'], 1.5)); - const s1 = num(bb['attack@proj_atk_scale_1'], 1), s2 = num(bb['attack@proj_atk_scale_2'], 1); - const pickLock = (battle, unit, locks) => { - const cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); - if (!cands.length) return null; - sortEnemyTargets(battle, unit, cands, 'lowDef'); - const cnt = (e) => locks.reduce((n, L) => n + (L.e === e ? 1 : 0), 0); - let best = null; - for (const e of cands) if (!best || cnt(e) < cnt(best)) best = e; - return best; - }; - // S3 bombardment (PRTS 蕾缪安 S3 note — the timing is not in the blackboard): after the skill ends ONE shell every - // LEMUEN_SHELL_INTERVAL s, in lock order, on a random point of the square of side 2 × emit_offset (PRTS "边长0.4", - // emit_offset 0.2) around its lock mark — the locked enemy while it is on the field ("持续追踪锁定目标"), else the spot - // it left ("锁定标记会留在原地") — landing LEMUEN_SHELL_FLIGHT s later; each shell hits every enemy within dist_2 once - // (limited_hit_time 1): proj_atk_scale_1 × ATK within dist_1 of its point, proj_atk_scale_2 × ATK beyond, all with the - // ATK cached when the skill ended ("缓存攻击力"); at most LEMUEN_SHELL_WINDOW s of shells ("最多产生33次轰炸"). - const spread = Math.max(0, num(bb['attack@emit_offset'], 0.2)); - const markOf = (L) => { - const e = L.e; - if (!L.gone && (!e.alive || !e.hidden)) { L.x = e.x; L.y = e.y; if (!e.alive) L.gone = true; } - return L; - }; - const blast = (battle, unit, atk, x, y) => { - battle.fx('bombard', { x, y, id: unit.id, r: d2 }); - for (const e of battle.foesInRadius(x, y, d2)) { - if (e.s.flags.untargetable) continue; - const d = bodyDist(e, x, y); - battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] }); - } - }; - const bombard = (battle, unit, locks) => { - const atk = unit.s.atk; - const seq = unit.deploySeq; - const n = Math.min(locks.length, Math.floor(LEMUEN_SHELL_WINDOW / LEMUEN_SHELL_INTERVAL + 1e-9)); - const fire = (i) => { - // knocked out / withdrawn after the skill ended: the shells not fired yet are dropped (those in the air land) - if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return; - const L = markOf(locks[i]); - // the random offset is drawn in her facing frame (the square turns onto itself): a turned board plays alike - const [oy, ox] = rotateOffset(battle.rng.range(-spread, spread), battle.rng.range(-spread, spread), unit.dir); - const x = L.x + ox, y = L.y + oy; - battle.fx('bombardShell', { x, y, id: unit.id, r: d2, t: LEMUEN_SHELL_FLIGHT, i }); - battle.after(LEMUEN_SHELL_FLIGHT, () => blast(battle, unit, atk, x, y), { owner: unit }); - if (i + 1 < n) battle.after(LEMUEN_SHELL_INTERVAL, () => fire(i + 1), { owner: unit }); - }; - if (n > 0) fire(0); - }; - // S2 归乡邀约: aimed snipe at a wanted enemy — ATK scale ramps main → fin by ex every interval (at most trig_cnt - // steps, aim_duration s), fired early once ATK × scale > the target's HP + DEF; ignores dodge; one bullet per aim - const aimS = { - main: num(bb['attack@main_atk_scale'], 1), ex: num(bb['attack@ex_atk_scale']), fin: num(bb['attack@fin_atk_scale'], 1), - iv: Math.max(0.05, num(bb['attack@interval'], 0.25)), steps: Math.floor(num(bb['attack@trig_cnt'], 10)), dur: num(bb['attack@aim_duration'], 2.5), - }; - const endAim = (battle, unit) => { unit.mem.lemAim = null; battle.removeBuff(unit, 'lemuen:aim'); }; - const skills = { - // S1 重逢问候: ammo 5, attacks at attack@atk_scale × ATK on one extra target - skchr_lemuen_1: { - kind: 'ammo', - ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))), - attack: { atkScale: num(bb['attack@atk_scale'], 1) }, - targeting: { maxTargets: 2 }, - }, - skchr_lemuen_2: { - kind: 'ammo', - ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 6))), - mods: { aspd: num(bb.attack_speed), atkPct: num(bb.atk) }, - onStart({ battle, unit }) { endAim(battle, unit); }, - onTick({ battle, unit, skill, dt }) { - const m = unit.mem; - let A = m.lemAim; - if (A && (!A.e.alive || A.e.hidden || !A.e.findBuff('lemuen:wanted'))) { endAim(battle, unit); A = null; } - if (!unit.canAct) return; - if (!A) { - const cands = battle.enemies.filter((e) => e.alive && !e.hidden && e.findBuff('lemuen:wanted') && canTargetEnemy(unit, e, ANY)); - if (!cands.length || skill.ammoLeft <= 0) return; - sortEnemyTargets(battle, unit, cands, 'lowDef'); - A = m.lemAim = { e: cands[0], t: 0 }; - battle.addBuff(unit, { key: 'lemuen:aim', flags: { disarm: true } }); // aiming: no normal attack meanwhile - skill.ammoLeft--; // "消耗子弹对其瞄准" - battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); - battle.fx('lock', { x: A.e.x, y: A.e.y, id: A.e.id, src: unit.id }); - } - A.t += dt; - const scale = Math.min(aimS.fin, aimS.main + aimS.ex * Math.min(aimS.steps, Math.floor((A.t + 1e-9) / aimS.iv))); - const e = A.e; - if (A.t + 1e-9 < aimS.dur && !(unit.s.atk * scale > e.hp + e.s.def)) return; - endAim(battle, unit); - battle.fx('crit', { x: e.x, y: e.y, id: e.id, src: unit.id }); - battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', canDodge: false, isSkill: true, tags: ['skill', 'snipe'] }); - if (skill.active && skill.ammoLeft <= 0) skill.end('ammo'); - }, - onEnd({ battle, unit }) { endAim(battle, unit); }, - }, - }; - return { - skills, - skill: { - kind: 'ammo', - ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))), - attack: { noAttack: true }, - onStart({ unit }) { unit.mem.lemLocks = []; unit.mem.lemAcc = aim; }, - onTick({ battle, unit, skill, dt }) { - const m = unit.mem; - if (!m.lemLocks) return; - for (const L of m.lemLocks) markOf(L); - if (!unit.canAct) return; - m.lemAcc += dt; - if (m.lemAcc + 1e-9 < aim) return; - m.lemAcc -= aim; - const e = pickLock(battle, unit, m.lemLocks); - if (!e) { if (m.lemLocks.length) skill.end('ammo'); return; } - m.lemLocks.push({ e, x: e.x, y: e.y }); - battle.fx('lock', { x: e.x, y: e.y, id: e.id, src: unit.id }); - skill.ammoLeft--; - battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); - if (skill.active && skill.ammoLeft <= 0) skill.end('ammo'); - }, - onEnd({ battle, unit, reason }) { - // the shells follow one by one after the end (bombard); knocked out / withdrawn mid-lock ('death'): none - const locks = unit.mem.lemLocks || []; - unit.mem.lemLocks = null; - if (reason !== 'death' && unit.alive) bombard(battle, unit, locks); - }, - }, - // elite module: "攻击的敌人未被击倒时自身额外获得1点技力" - trait: num(tb.sp) > 0 ? { - afterHit(battle, unit, target) { if (target && target.alive && unit.skill && !unit.skill.active) unit.skill.gainSp(num(tb.sp), 'trait'); }, - } : null, - talents: [ - { install(battle, unit) { // 跨境追缉许可 - unit.mem.wantedInterval = num(t0.interval, 8); - unit.mem.wantedScale = num(t0.damage_scale, 1); - ensureWanted(battle).lemuens.add(unit); - let sig = null; - battle.on('tick', () => { // wanted targets' tiles join her range (engine extra range keys, kept across rebuilds) - if (!live(unit)) return; - const keys = []; - for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('lemuen:wanted')) keys.push(...bodyKeys(e)); - const s = keys.join(','); - if (s === sig) return; - sig = s; - battle.setExtraRange(unit, keys); - }, { owner: unit }); - } }, - { install(battle, unit) { // 逃犯引渡手续 - const iv = num(t1.interval, 20), atk = num(t1.atk), add = Math.floor(num(t1.add_count)), exAdd = Math.floor(num(t1.ex_add_count)); - const ready = () => live(unit) && battle.time - unit.deployedAt >= iv - 1e-6; - battle.on('deploy', ({ unit: u }) => { - if (u !== unit) return; - const seq = unit.deploySeq; - battle.after(iv, () => { - if (!live(unit) || unit.deploySeq !== seq) return; - if (atk) battle.addBuff(unit, { key: 'lemuen:extradition', mods: { atkPct: atk } }); - battle.fx('talent', { x: unit.x, y: unit.y, id: unit.id, name: 'extradition' }); - }, { owner: unit }); - }, { owner: unit }); - battle.on('skillStart', ({ unit: u, skill }) => { - if (!ready() || !skill || skill.kind !== 'ammo') return; - if (u === unit) { if (add > 0) skill.addAmmo(add); } - else if (exAdd > 0 && u.side === 'ally' && u.kind === 'op' && hasBond(u, 'lateranoShip')) skill.addAmmo(exAdd); - }, { owner: unit }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 圣聆初雪 chess_char_6_02 (阵法术师) — S3 群山俯首; 无垠的雪景; 圣山的祝福; module 千分之一的心 - -function sbell2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess); - const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; - const maxL = Math.max(1, Math.floor(num(t0.max_cast_cnt, 5))); - const iceId = (chess?.tokens || []).find((t) => /icetgt/.test(String(t))) || 'token_10058_sbell2_icetgt'; - /** - * S2 霜涛覆岭 while it runs (`unit.mem.sbellS2`): a layer landing on a tile already at max snow spreads one layer to - * a neighbouring ground tile (at most max_cast_tile_count spreads per activation); ground enemies on snow take - * s2_magic_scale × ATK arts per second; an enemy leaving snow gets `cold` s of 寒冷; "积雪在目标点积累至5层时,使目标点变为 - * 冻结状态": a protection point (the blue gate) reaching max snow turns into the frozen 保护目标 (freezeTile). - */ - const addSnow = (battle, unit, k) => { - const snow = unit.mem.snow; - if (!snow) return false; - const r = (k / COLS) | 0, c = k % COLS; - if (!battle.grid.inRect(r, c) || !battle.grid.groundPassable(r, c, true)) return false; - // PRTS 备注 "存在自身的该召唤物的地块不会积雪" - if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive && t.tileR === r && t.tileC === c)) return false; - const S2 = unit.mem.sbellS2; - const cur = snow.get(k) ?? 0; - // a protection point already at max snow (its freeze waited for her other token to go) freezes on its next layer - if (S2 && cur >= maxL && freezeTile(battle, unit, k)) return true; - if (cur >= maxL) { - if (!S2 || S2.spreadLeft <= 0) return false; - // "积雪超过5层时会向周围扩散一层": the thinnest neighbouring ground tile gets the layer - let best = null, bl = Infinity; - for (const [dr, dc] of [[0, 1], [1, 0], [0, -1], [-1, 0]]) { - const nr = r + dr, nc = c + dc; - if (!battle.grid.inRect(nr, nc) || !battle.grid.groundPassable(nr, nc, true)) continue; - const L = snow.get(nr * COLS + nc) ?? 0; - if (L < maxL && L < bl) { bl = L; best = nr * COLS + nc; } - } - if (best == null) return false; - S2.spreadLeft--; - return addSnow(battle, unit, best); - } - snow.set(k, cur + 1); - if (S2 && cur + 1 >= maxL) freezeTile(battle, unit, k); - return true; - }; - /** - * "使目标点变为冻结状态" — PRTS 圣聆初雪 S2 备注: "目标点冻结的实际效果为令圣聆初雪在该地块上召唤一个保护目标(冻结状态)(无视部署 - * 属性),并去除相应地块上的积雪", "可以被'变为冻结状态'的目标点包括常规的保护目标点与促融共竞的保护目标点"; the token's page: - * "技能发动后于保护目标叠加5层积雪". So only a protection point (a goal tile, glyph E) freezes, whatever its deploy attributes - * (nobody may stand on a gate): her token icetgt (blocks 3; one at a time, maxDeployCount 1) appears on it and blocks the - * enemies walking in, and that tile's snow is used up. Until 0.1.3 (community report #32): any free tile a melee - * operator could stand on froze at max snow, and the gate never did. Returns true when the token appeared. - */ - const freezeTile = (battle, unit, k) => { - const r = (k / COLS) | 0, c = k % COLS; - if (battle.grid.tile(r, c).special !== 'end') return false; - if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive)) return false; - if (battle.isReservedTile(r, c)) return false; - const ice = battle.spawnToken(unit, iceId, r, c); - if (!ice) return false; - unit.mem.snow.delete(k); - battle.fx('summon', { x: c, y: r, id: ice.id, src: unit.id }); - return true; - }; - const talents = [ - { install(battle, unit) { // 无垠的雪景 (+ S2 snow rules) - const ivN = num(t0.interval, 5.5), ivS = num(bb.interval, ivN); - const slow = num(t0.move_speed), scale = num(t0.talent_magic_scale); - const snow = new Map(); - unit.mem.snow = snow; - const last = new WeakMap(); - let acc = 0, dotAcc = 0; - const lay = () => { - let n = 0; - for (const k of unit.rangeKeys || []) if (addSnow(battle, unit, k)) n++; - if (n) battle.fx('snow', { x: unit.x, y: unit.y, id: unit.id, tiles: n }); - }; - battle.on('deploy', ({ unit: u }) => { - if (u !== unit) return; - snow.clear(); - acc = 0; - if (num(t0.first_snow) > 0) lay(); - }, { owner: unit }); - battle.on('tick', ({ dt }) => { - if (!live(unit)) return; - acc += dt; - if (acc + 1e-9 >= (unit.skill && unit.skill.active ? ivS : ivN)) { acc = 0; lay(); } - if (!snow.size) return; - const S2 = unit.mem.sbellS2; - for (const e of battle.enemies) { - if (!e.alive || e.hidden) continue; - const k = keyOf(e); - const prev = last.get(e); - if (prev !== k) { - last.set(e, k); - const hadSnow = prev !== undefined && !e.isFlying && snow.has(prev); - if (hadSnow) snow.delete(prev); // first enemy leaving clears it - if (!e.isFlying && snow.has(k) && scale > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'snow'] }); - if (S2 && hadSnow && !snow.has(k) && e.alive && S2.cold > 0) battle.applyStatus(e, 'cold', { duration: S2.cold, source: unit }); - } - const L = snow.get(k); - if (L && e.alive && slow) battle.addBuff(e, { key: 'sbell2:snow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow * L) }, source: unit }); - } - if (!S2 || !(S2.dot > 0)) { dotAcc = 0; return; } - dotAcc += dt; - if (dotAcc + 1e-9 < 1) return; - dotAcc -= 1; - for (const e of battle.enemies) { - if (e.alive && !e.hidden && !e.isFlying && bodyInKeys(e, snow)) battle.dealDamage(unit, e, { amount: unit.s.atk * S2.dot, type: 'arts', isSkill: true, tags: ['skill', 'snow'] }); - } - }, { owner: unit }); - } }, - { install(battle, unit) { // 圣山的祝福 - const cold = num(t1.cold), selfFreeze = num(t1.freeze), eFreeze = num(t1.c2e_freeze), hr = num(t1.hp_ratio, 1); - battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.blessUsed = false; }, { owner: unit }); - battle.on('damaged', (ctx) => { - if (ctx.target !== unit || !(cold > 0) || ctx.type === 'element') return; - const s = ctx.source; - if (s && s.side === 'enemy' && s.alive) battle.applyStatus(s, 'cold', { duration: cold, source: unit }); - }, { owner: unit }); - battle.on('fatal', (ctx) => { - if (ctx.unit !== unit || ctx.prevented || unit.mem.blessUsed) return; - ctx.prevented = true; - unit.mem.blessUsed = true; - unit.hp = Math.max(1, unit.s.maxHp * hr); - if (selfFreeze > 0) battle.applyStatus(unit, 'freeze', { duration: selfFreeze, source: unit, force: true }); - if (eFreeze > 0) for (const e of enemiesIn(battle, unit)) battle.applyStatus(e, 'freeze', { duration: eFreeze, source: unit }); - battle.fx('blessing', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit, priority: -50 }); - } }, - ]; - if (num(mod.damage_scale) > 0) { - talents.push({ install(battle, unit) { // module: +3 % damage per enemy in range (max 5) - const per = num(mod.damage_scale), cap = Math.max(1, Math.floor(num(mod.max_valid_stack_cnt, 5))); - aura(battle, unit, 0.25, () => { - const n = Math.min(cap, enemiesIn(battle, unit).length); - if (n > 0) battle.addBuff(unit, { key: 'sbell2:heart', mods: { dmgDealtMul: 1 + per * n }, duration: 0.4, refresh: 'replace' }); - }); - } }); - } - const skills = { - // S1 铃音吹雪 (SEARCH trigger, 2 charges): atk_scale × ATK arts + `cold` s of 寒冷 on every enemy in range, pushed - // (force 1 = 中力) along her direction — PRTS 备注 "固定方向推动(不会因角度过大或距离过近而变化方向与力度),且仅对地面 - // 单位产生推力" (Battle.push fixed, official 力度 − 重量 distance); then one snow layer spreads forward over the ground - // (≤ trig_cnt tiles) - skchr_sbell2_1: { - kind: instantKind(def), - // data trigger SEARCH (the 阵法术师 row, every MANUAL skill of the class: "在初始攻击范围内存在敌人时释放技能") = - // an enemy inside her initial range, checked every tick — what the engine's DEFAULT does for a phalanx - // (noAttackUnlessSkill); an enemy anywhere on the field would dump both charges on an enemy spawning at the gate - trigger: 'DEFAULT', - onStart({ battle, unit }) { - const [fr, fc] = unit.fwd; - const force = num(bb.force); - for (const e of enemiesIn(battle, unit)) { - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); - if (!e.alive) continue; - if (num(bb.cold) > 0) battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: unit }); - if (!e.isFlying) battle.push(e, force, { from: unit, dir: { x: fc, y: fr }, fixed: true }); - } - const n = Math.max(0, Math.floor(num(bb.trig_cnt, 5))); - let laid = 0; - for (let d = 1; d <= n; d++) { - const r = unit.tileR + fr * d, c = unit.tileC + fc * d; - if (!battle.grid.inRect(r, c)) break; - if (addSnow(battle, unit, r * COLS + c)) laid++; - } - battle.fx('frostNova', { x: unit.x, y: unit.y, id: unit.id, tiles: laid }); - }, - }, - // S2 霜涛覆岭 (toggle, 持续时间无限): group attacks at attack@atk_scale_s2 × ATK + the S2 snow rules (addSnow / talent) - skchr_sbell2_2: { - kind: 'toggle', - attack: { atkScale: num(bb['attack@atk_scale_s2'], 1) }, - onStart({ unit }) { - unit.mem.sbellS2 = { spreadLeft: Math.floor(num(bb['talent@max_cast_tile_count'], 20)), dot: num(bb['talent@s2_magic_scale']), cold: num(bb['talent@cold']) }; - }, - onEnd({ battle, unit }) { - unit.mem.sbellS2 = null; - for (const t of battle.allyUnits) if (isTok(t, iceId, unit) && t.alive) battle.retreat(t, { reason: 'expired', permanent: true }); - }, - }, - }; - return { - skills, - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed), resIgnoreFlat: num(bb.magic_resist_penetrate_fixed) }, - ...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}), - attack: { atkScale: num(bb['attack@atk_scale_s3'], 1) }, - onStart({ battle, unit }) { // 立即诱导攻击范围内的所有敌人至自身周围的可达地面,持续 attract_time 秒 - const dur = num(bb.attract_time); - if (!(dur > 0)) return; - let n = 0; - // 诱导 = the engine `attract` status: unblockable, walked (own speed, grid path) to the point, then its route - // resumes; it runs its full time even if she falls meanwhile - for (const e of enemiesIn(battle, unit)) { - if (e.isFlying || e.isBoss || !e.alive) continue; - const goal = lureGoal(battle, unit, e); - if (goal && battle.applyStatus(e, 'attract', { duration: dur, source: unit, point: goal })) n++; - } - battle.fx('lure', { x: unit.x, y: unit.y, id: unit.id, n }); - }, - }, - talents, - }; -} - -/** The nearest ground tile around `unit` to `e` ("自身周围的可达地面"), or null. */ -function lureGoal(battle, unit, e) { - let goal = null, bd = Infinity; - for (const [dr, dc] of AROUND8) { - const r = unit.tileR + dr, c = unit.tileC + dc; - if (!battle.grid.groundPassable(r, c)) continue; - const d = Math.hypot(e.x - c, e.y - r); - if (d < bd - 1e-9) { bd = d; goal = [r, c]; } - } - return goal; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 余 chess_char_6_03 (本源铁卫) — S3 灶里乾坤; 礼尚往来; 闲云隐市; module 人间百味 - -function yu(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess), tb = def?.traitBb || {}; - const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); - const wallProb = isDef ? num(bb.prob) : 0, wallBurn = isDef ? num(bb.ep_damage_ratio) : 0; - // "将第二天赋效果赋予全场所有干员" belongs to S3 (the default skill) only - const s3On = (unit) => isDef && !!unit.skill?.active; - const skills = { - // S1 今日做东 (TAKE_DAMAGE, hurt SP): passive taunt +taunt_level; active: HP / DEF +, every attack taken ⇒ - // ep_damage_ratio × ATK 灼燃损伤 on the attacker (install below) - skchr_yu_1: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) } }, - // S2 厚礼上宾 (cast with an enemy on its x-1: the data's SKILL_RANGE, a deliberate deviation from the 重装 TAKE_DAMAGE - // row — tools/build-data.mjs TRIGGER_DEVIATIONS, DESIGN §22.10): atk_scale × ATK arts on every enemy of the skill range - // + the ground-reachable ones teleported onto his tile (leaders too, unless 自缚: teleportEnemy); block +block_cnt, HP / - // ATK +, normal attacks deal arts damage. The burst hits air units too [ASSUMED: no 对空 note on PRTS]; the teleport - // takes ground units only ("地面可达目标"). The 'pull' fx only when someone was teleported (each one also gets its own - // 'teleport' fx). PRTS's 0.13 s between the damage and the teleport is not modelled (same tick). - skchr_yu_2: { - kind: 'duration', - mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) }, - attack: { dmgType: 'arts' }, - onStart({ battle, unit }) { - const grid = skillGridOf(def) || AROUND8; - const foes = battle.unitsInGrid(unit, grid, { side: 'enemy' }).filter((e) => canTargetEnemy(unit, e, ANY)); - for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); - let n = 0; - for (const e of foes) if (teleportEnemy(battle, unit, e)) n++; - if (n > 0) battle.fx('pull', { x: unit.x, y: unit.y, id: unit.id, n }); - }, - }, - }; - return { - skills, - install(battle, unit) { - if (sid !== 'skchr_yu_1') return; - // 被动效果:自身更容易受到敌人攻击 (while this skill is carried) - battle.addBuff(unit, { key: 'yu:host', mods: { taunt: num(bb.taunt_level, 1) }, persist: true, allowDead: true }); - battle.on('damaged', (ctx) => { - const s = ctx.source; - if (ctx.target !== unit || !unit.skill?.active || !s || s.side !== 'enemy' || !s.alive || !ctx.dmg?.isAttack) return; - elementDmg(battle, unit, s, 'burn', unit.s.atk * num(bb.ep_damage_ratio), ['skill']); - }, { owner: unit }); - }, - skill: { - kind: 'duration', - mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) }, - // the wall runs through his tile perpendicular to his direction (RIGHT / LEFT: his column; UP / DOWN: his row — - // it turns with the deploy direction like a range) [ASSUMED orientation for UP / DOWN] - onStart({ battle, unit }) { - const vertical = unit.fwd[0] === 0; - unit.mem.yuWall = vertical ? { axis: 'x', v: unit.tileC } : { axis: 'y', v: unit.tileR }; - battle.fx('firewall', { x: unit.tileC, y: unit.tileR, id: unit.id, dir: unit.dir, axis: vertical ? 'col' : 'row' }); - }, - onEnd({ unit }) { unit.mem.yuWall = null; }, - }, - talents: [ - { install(battle, unit) { // 礼尚往来: 庇护 while blocking + DoT on blocked enemies - const dr = num(t0.damage_resistance), sc = bv(t0, 'atk_scale'), er = bv(t0, 'ep_damage_ratio'), iv = Math.max(0.1, bv(t0, 'interval', 1)); - // 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例 — true / element damage is not reduced - aura(battle, unit, 0.1, () => { - if (dr > 0 && unit.blocking.length) battle.addBuff(unit, { key: 'yu:shelter', mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr }, duration: 0.2, refresh: 'replace' }); - }); - aura(battle, unit, iv, () => { - for (const e of unit.blocking.slice()) { - if (!e.alive) continue; - if (sc > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', tags: ['talent'] }); - if (er > 0) elementDmg(battle, unit, e, 'burn', unit.s.atk * er, ['talent']); - } - }); - } }, - { install(battle, unit) { // 闲云隐市 (all operators while S3 runs) - const cnt = num(t1.cnt, 4), hr = num(t1.hp_recovery_per_sec_by_max_hp_ratio), er = num(t1.ep_heal_ratio), iv = Math.max(0.1, num(t1.interval, 1)); - aura(battle, unit, iv, () => { - const ops = opsOf(battle, unit.ownerId); - if (ops.length < cnt) return; - for (const a of s3On(unit) ? ops : [unit]) { - if (hr > 0) battle.heal(unit, a, a.s.maxHp * hr * iv, { self: true, silent: true }); - if (er > 0) battle.reduceElement(a, a.s.maxHp * er * iv); - } - }); - } }, - { install(battle, unit) { // S3 fire wall - const crosses = (a, b) => { - const w = unit.mem.yuWall; - if (w == null) return false; - return w.axis === 'y' ? (a.y - w.v) * (b.y - w.v) < 0 : (a.x - w.v) * (b.x - w.v) < 0; - }; - battle.on('damaged', (ctx) => { - const s = ctx.source, t = ctx.target; - if (!(wallBurn > 0) || unit.mem.yuWall == null || !live(unit) || ctx.type !== 'arts' || !(ctx.amount > 0)) return; - if (!s || s === unit || s.side !== 'ally' || !t || t.side !== 'enemy' || !t.alive || !crosses(s, t)) return; - elementDmg(battle, unit, t, 'burn', unit.s.atk * wallBurn, ['skill', 'firewall']); - }, { owner: unit }); - battle.on('hit', (ctx) => { - const s = ctx.source, t = ctx.target; - if (!(wallProb > 0) || unit.mem.yuWall == null || !live(unit) || !s || s.side !== 'enemy' || !t || t.side !== 'ally') return; - if (!ctx.dmg.isAttack || !(s.base.rangeRadius > 0) || s.blockedBy === t || !crosses(s, t)) return; - if (battle.rng() < wallProb) { - ctx.dmg.cancel = true; - const w = unit.mem.yuWall; - battle.fx('firewallBlock', w.axis === 'y' ? { x: t.x, y: w.v, id: unit.id } : { x: w.v, y: t.y, id: unit.id }); - } - }, { owner: unit }); - } }, - { install(battle, unit) { // elite: module element ×1.15 while blocking; 闲云隐市 arts ×1.14 vs burning targets (≥4 ops) - const epScale = num(tb.ep_damage_scale, 1), cnt = num(mod.cnt, Infinity), ds = num(mod.damage_scale, 1); - if (epScale > 1) onElementHit(battle, unit, (ctx) => (ctx.source === unit && live(unit) && unit.blocking.length ? epScale : 1)); - // "将第二天赋效果赋予全场所有干员": while S3 runs every operator of hers gets the ×1.14 too - if (ds > 1) battle.on('hit', (ctx) => { - const s = ctx.source; - if (!s || ctx.dmg.type !== 'arts' || !ctx.target || ctx.target.side !== 'enemy' || !ctx.target.findBuff('burnBurst')) return; - if (s !== unit && !(s.kind === 'op' && s.ownerId === unit.ownerId && live(unit) && s3On(unit))) return; - if (opsOf(battle, unit.ownerId).length >= cnt) ctx.dmg.mul *= ds; - }, { owner: unit }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 浊心斯卡蒂 chess_char_6_04 (吟游者) — S3 "潮涌,潮枯"; 远古血亲 (海嗣); 捕食习性; module 蜕化的残迹 - -function skadi2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess), tb = def?.traitBb || {}; - const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); - const tokId = def?.talents?.[0]?.tokenKey || (chess?.tokens || [])[0] || 'token_10017_skadi2_dedant'; - const auraRatio = num(tb['attack@atk_to_hp_recovery_ratio'], 0.1); - // S1 / S2 raise the trait heal ("特性效果提高至N%") while they run; S3 (default) turns it into the tide - const skillRatio = num(bb['attack@atk_to_hp_recovery_ratio'], auraRatio); - const seaborns = (battle, unit) => battle.allyUnits.filter((t) => isTok(t, tokId, unit) && live(t)); - const covered = (battle, unit, toks) => { // allies inside her range ∪ the seaborns' ranges - const set = new Set(battle.alliesInGrid(unit)); - for (const t of toks) for (const a of battle.alliesInGrid(t)) set.add(a); - for (const t of toks) set.delete(t); - return [...set]; - }; - const inCover = (battle, unit, a) => { - const k = a.tileR * COLS + a.tileC; - if (unit.rangeKeySet?.has(k)) return true; - return seaborns(battle, unit).some((t) => t.rangeKeySet?.has(k)); - }; - const skills = { - // S1 同归殊途之吟 (SP_FULL): full self heal, max HP +max_hp, trait heal attack@atk_to_hp_recovery_ratio, and - // damage_resistance of the damage taken by every ally of her (+ 海嗣) range is transferred to her (install) - skchr_skadi2_1: { - kind: 'duration', - mods: { hpPct: num(bb.max_hp) }, - onStart({ battle, unit }) { unit.hp = unit.s.maxHp; battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id }); }, - }, - // S2 同葬无光之愿 (toggle): 鼓舞 ATK / DEF = atk / def × her ATK / DEF on every other ally of her (+ 海嗣) range, - // trait heal attack@atk_to_hp_recovery_ratio (trait pulse) - skchr_skadi2_2: { kind: 'toggle' }, - }; - return { - skills, - skill: { - kind: 'duration', // effects run in the trait pulse below ("特性变为…") - onStart({ battle, unit }) { battle.fx('tide', { x: unit.x, y: unit.y, id: unit.id }); }, - }, - trait: { - install(battle, unit) { // replaces the bard aura: heal normally, tide (true damage + 鼓舞 + self drain) during S3 - unit.mem.noInspire = true; // 自身不受鼓舞影响 - const isInspire = (b) => b.key === 'inspire' || b.status === 'inspire' || b.key.startsWith('inspire:') || b.key.endsWith(':inspire'); - // "海嗣的攻击范围视为自身攻击范围的延伸": an enemy in a 海嗣's range also satisfies her DEFAULT trigger - if (unit.skill) unit.skill.addTriggerRange(() => seaborns(battle, unit)); - let n = 0; - battle.every(0.5, () => { - if (!unit.canAct) return; - for (let i = unit.buffs.length - 1; i >= 0; i--) if (isInspire(unit.buffs[i])) battle.removeBuff(unit, unit.buffs[i]); - n++; - const toks = seaborns(battle, unit); - const allies = covered(battle, unit, toks); - const sk = unit.skill; - const on = !!(sk && sk.active); - if (on && isDef) { - const val = unit.s.atk * num(bb.atk); - for (const a of allies) if (a !== unit) inspire(battle, a, val, unit); - if (n % 2 === 0) { - const dmg = unit.s.atk * num(bb.atk_scale); - if (dmg > 0) for (const src of [unit, ...toks]) for (const e of enemiesIn(battle, src)) { - battle.dealDamage(unit, e, { amount: dmg, type: 'true', isSkill: true, tags: ['skill', 'tide'] }); - } - const loss = unit.s.maxHp * num(bb.hp_ratio); - if (loss > 0) { if (unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit, silent: true }); else unit.hp = Math.min(unit.hp, 1); } - } - return; - } - if (on && sid === 'skchr_skadi2_2') { - const va = unit.s.atk * num(bb.atk), vd = unit.s.def * num(bb.def); - for (const a of allies) if (a !== unit) { inspire(battle, a, va, unit); inspire(battle, a, vd, unit, 'def'); } - } - if (n % 2 === 0) { - const amount = unit.s.atk * (on ? skillRatio : num(unit.profile?.auraRatio, auraRatio)); - for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, amount, { aura: true }); - } - }, { owner: unit }); - }, - }, - install(battle, unit) { - // S1: "攻击范围内我方所有单位受到伤害的50%直接转移给浊心斯卡蒂承担(同类效果取最高)" — the ally takes (1 − share), - // she takes the transferred part as true damage from the same attacker - if (sid === 'skchr_skadi2_1') { - const share = clamp(num(bb.damage_resistance), 0, 1); - const TAG = `skadi2:transfer:${unit.id}`; - battle.on('hit', (ctx) => { - const t = ctx.target, d = ctx.dmg; - if (!(share > 0) || !t || t === unit || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active) return; - if (d.type === 'element' || d.type === 'elemental' || d.skadiShare || d.tags?.includes('transfer') || !inCover(battle, unit, t)) return; - d.skadiShare = { share, tag: TAG }; // 同类效果取最高: one transfer per damage instance - d.mul *= 1 - share; - }, { owner: unit, priority: -20 }); - battle.on('damaged', (ctx) => { - const x = ctx.dmg?.skadiShare; - if (!x || x.tag !== TAG || !(ctx.amount > 0) || !live(unit)) return; - battle.dealDamage(ctx.source, unit, { amount: ctx.amount * x.share / Math.max(1e-6, 1 - x.share), type: 'true', canDodge: false, tags: ['transfer'] }); - }, { owner: unit }); - } - // module 新生代: "自身技能期间,攻击范围内的友军获得30点物理与法术伤害减免" (flat reduction per hit) - const flat = -num(mod.damage_resistance); - if (flat > 0) { - battle.on('hit', (ctx) => { - const t = ctx.target, d = ctx.dmg; - if (!t || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active || !inCover(battle, unit, t)) return; - if (d.type === 'phys') d.defIgnoreFlat -= flat; // +flat effective DEF ⇒ −flat damage (above the 5 % floor) - else if (d.type === 'arts') d.amount = Math.max(0, d.amount - flat / Math.max(0.05, 1 - (t.s.res ?? 0) / 100)); - }, { owner: unit, priority: -10 }); - } - }, - talents: [ - { install(battle, unit) { // 远古血亲: the seaborn is an inert range extension with a limited life - battle.on('deploy', ({ unit: t }) => { - if (!isTok(t, tokId, unit)) return; - if (t.profile) t.profile.noAttack = true; - const dur = num(t.def?.talents?.[0]?.bb?.duration, parseN(tdesc(def, 0), /持续(\d+(?:\.\d+)?)秒/, 25)); - const seq = t.deploySeq, r = t.tileR, c = t.tileC; - battle.after(dur, () => { - if (!t.alive || t.deploySeq !== seq) return; - battle.retreat(t, { reason: 'expired', permanent: true }); - const wait = num(t.base.respawnTime, 30), cost = num(t.base.cost, 0); - let tries = 0; - const again = () => { - if (battle.finished || tries++ > 300) return; - const ps = battle.getPlayer(unit.ownerId); - if (!live(unit) || !ps || ps.dp + 1e-9 < cost || battle.unitAt(r, c)) { battle.after(1, again, { owner: unit }); return; } - battle.addDp(unit.ownerId, -cost); - if (!battle.spawnToken(unit, tokId, r, c)) { battle.addDp(unit.ownerId, cost); battle.after(1, again, { owner: unit }); } - }; - battle.after(wait, again, { owner: unit }); - }, { owner: t }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 捕食习性 (module 新生代: ATK 9 / 20 %, DEF +8 %, +3 SP per op deployed in range) + module (≥2 other ops in range: ATK +8 %) - const a1 = num(t1['skadi2_e_003_t_2[atk][1].atk'], bv(t1, 'skadi2_t_2[atk][1].atk', 0)); - const a2 = num(t1['skadi2_e_003_t_2[atk][2].atk'], bv(t1, 'skadi2_t_2[atk][2].atk', a1)); - const d1 = num(t1['skadi2_e_003_t_2[def].def']), sp = num(t1.sp); - const mCnt = num(mod.cnt, Infinity), mAtk = num(mod.atk); - aura(battle, unit, 0.5, () => { - const toks = seaborns(battle, unit); - const ops = covered(battle, unit, toks).filter((a) => a !== unit && a.kind === 'op'); - const val = ops.some((a) => ABYSSAL.has(a.def?.charId)) ? a2 : ops.length ? a1 : 0; - const own = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op').length; - const total = val + (own >= mCnt ? mAtk : 0); - const dPct = ops.length ? d1 : 0; - if (total > 0 || dPct > 0) battle.addBuff(unit, { key: 'skadi2:predator', mods: { atkPct: total, defPct: dPct }, duration: 0.75, refresh: 'replace' }); - }); - // "我方干员部署于自身或海嗣范围内后,自身立刻获得3点技力" - if (sp > 0) battle.on('deploy', ({ unit: u }) => { - if (!u || u === unit || u.kind !== 'op' || u.side !== 'ally' || !live(unit) || !unit.skill) return; - if (inCover(battle, unit, u)) unit.skill.gainSp(sp, 'talent'); - }, { owner: unit }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 异客 chess_char_6_05 (链术师) — S3 辉煌裂片; 机理分析; 孤卒 - -const STORM_RADIUS = 1.5; // [ASSUMED] storm zone radius (not in data) - -function pasngr(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; - const strike = (battle, unit, first, scale) => { - const ch0 = unit.profile?.chain || { count: 4, falloff: 0.15, radius: CHAIN_RADIUS }; - // elite module (电磁调节器) upgrades the skill's chain too: skill@chain.atk_scale / skill@sluggish - const ch = { ...ch0, falloff: tb['skill@chain.atk_scale'] != null ? 1 - num(tb['skill@chain.atk_scale']) : num(ch0.falloff, 0.15) }; - const count = Math.max(1, Math.floor(num(bb['chain.max_target'], ch.count || 4))); - const slug = num(tb['skill@sluggish'], num(bb.sluggish, ch.sluggish ?? 0)); - const hit = new Set(); - let prev = first; - for (let i = 0; i < count && prev; i++) { - hit.add(prev.id); - battle.fx('lightning', { x: prev.x, y: prev.y, id: prev.id, src: unit.id }); - battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * Math.pow(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] }); - if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit }); - let best = null, bd = Infinity; - for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) { - if (hit.has(x.id) || !canTargetEnemy(unit, x, ANY)) continue; - const d = bodyDist(x, prev.x, prev.y); - if (d < bd - 1e-9) { bd = d; best = x; } - } - prev = best; - } - }; - // the chain of her profile (trait / module: bounce count, falloff, 停顿) with skill overrides - const chainOf = (unit, o) => ({ ...(unit.profile?.chain || { count: 4, falloff: 0.15, radius: CHAIN_RADIUS, sluggish: 0.5 }), ...o }); - const skills = { - // S1 电能之触: next attack at atk_scale × ATK, bouncing over max_target enemies with a sluggish-s 停顿 (the module's - // skill@pasngr_s_1.chain.atk_scale sets its falloff) - skchr_pasngr_1: { - kind: 'instant', - attack: { atkScale: bv(bb, 'atk_scale', 1) }, - onStart({ unit, skill }) { - const o = { count: Math.max(1, Math.floor(bv(bb, 'max_target', 4))), sluggish: bv(bb, 'sluggish', 0.5) }; - if (tb['skill@pasngr_s_1.chain.atk_scale'] != null) o.falloff = 1 - num(tb['skill@pasngr_s_1.chain.atk_scale']); - skill.spec.attack.chain = chainOf(unit, o); - }, - }, - // S2 聚焦指令: range +1, ATK +atk, base attack time ×0.7/×0.6 (akdata: 乘算负数), bounces raised to attack@max_target - skchr_pasngr_2: { - kind: 'duration', - mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def, true) }, - targeting: { rangeExtend: num(bb.ability_range_forward_extend) }, - attack: {}, - onStart({ unit, skill }) { skill.spec.attack.chain = chainOf(unit, { count: Math.max(1, Math.floor(num(bb['attack@max_target'], 5))) }); }, - }, - }; - return { - skills, - skill: { - kind: 'charges', - onStart({ battle, unit }) { - const keys = skillGrid ? absoluteRangeKeys(skillGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend) : unit.rangeKeys; - const cands = enemiesIn(battle, unit, keys); - if (!cands.length) return; - const tgt = cands.reduce((a, b) => (b.hp > a.hp ? b : a)); - const cx = tgt.x, cy = tgt.y; - const dur = num(bb.duration, 4), iv = Math.max(0.1, num(bb.interval, 0.5)), scale = num(bb.atk_scale, 1); - const n = Math.max(1, Math.round(dur / iv)); - battle.fx('storm', { x: cx, y: cy, id: unit.id, duration: dur, r: STORM_RADIUS }); - let k = 0; - const seq = unit.deploySeq; - const h = battle.every(iv, () => { - if (!live(unit) || unit.deploySeq !== seq) { h.cancel(); return; } // the storm ends when she leaves the field - if (++k >= n) h.cancel(); - const zone = battle.foesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY)); - const e = battle.rng.pick(zone); - if (e) strike(battle, unit, e, scale); - }, { owner: unit }); - }, - }, - talents: [ - { install(battle, unit) { // 机理分析 - const thr = num(t0.hp_ratio, 0.8), ds = bv(t0, 'damage_scale', 1), dur = bv(t0, 'duration', 3); - const key = `pasngr:enhance:${unit.id}`; - battle.on('hit', (ctx) => { - const t = ctx.target; - if (ctx.source !== unit || !t || t.side !== 'enemy' || ctx.dmg.type === 'element') return; - if (ctx.dmg.isAttack && t.hpRatio >= thr - 1e-9) battle.addBuff(t, { key, duration: dur, refresh: 'replace', source: unit }); - if (t.findBuff(key)) ctx.dmg.mul *= ds; - }, { owner: unit }); - } }, - { install(battle, unit) { // 孤卒: no enemy on the 4 surrounding tiles - const atk = num(t1.atk), spr = num(t1.sp_recovery_per_sec); - aura(battle, unit, 0.25, () => { - const near = new Set(absoluteRangeKeys(N4, unit.tileR, unit.tileC, 1, 0)); - if (battle.enemies.some((e) => e.alive && !e.hidden && bodyInKeys(e, near))) return; - battle.addBuff(unit, { key: 'pasngr:lone', mods: { atkPct: atk, spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' }); - }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 佩佩 chess_char_6_06 (撼地者) — S3 时光震荡; 往昔传承; 弥漫莲香; module 晴雨 - -function pepe(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const sid = selectedSkill(chess, def); - const stackAtk = num(bb['attack@atk']), maxStack = Math.max(0, Math.floor(num(bb['attack@max_stack_cnt']))); - const ext = num(bb['attack@ability_range_forward_extend']); - const stun = num(bb['attack@stun']), stunMain = num(bb['attack@stun_main'], stun); - // 往昔传承: "技能期间每击倒1名敌人,技能结束时获得N点技力,至多回复M点" (any timed skill of hers) - const pastSp = (unit, skill) => { - const gain = Math.min(num(t0.max_sp, Infinity), (unit.mem.pepeKills || 0) * num(t0.sp)); - unit.mem.pepeKills = 0; - if (gain > 0 && unit.alive) skill.gainSp(gain, 'talent'); - }; - const skills = { - // S1 盖戳!: next attack at atk_scale × ATK (splash included); castable while under a 异常状态, which it cleanses - // (install below) - skchr_pepe_1: { kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) } }, - // S2 阻遏混乱锤: skill range, ATK / ASPD +, random target in range; every use gives later casts ASPD - // +attack_speed_extra (≤ max_stack_cnt stacks) - skchr_pepe_2: { - kind: 'duration', - mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, - ...(skillGridOf(def) ? { targeting: { rangeGrid: skillGridOf(def) } } : {}), - onStart({ battle, unit }) { - unit.mem.pepeKills = 0; - const n = unit.mem.pepeRage || 0; - if (n > 0) battle.addBuff(unit, { key: 'pepe:rage', mods: { aspd: num(bb.attack_speed_extra) * n } }); - unit.mem.pepeRage = Math.min(Math.floor(num(bb.max_stack_cnt, 2)), n + 1); - }, - onEnd({ battle, unit, skill }) { battle.removeBuff(unit, 'pepe:rage'); pastSp(unit, skill); }, - }, - }; - return { - skills, - install(battle, unit) { - if (sid === 'skchr_pepe_1') { - battle.on('tick', () => { // "处于异常状态时可以释放技能并清除异常状态" - const sk = unit.skill; - if (!live(unit) || !sk || !sk.ready || sk.active || !hasAbnormal(unit)) return; - cleanseAbnormal(battle, unit); - sk.activate('abnormal'); - }, { owner: unit }); - } - if (sid === 'skchr_pepe_2') { - battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标 (and the enemies she blocks — Battle.blockedTargets) - if (ctx.attacker !== unit || !unit.skill?.active) return; - const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile); - for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e); - if (c.length) ctx.targets = [battle.rng.pick(c)]; - }, { owner: unit }); - } - }, - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) }, - attack: { - splashRadius: 1, - onHit({ battle, unit, target }) { if (target && target.alive && stunMain > 0) battle.applyStatus(target, 'stun', { duration: stunMain, source: unit }); }, - }, - onStart({ unit, skill }) { - unit.mem.pepeStack = 0; - unit.mem.pepeKills = 0; - unit.mem.pepeR0 = num(unit.profile?.splashRadius, 1); - skill.spec.attack.splashRadius = unit.mem.pepeR0; - }, - onAttack({ battle, unit, skill }) { - if (unit.mem.pepeStack >= maxStack) return; - unit.mem.pepeStack++; - battle.addBuff(unit, { key: 'pepe:tremor', mods: { atkPct: stackAtk * unit.mem.pepeStack } }); - skill.spec.attack.splashRadius = unit.mem.pepeR0 + ext * unit.mem.pepeStack; - }, - onEnd({ battle, unit, skill }) { - battle.removeBuff(unit, 'pepe:tremor'); - skill.spec.attack.splashRadius = unit.mem.pepeR0 ?? 1; - pastSp(unit, skill); - }, - }, - talents: [ - { install(battle, unit) { // S3 splash stun + 往昔传承 kill counter - battle.on('damaged', (ctx) => { - if (ctx.source !== unit || !unit.skill?.active || !ctx.dmg?.isSplash || !ctx.target.alive || stun <= 0) return; - battle.applyStatus(ctx.target, 'stun', { duration: stun, source: unit }); - }, { owner: unit }); - battle.on('kill', ({ killer, victim }) => { - if (killer === unit && victim.side === 'enemy' && unit.skill?.active) unit.mem.pepeKills = (unit.mem.pepeKills || 0) + 1; - }, { owner: unit }); - } }, - { install(battle, unit) { // 弥漫莲香: every 近卫 op ATK +16 % - const atk = num(t1.atk); - if (atk) aura(battle, unit, 0.5, () => { - for (const a of battle.allyUnits) if (live(a) && a.kind === 'op' && a.def?.profession === 'WARRIOR') battle.addBuff(a, { key: 'pepe:lotus', mods: { atkPct: atk }, duration: 0.75, refresh: 'replace' }); - }); - } }, - { install(battle, unit) { // elite module: ≥ cnt enemies in the splash area ⇒ this attack ×1.15 - const sc = num(tb.atk_scale_e, 1), cnt = num(tb.cnt, Infinity); - if (!(sc > 1)) return; - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit) return; - const t = ctx.targets[0]; - const r = num(ctx.profile?.splashRadius, 1); - unit.mem.pepeBoost = !!t && battle.foesInRadius(t.x, t.y, r).length >= cnt; - }, { owner: unit }); - battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.dmg.isAttack && unit.mem.pepeBoost) ctx.dmg.mul *= sc; }, { owner: unit }); - battle.on('attack', (ctx) => { if (ctx.attacker === unit) unit.mem.pepeBoost = false; }, { owner: unit, priority: -100 }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 维娜·维多利亚 chess_char_6_07 (术战者) — S3 俱以我之名; 诸王的叹息; 无拘的锋芒; module 城主的冒险 - -function siege2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess); - const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); - const tGrid = def?.talents?.[0]?.rangeGrid?.length ? def.talents[0].rangeGrid : [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]; - const tokId = (chess?.tokens || []).find((t) => /vlion/.test(String(t))) || 'token_10040_siege2_vlion'; - const mine = (u, unit) => u === unit || (!!u && u.kind === 'token' && u.ownerUnit === unit); - const skills = { - // S1 重铸晖光: the next attack also deals atk_scale × ATK true damage to every ground enemy on the skill's 4 - // surrounding tiles (+ her own) - skchr_siege2_1: { - kind: 'instant', - attack: { - onHit({ battle, unit }) { - for (const e of battle.unitsInGrid(unit, skillGridOf(def) || N4, { side: 'enemy' })) { - if (!e.isFlying && e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'true', isSkill: true, tags: ['skill'] }); - } - battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id }); - }, - }, - }, - // S2 进赴故土 (toggle): range +1 (skill grid), ATK +atk, one extra target; passive: ≥buff_stack_cnt other allies in - // the talent-1 area ⇒ SP +sp_recovery_per_sec (install) - skchr_siege2_2: { - kind: 'toggle', - mods: { atkPct: num(bb.atk) }, - targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))), ...(skillGridOf(def) ? { rangeGrid: skillGridOf(def) } : {}) }, - }, - }; - return { - skills, - install(battle, unit) { - if (sid === 'skchr_siege2_2') { - const need = Math.max(1, Math.floor(num(bb.buff_stack_cnt, 2))), spr = num(bb.sp_recovery_per_sec); - if (spr > 0) aura(battle, unit, 0.25, () => { - const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device' && battle.allySelectable(a, unit)).length; - if (n >= need) battle.addBuff(unit, { key: 'siege2:homeland', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' }); - }); - } - // module 秩序圣“球”: enemies blocked by her or her summons take 10 % 脆弱 (trait); she and her summons deal ×1.15 to - // 战栗 targets (talent) - const frag = num(tb.damage_scale, 1) - 1, tremMul = num(mod.damage_scale, 1); - if (frag > 0) aura(battle, unit, 0.25, () => { - for (const e of battle.enemies) if (e.alive && e.blockedBy && mine(e.blockedBy, unit)) battle.applyStatus(e, 'fragile', { duration: 0.4, value: frag, source: unit }); - }); - if (tremMul > 1) battle.on('hit', (ctx) => { - const t = ctx.target; - if (!mine(ctx.source, unit) || !t || t.side !== 'enemy' || !(t.findBuff('tremble') || t.s.flags.tremble)) return; - ctx.dmg.mul *= tremMul; - }, { owner: unit }); - }, - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) }, - targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) }, - attack: { dmgType: 'true' }, - // "立即在天赋一生效范围内可部署地面召唤“黄金盟誓”" (EN client "Summons Golden Vows on deployable tiles within - // Talent 1's range"): one on EVERY free tile of the talent-1 area a melee piece could be deployed on — fences - // (low, deployable, not walkable) included [ASSUMED: the EN wiki's "open low ground tiles"]; player report B3 - onStart({ battle, unit, skill }) { - const lions = []; - for (const tile of freeTiles(battle, unit, tGrid, { ground: false })) { - const lion = battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: skill.timeLeft }); - if (!lion) continue; - lions.push(lion); - if (!lion.kit?.fromTokens && lion.profile) lion.profile.dmgType = 'true'; // "攻击造成真实伤害" without a token kit - battle.fx('summon', { x: lion.x, y: lion.y, id: lion.id, src: unit.id }); - } - unit.mem.vlions = lions; - }, - onEnd({ battle, unit }) { - const lions = unit.mem.vlions || []; - unit.mem.vlions = null; - for (const lion of lions) if (lion.alive) battle.retreat(lion, { reason: 'expired', permanent: true }); - battle.setExtraRange(unit, null); - }, - }, - talents: [ - { install(battle, unit) { // S3: enemies blocked by allies inside the talent area become targetable - let sig = ''; - if (!isDef) return; - battle.on('tick', () => { // (engine extra range keys: kept across range rebuilds, cleared at the skill end) - if (!live(unit) || !unit.skill?.active) { sig = ''; return; } - const keys = []; - for (const a of battle.unitsInGrid(unit, tGrid, { side: 'ally' })) for (const e of a.blocking) if (e.alive) keys.push(keyOf(e)); - const s = keys.join(','); - if (s === sig) return; - sig = s; - battle.setExtraRange(unit, keys); - }, { owner: unit }); - } }, - { install(battle, unit) { // 诸王的叹息 - const dr = num(t0.damage_resistance), atk = num(t0.atk); - aura(battle, unit, 0.25, () => { - const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device' && battle.allySelectable(a, unit)); - if (!allies.includes(unit)) allies.push(unit); - if (dr > 0) for (const a of allies) battle.addBuff(a, { key: 'siege2:sigh', mods: { physTakenMul: 1 - dr }, duration: 0.4, refresh: 'replace' }); - const n = allies.filter((a) => a !== unit).length; - if (n > 0 && atk) battle.addBuff(unit, { key: 'siege2:kings', mods: { atkPct: atk * n }, duration: 0.4, refresh: 'replace' }); - }); - } }, - { install(battle, unit) { // 无拘的锋芒: first damage on each enemy ⇒ 战栗 (module 秩序圣“球”: 6 s, elite / leader 12 s) - const durN = num(t1.not_combat_normal, num(t1.not_combat)), durE = num(t1.not_combat_elite, durN); - const seen = new WeakSet(); - battle.on('damaged', (ctx) => { - const t = ctx.target; - if (ctx.source !== unit || !t || t.side !== 'enemy' || seen.has(t)) return; - const dur = isElite(t) ? durE : durN; - if (!(dur > 0)) return; - seen.add(t); - if (t.alive) battle.applyStatus(t, 'tremble', { duration: dur, source: unit }); - }, { owner: unit }); - } }, - { install(battle, unit) { // elite module: ASPD +8 while not blocking - const as = num(tb.attack_speed); - if (as) aura(battle, unit, 0.25, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'siege2:free', mods: { aspd: as }, duration: 0.4, refresh: 'replace' }); }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 焰影苇草 chess_char_6_08 (咒愈师) — S3 生命火种; 灼痕; 映耀 - -function reed2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess); - const isDef = onDefaultSkill(chess); - const selfAtk = (() => { for (const k of Object.keys(bb)) if (!k.startsWith('talent@') && (k === 'atk' || k.endsWith('.atk'))) return num(bb[k]); return 0; })(); - const sProb = num(bb['talent@prob'], num(t0.prob)), dot = num(bb['talent@s3_atk_scale']); - const aoe = num(bb['talent@aoe_scale']), aoeR = num(bb['talent@range_radius'], 1.7); - const scorchAtk = num(t0.atk), scorchFragile = num(t0.damage_scale, 1) - 1; - // 灼痕: ATK −20 % (marker buff, 不可叠加) + 30 % 法术脆弱 (the catalogue status: 同名效果取最高 with other sources) - const scorch = (battle, unit, e) => { - if (!e || !e.alive) return; - const sk = unit.skill; - // "灼痕效果持续至技能结束" — S3 (the default skill) only - const dur = isDef && sk && sk.active ? Math.max(0.1, sk.timeLeft) : num(t0.duration, 6); - battle.addBuff(e, { key: 'reed2:scorch', duration: dur, refresh: 'extend', mods: scorchAtk ? { atkPct: scorchAtk } : null, visible: true, source: unit }); - if (scorchFragile > 0 && e.alive) battle.applyStatus(e, 'artsFragile', { duration: dur, value: scorchFragile, source: unit }); - }; - const skills = { - // S1 迅捷打击·γ型 - 'skcom_quickattack[3]': { kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } }, - // S2 枯荣共息: up to max_target operators of her range (ground ones first) carry three fireballs for - // projectile_life_time s: every `cooldown` s one enemy in the carrier's range (else hers) takes atk_scale × her ATK - // arts, and her trait heals only that carrier (trait scale × the damage) - skchr_reed2_2: { - kind: 'duration', - duration: num(bb.projectile_life_time) > 0 ? num(bb.projectile_life_time) : undefined, - onStart({ battle, unit, skill }) { - const n = Math.max(1, Math.floor(num(bb.max_target, 1))); - const cands = battle.alliesInGrid(unit).filter((a) => a.kind === 'op' && live(a)); - cands.sort((a, b) => (a === unit) - (b === unit) || (b.ground ? 1 : 0) - (a.ground ? 1 : 0) || a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq); - unit.mem.reedFire = cands.slice(0, n).map((a) => ({ a, acc: 0 })); - for (const F of unit.mem.reedFire) { - battle.addBuff(F.a, { key: `reed2:fireball:${unit.id}`, duration: skill.timeLeft, visible: true, source: unit }); - battle.fx('ember', { x: F.a.x, y: F.a.y, id: F.a.id, src: unit.id, n: 3 }); - } - }, - onTick({ battle, unit, dt }) { - const cd = Math.max(0.1, num(bb.cooldown, 1.5)); - for (const F of unit.mem.reedFire || []) { - if (!live(F.a)) continue; - F.acc += dt; - if (F.acc + 1e-9 < cd) continue; - F.acc -= cd; - let c = enemiesIn(battle, F.a); - if (!c.length) c = enemiesIn(battle, unit); - if (!c.length) continue; - sortEnemyTargets(battle, F.a, c, null); - // "每1.5秒对一名敌人造成…法术伤害并仅对该干员触发焰影苇草特性": the trait heal (professions.js - // installIncantation) runs for this damage and names the carrier instead of the lowest-HP ally in range - battle.dealDamage(unit, c[0], { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'fireball'], traitAlly: F.a }); - battle.fx('strike', { x: c[0].x, y: c[0].y, id: c[0].id, src: F.a.id }); - } - }, - onEnd({ battle, unit }) { - for (const F of unit.mem.reedFire || []) battle.removeBuff(F.a, `reed2:fireball:${unit.id}`); - unit.mem.reedFire = null; - }, - }, - }; - return { - skills, - install(battle, unit) { - // module “独属自己的一隅”: "攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至110%" - const ds = num(mod.damage_scale, 1); - if (ds > 1) aura(battle, unit, 0.25, () => { - if (battle.injuredAlliesInKeys(unit.rangeKeys, unit).some((a) => a.kind === 'op' && a !== unit)) { - battle.addBuff(unit, { key: 'reed2:corner', mods: { dmgDealtMul: ds }, duration: 0.4, refresh: 'replace' }); - } - }); - }, - skill: { - kind: 'duration', - mods: { atkPct: selfAtk }, - targeting: { maxTargets: Math.max(1, Math.floor(num(bb.max_target, 1))) }, - onStart({ unit }) { unit.mem.reedAcc = 0; }, - onTick({ battle, unit, dt }) { - unit.mem.reedAcc += dt; - if (unit.mem.reedAcc + 1e-9 < 1) return; - unit.mem.reedAcc -= 1; - if (!(dot > 0)) return; - for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('reed2:scorch')) { - battle.dealDamage(unit, e, { amount: unit.s.atk * dot, type: 'arts', isSkill: true, tags: ['skill', 'scorch'] }); - } - }, - }, - talents: [ - { install(battle, unit) { // 灼痕 (+ S3 kill explosions) - battle.on('damaged', (ctx) => { - const t = ctx.target; - if (ctx.source !== unit || !t || t.side !== 'enemy' || !t.alive || ctx.type === 'element') return; - if (ctx.dmg?.tags?.includes('scorch')) return; - const p = unit.skill?.active ? sProb : num(t0.prob); - if (p >= 1 || (p > 0 && battle.rng() < p)) scorch(battle, unit, t); - }, { owner: unit }); - battle.on('kill', ({ victim }) => { - if (!unit.skill?.active || !live(unit) || victim.side !== 'enemy' || !victim.findBuff('reed2:scorch') || !(aoe > 0)) return; - const x = victim.x, y = victim.y; - // deferred to the next scheduler pass: chained explosions must not nest kill → damage → kill hooks - battle.after(0, () => { - if (!live(unit)) return; - battle.fx('scorchBurst', { x, y, id: unit.id, r: aoeR }); - for (const e of battle.foesInRadius(x, y, aoeR, true)) { // splash around the victim: 中点判定 - if (!e.alive) continue; - battle.dealDamage(unit, e, { amount: unit.s.atk * aoe, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'scorch'] }); - scorch(battle, unit, e); - } - }, { owner: unit }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 映耀 - const share = num(t1.scale), boost = num(t1.heal_scale, 1); - battle.on('heal', (ctx) => { - if (ctx.source !== unit || ctx.target === unit || ctx.opts?.reflect) return; - if (boost !== 1) ctx.amount *= boost; - if (share > 0 && ctx.amount > 0) battle.heal(unit, unit, ctx.amount * share, { self: true, reflect: true }); - }, { owner: unit }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 塑心 chess_char_6_09 (巫役) — S1 “黄金的狂喜”; 无词哀歌; 精神逆构; module 强弱法 - -function cello(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess); - const sid = selectedSkill(chess, def); - const isS1 = sid === 'skchr_cello_1' || !(chess?.skills?.length); // S1 = the default (no loadout info ⇒ default) - // "第二天赋效果提升至N倍" (S3): every 精神逆构 number scaled around 1 (delta × N) while it runs - const boost = (c) => (c.mem.celloBoost > 0 ? c.mem.celloBoost : 1); - const scaled = (v, c) => 1 + (v - 1) * boost(c); - /** S2 partner: the other operator of her range with the highest ATK (re-picked every 0.5 s). */ - const pickPartner = (battle, unit) => { - unit.mem.celloPick = 0.5; - const ops = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op'); - unit.mem.celloPartner = ops.reduce((best, a) => (!best || a.s.atk > best.s.atk + 1e-9 ? a : best), null); - }; - const skills = { - // S2 “安魂的弥撒”: ASPD +, one extra target; while it runs every damage she or the highest-ATK other operator of her - // range deals to an enemy also adds ep_damage_ratio × her ATK 凋亡损伤 (install) - skchr_cello_2: { - kind: 'duration', - mods: { aspd: num(bb.attack_speed) }, - targeting: { maxTargets: 2 }, - onStart({ battle, unit }) { pickPartner(battle, unit); }, - onTick({ battle, unit, dt }) { - unit.mem.celloPick -= dt; - if (unit.mem.celloPick > 0 && live(unit.mem.celloPartner)) return; - pickPartner(battle, unit); - }, - onEnd({ unit }) { unit.mem.celloPartner = null; }, - }, - // S3 “自由的探戈”: stops attacking, skill range, ATK +atk, 精神逆构 ×scale_delta_to_one; the other operators of her - // range with the highest max HP / ATK / DEF get +20 % of that stat - skchr_cello_3: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - ...(skillGridOf(def) ? { targeting: { rangeGrid: skillGridOf(def) } } : {}), - attack: { noAttack: true }, - onStart({ unit }) { unit.mem.celloBoost = num(bb.scale_delta_to_one, 1); unit.mem.celloAcc = Infinity; }, - onTick({ battle, unit, dt }) { - unit.mem.celloAcc += dt; - if (unit.mem.celloAcc < 0.5) return; - unit.mem.celloAcc = 0; - const ops = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op'); - const top = (f) => ops.reduce((best, a) => (!best || f(a) > f(best) + 1e-9 ? a : best), null); - const give = (a, key, mods) => { if (a) battle.addBuff(a, { key, mods, duration: 0.75, refresh: 'replace', source: unit, visible: true }); }; - give(top((a) => a.base.maxHp), 'cello:tango:hp', { hpPct: num(bb['cello_s_3[max_hp].max_hp']) }); - give(top((a) => a.base.atk), 'cello:tango:atk', { atkPct: num(bb['cello_s_3[atk].atk']) }); - give(top((a) => a.base.def), 'cello:tango:def', { defPct: num(bb['cello_s_3[def].def']) }); - }, - onEnd({ unit }) { unit.mem.celloBoost = 1; }, - }, - }; - return { - skills, - // S1 "技能未开启时无法普通攻击" and its 未处于损伤爆发期间 target pick belong to S1 only - trait: isS1 ? { noAttackUnlessSkill: true, priority: 'notBurst' } : null, - install(battle, unit) { - if (sid !== 'skchr_cello_2') return; - const ratio = num(bb.ep_damage_ratio); - // PRTS 塑心 S2 备注: "受影响的干员即将造成伤害时,因该效果造成的凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源 - // 始终为塑心" — a late `hit` handler (after every handler that may cancel the damage), so the 凋亡 lands while the - // target is alive and may burst before the damage; 无来源 bursts (source null) never trigger it. It rides the - // damage about to be dealt, so a hit dodged or absorbed afterwards still carried it [ASSUMED]. - battle.on('hit', (ctx) => { - const s = ctx.source, t = ctx.target, d = ctx.dmg; - if (!(ratio > 0) || !unit.skill?.active || !live(unit) || !s || !t || t.side !== 'enemy' || !hasHp(t) || !d || d.cancel) return; - if (d.type === 'element' || d.type === 'elemental' || !(d.amount > 0) || (s !== unit && s !== unit.mem.celloPartner)) return; - elementDmg(battle, unit, t, 'apoptosis', unit.s.atk * ratio); - }, { owner: unit, priority: -1000 }); - }, - skill: { - kind: 'charges', - targeting: { priority: 'notBurst' }, - attack: { - atkScale: num(bb.atk_scale, 1), - onHit({ battle, unit, target }) { if (target && target.alive) elementDmg(battle, unit, target, 'apoptosis', unit.s.atk * num(bb.ep_damage_ratio)); }, - }, - }, - talents: [ - { install(battle, unit) { // 无词哀歌 - const er = num(t0.ep_damage_ratio), slug = num(t0.sluggish); - aura(battle, unit, 1, () => { - for (const e of enemiesIn(battle, unit)) { - if (er > 0) elementDmg(battle, unit, e, 'apoptosis', unit.s.atk * er, ['talent']); - if (slug > 0 && e.alive) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit }); - } - }); - } }, - { install(battle, unit) { // 精神逆构 (+ module element fragile / field-wide + DoT) and trait module (×1.18 vs elite/leader) - const amp = num(t1.ep_damage_scale, 1), frag = num(t1.damage_scale, 1) - 1, elite = num(tb.ep_damage_scale, 1); - // module 音乐家的旅程: "在场时,全场敌人…" (field-wide) + damage_value 元素伤害 per second during the burst - const dot = num(t1.damage_value), fieldWide = t1.damage_value != null, dotIv = Math.max(0.1, num(t1.interval, 1)); - unit.mem.celloAmp = amp; - unit.mem.celloWide = fieldWide; - // one battle-wide handler: every apoptosis fill on an enemy inside some 塑心's range (or anywhere for a field-wide - // one) × the strongest 精神逆构 (the same effect from two 塑心 does not stack) - const S = bstate(battle); - if (!S.cellos) { - S.cellos = new Set(); - battle.on('elementHit', (ctx) => { - const t = ctx.target, d = ctx.dmg; - if (!d || d.type !== 'element' || d.element !== 'apoptosis' || !t || t.side !== 'enemy') return; - let best = 1; - for (const c of S.cellos) { - if (!live(c) || !(c.mem.celloWide || bodyInKeys(t, c.rangeKeySet))) continue; - const v = scaled(c.mem.celloAmp, c); - if (v > best) best = v; - } - if (best > 1) d.mul *= best; - }); - } - if (amp > 1) S.cellos.add(unit); - // 强弱法 (elite trait): her element damage vs elite / leader enemies ×1.18 - if (elite > 1) onElementHit(battle, unit, (ctx) => (ctx.source === unit && ctx.target.side === 'enemy' && isElite(ctx.target) ? elite : 1)); - // "凋亡损伤爆发期间受到5%的元素脆弱" — the 元素脆弱 status (同名效果取最高) - if (frag > 0) aura(battle, unit, 0.25, () => { - for (const e of enemiesIn(battle, unit)) if (e.findBuff('apoptosisBurst')) battle.applyStatus(e, 'elemFragile', { duration: 0.4, value: frag * boost(unit), source: unit }); - }); - if (dot > 0) aura(battle, unit, dotIv, () => { - for (const e of battle.enemies) { - if (e.alive && !e.hidden && e.findBuff('apoptosisBurst')) battle.dealDamage(unit, e, { amount: dot * boost(unit), type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'elementDmg'] }); - } - }); - // module 音乐家的旅程 trait: "攻击范围内敌人受到10%的元素脆弱" - const mFrag = num(mod.damage_scale, 1) - 1; - if (mFrag > 0) aura(battle, unit, 0.25, () => { - for (const e of enemiesIn(battle, unit)) battle.applyStatus(e, 'elemFragile', { duration: 0.4, value: mFrag, source: unit }); - }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 妮芙 chess_char_6_10 (本源术师, hidden tier-6 entry) — S2 怵然震爆; 失魂; 窥心钥; module 心声 - -function nymph(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const soul = (battle, unit, t, scale) => { - const burst = t.findBuff('apoptosisBurst'); - if (!burst || !(scale > 0)) return; - const key = `nymph:soul:${unit.id}`; - const cur = t.findBuff(key); - if (cur) { cur.data.scale = Math.max(cur.data.scale, scale); cur.timeLeft = Math.max(cur.timeLeft, burst.timeLeft); return; } - battle.addBuff(t, { - key, duration: Math.max(0.1, burst.timeLeft), interval: 1, source: unit, data: { scale }, - // 元素伤害 = the 'elemental' damage type (no DEF/RES, × elementalTakenMul) - onTick: ({ unit: e, buff }) => { if (e.findBuff('apoptosisBurst')) battle.dealDamage(unit, e, { amount: unit.s.atk * buff.data.scale, type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'elementDmg'] }); }, - }); - }; - return { - skill: { - kind: 'charges', - attack: { - atkScale: num(bb.atk_scale, 1), - splashRadius: num(bb.projectile_range, 1.5), - splashScale: 1, - onHit({ battle, unit, target }) { - if (target && target.alive && num(bb.fear) > 0) battle.applyStatus(target, 'fear', { duration: num(bb.fear), source: unit }); - battle.fx('shockBlast', { x: target ? target.x : unit.x, y: target ? target.y : unit.y, id: unit.id }); - }, - }, - }, - talents: [ - { install(battle, unit) { // skill apoptosis (per damaged target) + 失魂 - battle.on('damaged', (ctx) => { - const t = ctx.target; - if (ctx.source !== unit || !t || t.side !== 'enemy' || ctx.type === 'element' || !ctx.dmg?.isAttack) return; - const skillHit = !!ctx.dmg.isSkill; - if (t.alive) soul(battle, unit, t, skillHit ? num(bb.element_atk_scale, num(t0.element_atk_scale)) : num(t0.element_atk_scale)); - if (skillHit && t.alive) elementDmg(battle, unit, t, 'apoptosis', ctx.amount * num(bb.ep_damage_ratio)); - }, { owner: unit }); - } }, - { install(battle, unit) { // 窥心钥 (elite module: field-wide, +ASPD at max stacks) - const atk = num(t1.atk), max = Math.max(1, Math.floor(num(t1.max_stack_cnt, 10))); - const fieldWide = t1.stack_cnt_check != null, as = num(t1.attack_speed); - battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.nymphKey = 0; }, { owner: unit }); - battle.on('elementBurst', ({ target, element }) => { - if (element !== 'apoptosis' || !live(unit) || !target || target.side !== 'enemy') return; - if (!fieldWide && !bodyInKeys(target, unit.rangeKeySet)) return; - unit.mem.nymphKey = Math.min(max, (unit.mem.nymphKey || 0) + 1); - const n = unit.mem.nymphKey; - battle.addBuff(unit, { key: 'nymph:key', mods: { atkPct: atk * n, aspd: fieldWide && n >= max ? as : 0 } }); - }, { owner: unit }); - } }, - { install(battle, unit) { // elite module: ×1.1 vs targets in an element burst - const ds = num(tb.damage_scale, 1); - if (ds > 1) battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.target.s.flags.burstLock) ctx.dmg.mul *= ds; }, { owner: unit }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 缪尔赛思 chess_char_6_11 (战术家) — S3 浅层非熵适应; 净水即生命 (流形); 开源节流; module 梳妆流形 - -/** - * The operator a 流形 copies: the nearest (Chebyshev tiles) living operator of its player on the field, not the summoner; - * ties → higher base ATK → lower id. Same pick as content/tokens.js (unmanaged 流形), so both paths agree. - */ -function pickCopyTarget(battle, owner, t) { - let best = null, bs = null; - for (const a of battle.allyUnits) { - if (a.kind !== 'op' || !live(a) || a === owner || a.ownerId !== owner.ownerId) continue; - const s = [Math.max(Math.abs(a.tileR - t.tileR), Math.abs(a.tileC - t.tileC)), -a.base.atk, a.id]; - let less = !bs; - if (!less) for (let i = 0; i < 3; i++) { if (s[i] < bs[i]) { less = true; break; } if (s[i] > bs[i]) break; } - if (less) { best = a; bs = s; } - } - return best; -} - -/** - * 流形 copy (token talent "复制目标90%的生命值、攻击力、防御力、法抗,以及阻挡数、攻击间隔、攻击范围、初始伤害类型(不攻击、 - * 治疗类型则不继承)"): scale × HP/ATK/DEF/RES, block count, BAT/ASPD, range and damage type. The attack itself stays the - * token's single-target attack (melee or ranged after the copied position) — splash/chain/multi-hit shapes are not - * listed and not copied. `ranged` (近/远程位) decides the special trait (steal / split). - */ -function copyInto(battle, t, src, scale, ranged) { - const sb = src.base, b = t.base; - b.maxHp = Math.max(1, num(sb.maxHp, b.maxHp) * scale); - for (const k of ['atk', 'def', 'res']) if (Number.isFinite(sb[k])) b[k] = sb[k] * scale; - for (const k of ['blockCnt', 'bat', 'aspd']) if (Number.isFinite(sb[k])) b[k] = sb[k]; - if (Array.isArray(src.rangeGrid) && src.rangeGrid.length) t.rangeGrid = src.rangeGrid; - const sp = src.profile || {}; - const p = t.profile; - if (p) { - p.attack = ranged ? 'ranged' : 'melee'; - // (a 阵法术师 / 轰击术师's instant 'beam' is their every-enemy-in-range shape: the copy fires a plain bolt) - p.projectile = ranged ? (sp.projectile && sp.projectile !== 'none' && sp.projectile !== 'orb' && sp.projectile !== 'beam' ? sp.projectile : 'bolt') : 'none'; - p.canHitFly = ranged ? true : !!sp.canHitFly; - if (!(sp.dmgType === 'heal' || sp.dmgType === 'none' || sp.noAttack || sp.noAttackUnlessSkill)) p.dmgType = sp.dmgType; - p.heal = null; - p.noAttack = false; - p.noAttackUnlessSkill = false; - } - battle.removeBuff(t, 'mlyss:stolen'); - t.mem.mlyss = { ranged, from: src.id, stolenAtk: 0, stolenDef: 0, attacks: 0 }; // "复制后重置" - t.markDirty(); - t.hp = t.s.maxHp; - battle.refreshRange(t); // the copied grid: current + initial range, trigger keys -} - -function mlyss(bb, chess, def) { - const tokId = def?.talents?.[0]?.tokenKey || (chess?.tokens || [])[0] || 'token_10030_mlyss_wtrman'; - const t1 = tbb(def, 1), mod = moduleBb(chess); - const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); - const reinfCut = parseN(chess?.trait?.moduleDesc, /伤害降低(\d+(?:\.\d+)?)%/, 0) / 100; - // S3 (default) only: melee pulse interval, ranged-copy bind - const pulseIv = Math.max(0.2, bv(bb, 'interval', 2)), bindDur = isDef ? bv(bb, 'duration', 1.5) : 0; - // "自身与流形攻击力+N%(攻击速度+N)" — every skill of hers buffs the 流形 too while it runs - const manifoldMods = { atkPct: num(bb.atk), ...(num(bb.attack_speed) ? { aspd: num(bb.attack_speed) } : {}) }; - const buffManifold = (battle, t, dur) => battle.addBuff(t, { key: 'mlyss:s3', mods: manifoldMods, duration: dur }); - // module 落叶四季 (开源节流): copying a 莱茵生命 operator ⇒ she gains `sp`; deploying the copied one ⇒ it gains `sp_other` - const rhineSp = num(mod.sp), rhineSpOther = num(mod.sp_other); - const tokTal = (t, key) => { for (const x of t.def?.talents || []) if (x?.bb && typeof x.bb[key] === 'number') return x.bb; return {}; }; - // "其被击败后会在25秒后自动刷新" (token talent interval; the talent text as a fallback) - const respawnOf = (t) => num(tokTal(t, 'scale').interval, parseN(tdesc(def, 0), /(\d+(?:\.\d+)?)秒后自动刷新/, 25)); - const isRanged = (src) => (src.def?.position ? src.def.position === 'RANGED' : src.profile?.attack === 'ranged'); - - const copyNearest = (battle, unit, t) => { - const src = pickCopyTarget(battle, unit, t); - if (!src) { // nobody to copy: the copy skill is not spent — it ends and stays ready (no attack without a copy) - if (t.skill?.active) { t.skill.end('noTarget'); t.skill.gainSp(t.skill.spCost, 'refund'); } - return; - } - copyInto(battle, t, src, num(tokTal(t, 'scale').scale, 1), isRanged(src)); - battle.fx('copy', { x: t.x, y: t.y, id: t.id, src: src.id }); - if (rhineSp > 0 && RHINE.has(src.def?.charId) && unit.skill) unit.skill.gainSp(rhineSp, 'talent'); - }; - const mineLive = (battle, unit) => battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x)); - - const skills = { - // S1 渐进性润化: fake_cost DP over the skill (1 every mlyss_s_1[cost].interval s, the rest at its end), she and her - // 流形 ATK / ASPD + - skchr_mlyss_1: { - kind: 'duration', - mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, - onStart({ battle, unit, skill }) { - unit.mem.mlyDp = { acc: 0, got: 0 }; - for (const t of mineLive(battle, unit)) buffManifold(battle, t, skill.timeLeft); - }, - onTick({ battle, unit, dt }) { - const m = unit.mem.mlyDp; - if (!m) return; - const iv = Math.max(0.1, bv(bb, 'interval', 1.364)), total = Math.floor(num(bb.fake_cost, 11)), per = bv(bb, 'cost', 1); - m.acc += dt; - while (m.acc + 1e-9 >= iv && m.got < total) { m.acc -= iv; m.got++; battle.addDp(unit.ownerId, per); } - }, - onEnd({ battle, unit, reason }) { - const m = unit.mem.mlyDp; - unit.mem.mlyDp = null; - const total = Math.floor(num(bb.fake_cost, 11)); - if (m && reason === 'duration' && m.got < total) battle.addDp(unit.ownerId, (total - m.got) * bv(bb, 'cost', 1)); - }, - }, - // S2 生态耦合: +cost DP, she and her 流形 ATK +; melee copies regenerate hp ratio / s and take 庇护; ranged copies - // attack twice at random targets of their range (install) - skchr_mlyss_2: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - onStart({ battle, unit, skill }) { - battle.addDp(unit.ownerId, num(bb.cost)); - battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost) }); - for (const t of mineLive(battle, unit)) buffManifold(battle, t, skill.timeLeft); - }, - }, - }; - - return { - skills, - install(battle, unit) { - if (sid === 'skchr_mlyss_2') { - const regen = num(bb.hp_recovery_per_sec_by_max_hp_ratio), dr = num(bb.damage_resistance); - battle.every(0.25, () => { - const on = !!unit.skill?.active && live(unit); - for (const t of battle.allyUnits) { - if (!isTok(t, tokId, unit) || !t.profile) continue; - const m = t.mem.mlyss; - if (t.mem.mlyssHits == null) t.mem.mlyssHits = Math.max(1, Math.floor(num(t.profile.hits, 1))); - const ranged = !!(m && m.ranged); - t.profile.hits = on && ranged && live(t) ? t.mem.mlyssHits * 2 : t.mem.mlyssHits; // 二连击 - if (on && m && !ranged && live(t)) { - battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen, physTakenMul: 1 - dr, artsTakenMul: 1 - dr } }); - } - } - }, { owner: unit }); - battle.on('beforeAttack', (ctx) => { // ranged copies: 随机攻击范围内的目标 - const t = ctx.attacker; - if (!isTok(t, tokId, unit) || !t.mem.mlyss?.ranged || !unit.skill?.active) return; - const c = battle.enemiesInKeys(t.rangeKeys, t, ctx.profile); - if (c.length) ctx.targets = [battle.rng.pick(c)]; - }, { owner: unit }); - } - if (rhineSpOther > 0) battle.on('deploy', ({ unit: u, initial }) => { // the copied 莱茵生命 operator is deployed - if (initial || !u || u.kind !== 'op' || u.ownerId !== unit.ownerId || !RHINE.has(u.def?.charId) || !u.skill) return; - if (battle.allyUnits.some((t) => isTok(t, tokId, unit) && live(t) && t.mem.mlyss?.from === u.id)) u.skill.gainSp(rhineSpOther, 'talent'); - }, { owner: unit }); - }, - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - onStart({ battle, unit, skill }) { - battle.addDp(unit.ownerId, num(bb.cost)); - battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost) }); - const S = unit.mem; - let ranged = false; - for (const t of battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x))) { - buffManifold(battle, t, skill.timeLeft); - if (t.mem.mlyss?.ranged) ranged = true; - } - if (ranged) { // 刷新所有流形: heal the living ones, respawn the destroyed ones now - for (const t of battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x))) t.hp = t.s.maxHp; - for (const p of (S.mlyssPending || []).splice(0)) { p.timer?.cancel?.(); p.spawn(); } - } - S.mlyssPulse = battle.every(pulseIv, () => { // melee copies: pull the 8 surrounding tiles + stun blocked units - if (!unit.skill?.active) return; - for (const t of battle.allyUnits) { - if (!isTok(t, tokId, unit) || !live(t) || !t.mem.mlyss || t.mem.mlyss.ranged) continue; - for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force)); - for (const e of t.blocking) if (e.alive) battle.applyStatus(e, 'stun', { duration: pulseIv + 0.1, source: unit }); - battle.fx('pulse', { x: t.x, y: t.y, id: t.id }); - } - }, { owner: unit, immediate: true }); - }, - onEnd({ unit }) { if (unit.mem.mlyssPulse) unit.mem.mlyssPulse.cancel(); unit.mem.mlyssPulse = null; }, - }, - talents: [ - { install(battle, unit) { // 净水即生命: 流形 copy / respawn / melee steal / ranged split - unit.mem.mlyssPending = []; - const S = bstate(battle); - S.robbed ??= new WeakMap(); // enemy → { atk, def } stolen by any 流形 - const mine = (t) => isTok(t, tokId, unit); - // her 流形 on the field (split clones aside: deployLimit 1 counts the 流形 itself) - const standing = () => battle.allyUnits.some((t) => mine(t) && live(t) && !t.mem.mlyssClone); - const cancelPending = () => { for (const p of unit.mem.mlyssPending.splice(0)) p.timer?.cancel?.(); }; - battle.on('deploy', ({ unit: t }) => { - if (!mine(t)) return; - const clone = unit.mem.mlyssCloneOf; - if (clone) { // a split copy: same stats as its origin, never copies/splits/respawns itself - unit.mem.mlyssCloneOf = null; - t.mem.mlyssClone = true; - t.mem.isClone = true; // content/tokens.js skips clones too (her redeploy must bring a real 流形, not adopt a clone) - copyInto(battle, t, clone, 1, true); - return; - } - cancelPending(); // a 流形 stands again (tactical re-summon, S3 refresh): no second one later - t.mem.mlyssHome = [t.tileR, t.tileC]; - const sp = num(tokTal(t, 'sp').sp); - if (sp > 0 && !unit.mem.mlyssFirst && t.skill) t.skill.gainSp(sp, 'talent'); // elite: first deployment +5 SP - unit.mem.mlyssFirst = true; - if (unit.skill?.active && unit.skill.isTimed) buffManifold(battle, t, unit.skill.timeLeft); - }, { owner: unit }); - battle.on('skillStart', ({ unit: t }) => { if (mine(t) && !t.mem.mlyssClone) copyNearest(battle, unit, t); }, { owner: unit }); - // trigger rule MLYSS_WTRMAN ("全部技能,受流形影响") [ASSUMED]: DEFAULT (engine), and an enemy inside the range of one - // of her copied 流形 also satisfies it (her S3 acts through the 流形: pull/stun or bind around them) - if (unit.skill) unit.skill.addTriggerRange(() => battle.allyUnits.filter((t) => mine(t) && live(t) && t.mem.mlyss)); - battle.on('tick', () => { // without a dedicated token kit the copy fires once ready and someone can be copied - for (const t of battle.allyUnits) { - if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready || t.skill.opCooling) continue; - if (pickCopyTarget(battle, unit, t)) t.skill.activate('MLYSS_WTRMAN'); - } - }, { owner: unit }); - // "其被击败后会在25秒后自动刷新": one pending respawn at a time, on its tile, only while she is on the field and no - // 流形 of hers stands (her redeploy summons it again as her 援军, tokens.js); expired / replaced ones do not return - battle.on('death', ({ unit: t, reason }) => { - if (t === unit) { cancelPending(); return; } - if (!mine(t) || t.mem.mlyssClone || reason !== 'killed' || !t.mem.mlyssHome || standing()) return; - cancelPending(); - const [r, c] = t.mem.mlyssHome; - const entry = { spawn: null, timer: null }; - let tries = 0; - entry.spawn = () => { - const i = unit.mem.mlyssPending.indexOf(entry); - if (i >= 0) unit.mem.mlyssPending.splice(i, 1); - if (battle.finished || !live(unit) || standing() || tries++ > 120) return; - if (battle.isReservedTile(r, c) || !battle.spawnToken(unit, tokId, r, c)) { // its tile is taken: retry in 1 s - entry.timer = battle.after(1, entry.spawn, { owner: unit }); - unit.mem.mlyssPending.push(entry); - } - }; - entry.timer = battle.after(respawnOf(t), entry.spawn, { owner: unit }); - unit.mem.mlyssPending.push(entry); - }, { owner: unit }); - battle.on('attack', ({ attacker: t, targets }) => { - if (!mine(t) || !t.mem.mlyss || !live(t)) return; - const tal = tokTal(t, 'steal_atk'); - const m = t.mem.mlyss; - if (!m.ranged) { // melee copy: 每次攻击偷取敌方10点攻击力与防御力(最高250点)— a capped copy steals nothing more - const sa = num(tal.steal_atk), sd = num(tal.steal_def, sa); - const capA = num(tal.steal_atk_max, Infinity), capD = num(tal.steal_def_max, capA); - let changed = false; - for (const e of targets) { - if (!e.alive || e.side !== 'enemy') continue; - const ga = Math.max(0, Math.min(sa, capA - m.stolenAtk)), gd = Math.max(0, Math.min(sd, capD - m.stolenDef)); - if (!(ga > 0 || gd > 0)) break; - m.stolenAtk += ga; m.stolenDef += gd; - const rb = S.robbed.get(e) || { atk: 0, def: 0 }; - rb.atk += ga; rb.def += gd; - S.robbed.set(e, rb); - battle.addBuff(e, { key: 'mlyss:robbed', mods: { atkFlat: -rb.atk, defFlat: -rb.def }, source: t }); - changed = true; - } - if (changed) battle.addBuff(t, { key: 'mlyss:stolen', mods: { atkFlat: m.stolenAtk, defFlat: m.stolenDef } }); - } else if (!t.mem.mlyssClone) { // ranged copy: split every N attacks (the clone lasts `interval` s) - const every = Math.max(1, Math.floor(bv(tal, 'max_stack_cnt', 10))); - if (++m.attacks % every !== 0) return; - const tiles = N4.slice(1).map(([dr, dc]) => [t.tileR + dr, t.tileC + dc]) - .filter(([r, c]) => battle.grid.inRect(r, c) && !battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: true })); - const tile = bestTile(battle, tiles); - if (!tile) return; - unit.mem.mlyssCloneOf = t; - let cl = null; - try { cl = battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: num(tal.interval, 25), kit: { skill: null } }); } finally { unit.mem.mlyssCloneOf = null; } - if (cl) battle.fx('split', { x: cl.x, y: cl.y, id: cl.id, src: t.id }); - } - }, { owner: unit }); - battle.on('damaged', (ctx) => { // S3, ranged copies: attacks bind - const t = ctx.source; - if (!mine(t) || !t.mem.mlyss?.ranged || !unit.skill?.active || !ctx.dmg?.isAttack || !ctx.target.alive || !(bindDur > 0)) return; - battle.applyStatus(ctx.target, 'bind', { duration: bindDur, source: unit }); - }, { owner: unit }); - battle.on('hit', (ctx) => { // module: 援军 / 流形 take less damage from the enemies they block - const t = ctx.target, s = ctx.source; - if (!t || !s || s.side !== 'enemy' || s.blockedBy !== t) return; - if (mine(t)) { // 流形 (her 援军): token module part, unless its token kit (content/tokens.js) applies it - if (!t.kit?.fromTokens) { const ds = num(tokTal(t, 'damage_scale').damage_scale, 1); if (ds > 0 && ds < 1) ctx.dmg.mul *= ds; } - } else if (reinfCut > 0 && t === unit.trait?.reinforcement) ctx.dmg.mul *= 1 - reinfCut; // engine 援军 - }, { owner: unit }); - // module 落叶四季: "援军阻挡的敌人更容易受到我方的攻击" — the 流形's module part (token talent taunt_level: +1) on - // the enemies it blocks: our operators pick higher-taunt enemies first (targeting.js sortEnemyTargets) - battle.every(0.25, () => { - for (const t of battle.allyUnits) { - if (!mine(t) || !live(t) || !t.blocking.length) continue; - const lv = num(tokTal(t, 'taunt_level').taunt_level); - if (!(lv > 0)) continue; - for (const e of t.blocking) if (e.alive) battle.addBuff(e, { key: 'mlyss:exposed', mods: { taunt: lv }, duration: 0.4, refresh: 'replace', source: t }); - } - }, { owner: unit }); - } }, - { install(battle, unit) { // 开源节流: 莱茵生命 ops of the owner cost less DP to redeploy - const S = bstate(battle); - S.rhine ??= new Set(); - if (S.rhine.has(unit.ownerId)) return; - S.rhine.add(unit.ownerId); - const cut = num(t1.cost), first = num(t1.runtime_cost); - battle.on('battleStart', () => { - const rh = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && RHINE.has(a.def?.charId)).sort((a, b) => a.deploySeq - b.deploySeq); - rh.forEach((a, i) => { a.base.cost = Math.max(0, a.base.cost + cut + (i === 0 ? first : 0)); }); - }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 迷迭香 chess_char_6_12 (投掷手) — S2 末梢阻断; 歼灭战装备; 感知稳定 - -/** S2 末梢阻断 "溅射范围扩大": radius 1.5 (PRTS 溅射半径一览, 技能: 迷迭香 末梢阻断 1.5; ×1.3 [ASSUMED] until 0.1.1). */ -const ROSMON_S2_SPLASH = 1.5; - -function rosmon(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const sid = selectedSkill(chess, def); - const prob = num(bb['attack@prob']), stun = num(bb['attack@stun']); - const gearId = (chess?.tokens || []).find((t) => /rosmon_shield/.test(String(t))) || 'token_10012_rosmon_shield'; - const skills = { - // S1 思维膨大 (attack SP): the next attack also deals extra_atk_scale × ATK arts to every enemy it hits - skchr_rosmon_1: { - kind: 'instant', - attack: { - onEachHit({ battle, unit, target, kind }) { - if (!target || !target.alive || (kind !== 'main' && kind !== 'splash')) return; - battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb.extra_atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); - }, - }, - }, - // S3 “如你所愿”: base attack time ×0.5 (akdata: 乘算负数), ATK +atk, 2 targets but blocked enemies only (install); - // two 战术装备 on melee tiles of her range (the token kit: appear stun, blocked enemies DEF −160, lifetime) - skchr_rosmon_3: { - kind: 'duration', - mods: { batPct: batOf(bb.base_attack_time, def, true), atkPct: num(bb.atk) }, - targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))) }, - onStart({ battle, unit }) { - let n = 0; - for (let i = 0; i < 2; i++) if (summonToken(battle, unit, gearId, 'melee')) n++; - battle.fx('summon', { x: unit.x, y: unit.y, id: unit.id, n }); - }, - }, - }; - return { - skills, - install(battle, unit) { - if (sid !== 'skchr_rosmon_3') return; - battle.on('beforeAttack', (ctx) => { // "仅选择被阻挡的敌人作为目标" - if (ctx.attacker !== unit || !unit.skill?.active) return; - // her range, plus the enemies she blocks herself — always her targets (Battle.blockedTargets, DESIGN §20.3) - const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile); - for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e); - for (let i = c.length - 1; i >= 0; i--) if (!c[i].blockedBy) c.splice(i, 1); - sortEnemyTargets(battle, unit, c, ctx.profile?.priority ?? null); - ctx.targets = c.slice(0, Math.max(1, Math.floor(num(ctx.profile?.maxTargets, 2)))); - }, { owner: unit }); - }, - skill: { - kind: 'duration', - mods: { batPct: batOf(bb.base_attack_time, def), atkPct: num(bb.atk) }, - onStart({ unit }) { - const p = unit.profile; - unit.mem.rosSaved = { r: p.splashRadius, n: p.shockTimes }; - p.splashRadius = Math.max(num(p.splashRadius, 0.9), ROSMON_S2_SPLASH); - p.shockTimes = num(p.shockTimes, 2) + Math.floor(num(bb.add_times)); - }, - onEnd({ unit }) { - const s = unit.mem.rosSaved; - if (s) { unit.profile.splashRadius = s.r; unit.profile.shockTimes = s.n; } - unit.mem.rosSaved = null; - }, - }, - talents: [ - { install(battle, unit) { // S2: attack & aftershock stun chance - battle.on('damaged', (ctx) => { - if (ctx.source !== unit || !unit.skill?.active || !(stun > 0) || !ctx.target.alive || ctx.target.side !== 'enemy') return; - if (!ctx.dmg?.isAttack && !ctx.dmg?.tags?.includes('aftershock')) return; - if (battle.rng() < prob) battle.applyStatus(ctx.target, 'stun', { duration: stun, source: unit }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 歼灭战装备 - const v = num(t0.def_penetrate_fixed); - if (v) battle.addBuff(unit, { key: 'rosmon:pierce', mods: { defIgnoreFlat: v }, persist: true, allowDead: true }); - } }, - { install(battle, unit) { // 感知稳定 - const atk = num(t1.atk); - battle.on('deploy', ({ unit: u }) => { - if (u !== unit || !atk) return; - battle.after(0, () => { - if (!live(unit)) return; - const casters = opsOf(battle, unit.ownerId).filter((a) => a.def?.profession === 'CASTER'); - const pick = battle.rng.pick(casters); - if (!pick) return; - battle.addBuff(unit, { key: 'rosmon:stable', mods: { atkPct: atk } }); - battle.addBuff(pick, { key: 'rosmon:stable', mods: { atkPct: atk } }); - battle.fx('link', { x: pick.x, y: pick.y, id: pick.id, src: unit.id }); - }, { owner: unit }); - }, { owner: unit }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 新约能天使 chess_char_6_13 (怪杰) — S2 开火成瘾症; 火力电台; 铳弹协约; module 新朋友圣城生活套组 -// S2 (PRTS 备注): the ally is the friendly operator of her attack range with the highest 仇恨值 ("选择的友方干员为攻击范围内 -// 仇恨值最高的我方干员": highest taunt level, then the latest deployed — targeting.js aggroCmp, the order enemies attack -// in; it used to be the highest ASPD, so the shield often went to a back-row shooter instead of the operator in front); -// both barriers are shield_max_hp_ratio × the holder's OWN max HP ("获得的屏障均以自身生命上限为标准计算"), losing -// initial/shield_max_duration per second ("屏障每秒衰减量为:初始屏障量/30"); a new one replaces the old one ("重复获得此 -// 屏障时,重置屏障量与衰减速度"). - -const AIRSTRIKE_RADIUS = 1; // [ASSUMED] bombardment splash radius (not in data) - -function angel2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const sid = selectedSkill(chess, def); - const steal = num(bb.steal), extra = Math.floor(num(bb.addtional_ammo_each)); - const shieldRatio = num(bb.shield_max_hp_ratio), shieldDur = num(bb.shield_max_duration); - const coordId = (chess?.tokens || []).find((t) => /angel2_target/.test(String(t))) || TOKEN_IDS.deliveryTarget; - const perAttack = 5; // S3 "每次攻击消耗5发" (the 5 连击) - const coord = (battle, unit) => battle.allyUnits.find((t) => isTok(t, coordId, unit) && t.alive) ?? null; - const placeCoord = (battle, unit) => { - unit.mem.angelCoord = false; - if (coord(battle, unit)) return; - const t = summonToken(battle, unit, coordId, 'melee'); - if (t) battle.fx('anchor', { x: t.x, y: t.y, id: t.id, src: unit.id }); - }; - const skills = { - // S1 天空大扫除: 8 bullets at attack@atk_scale × ATK, flyers first; stopped by hand ⇒ the remaining bullets are fired - // at random enemies of her range - skchr_angel2_1: { - kind: 'ammo', - ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 8))), - attack: { atkScale: num(bb['attack@atk_scale'], 1) }, - targeting: { priority: 'fly' }, - onAttack({ unit, skill }) { unit.mem.angelLeft = skill.ammoLeft - 1; }, - onEnd({ battle, unit, skill, reason }) { - const left = Math.max(0, Math.floor(num(unit.mem.angelLeft))); - unit.mem.angelLeft = 0; - if (reason !== 'stopped' || !unit.alive || !left) return; - for (let i = 0; i < left; i++) { - const c = battle.enemiesInKeys(unit.rangeKeys, unit, ANY); - const e = battle.rng.pick(c); - if (!e) break; - battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@atk_scale'], 1), type: 'phys', isAttack: true, isSkill: true, tags: ['skill', 'volley'] }); - battle.emit('ammoUsed', { unit, left: left - i - 1, skill }); - } - }, - }, - // S3 使命必达!: ATK +atk; attacks are 5 hits of attack@atk_scale × ATK spending 5 of the 50 bullets; at the start, - // with a 投递坐标 (placed after each deployment, install): attack@cannon_atk_scale × ATK physical splash there and the - // knocked-out ground operator with the longest remaining redeploy time lands on it with attack@sp SP - skchr_angel2_3: { - kind: 'ammo', - ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 50))), - mods: { atkPct: num(bb.atk) }, - attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: perAttack }, - onStart({ battle, unit }) { - if (unit.mem.angelCoord) placeCoord(battle, unit); - const c = coord(battle, unit); - if (!c) return; - const r = c.tileR, col = c.tileC; - battle.fx('airstrike', { x: col, y: r, id: unit.id, r: AIRSTRIKE_RADIUS }); - for (const e of battle.foesInRadius(col, r, AIRSTRIKE_RADIUS)) { - if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@cannon_atk_scale'], 1), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'delivery'] }); - } - const waiting = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && !a.alive && !a.removed && a !== unit - && a.def?.position !== 'RANGED' && battle.grid.canStand(r, col, { ranged: false })); - if (!waiting.length) return; - waiting.sort((a, b) => (b.respawnAt ?? 0) - (a.respawnAt ?? 0) || b.base.respawnTime - a.base.respawnTime || a.id - b.id); - const a = waiting[0]; - battle.retreat(c, { reason: 'expired', permanent: true }); - if (battle.redeploy(a, { free: true, tile: [r, col] })) { - if (a.skill) a.skill.gainSp(num(bb['attack@sp']), 'skill'); - battle.fx('appear', { x: col, y: r, id: a.id, src: unit.id }); - } - }, - onAttack({ battle, unit, skill }) { // bullets 2..5 of the 5 连击 (the engine spends the 1st) - for (let i = 1; i < perAttack && skill.ammoLeft > 1; i++) { - skill.ammoLeft--; - battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); - } - }, - }, - }; - return { - skills, - install(battle, unit) { - if (sid === 'skchr_angel2_1') { // bullets loaded at the start (after every skillStart ammo bonus: 逃犯引渡手续 …) - battle.on('skillStart', ({ unit: u, skill }) => { if (u === unit) unit.mem.angelLeft = skill.ammoLeft; }, { owner: unit, priority: -500 }); - } - if (sid !== 'skchr_angel2_3') return; - // "部署后获得投递坐标": one coordinate per deployment, placed (the player's choice in the original) on a ground tile - // of her range on the enemy path next to the first enemy that enters her range (at the latest when S3 starts) - battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.angelCoord = !coord(battle, unit); }, { owner: unit }); - battle.on('tick', () => { - if (!unit.mem.angelCoord || !live(unit) || !battle.enemiesInKeys(unit.rangeKeys, unit, ANY).length) return; - placeCoord(battle, unit); - }, { owner: unit }); - }, - skill: { - kind: 'ammo', - ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 10))), - mods: { batPct: batOf(bb.base_attack_time, def) }, - attack: { atkScale: num(bb['attack@atk_scale'], 1) }, - onStart({ battle, unit, skill }) { - const cands = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op' && live(a)); - cands.sort(aggroCmp); - const victim = steal > 0 ? cands[0] ?? null : null; - unit.mem.angelVictim = victim; - decayingShield(battle, unit, 'angel2:barrier', unit.s.maxHp * shieldRatio, shieldDur); - if (victim) { - battle.addBuff(victim, { key: 'angel2:stolen', mods: { aspd: -steal }, source: unit, visible: true }); - battle.addBuff(unit, { key: 'angel2:steal', mods: { aspd: steal } }); - decayingShield(battle, victim, 'angel2:barrier', victim.s.maxHp * shieldRatio, shieldDur); - if (extra > 0) skill.addAmmo(extra); - battle.fx('steal', { x: victim.x, y: victim.y, id: victim.id, src: unit.id }); - } - }, - onEnd({ battle, unit }) { - const v = unit.mem.angelVictim; - unit.mem.angelVictim = null; - if (v) battle.removeBuff(v, 'angel2:stolen'); - battle.removeBuff(unit, 'angel2:steal'); - }, - }, - talents: [ - { install(battle, unit) { // 火力电台 - const hr = num(t0.hp_ratio), prob = num(t0.prob), sc = num(t0.aoe_atk_scale, num(t0.damage_scale)); - battle.on('ammoUsed', ({ unit: u }) => { - if (!live(unit) || !u || u.side !== 'ally') return; - if (hr > 0) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true, silent: true }); - if (!(prob > 0) || !(sc > 0) || battle.rng() >= prob) return; - const cands = battle.enemiesInKeys(u.rangeKeys || [], u, ANY); - if (!cands.length) return; - sortEnemyTargets(battle, u, cands, null); - const c = cands[0]; - battle.fx('airstrike', { x: c.x, y: c.y, id: unit.id, r: AIRSTRIKE_RADIUS }); - for (const e of battle.foesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定 - if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, isSplash: true, tags: ['talent', 'airstrike'] }); - } - }, { owner: unit }); - } }, - { install(battle, unit) { // 铳弹协约 - const atk = num(t1.atk), mult = num(t1.mult, 1); - if (atk) aura(battle, unit, 0.5, () => { - for (const a of battle.allyUnits) { - if (!live(a) || a.kind !== 'op' || !a.skill || a.skill.kind !== 'ammo') continue; - battle.addBuff(a, { key: 'angel2:covenant', mods: { atkPct: atk * (hasBond(a, 'lateranoShip') ? mult : 1) }, duration: 0.75, refresh: 'replace' }); - } - }); - } }, - { install(battle, unit) { // elite module: HP > 80 % ⇒ SP +0.25/s - const thr = bv(tb, 'hp_ratio', 0), spr = bv(tb, 'sp_recovery_per_sec', 0); - if (spr > 0 && tb['angel2_tr[e].hp_ratio'] != null) aura(battle, unit, 0.25, () => { - if (unit.hpRatio > num(tb['angel2_tr[e].hp_ratio'], thr)) battle.addBuff(unit, { key: 'angel2:calm', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' }); - }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 流明 chess_char_6_14 (疗养师) — S3 灯火不灭; 凡人之愿; 应急处理; module 纯铜单筒望远镜 - -function lumen(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const isDef = onDefaultSkill(chess); - const hs = num(bb.heal_scale, 1); - const abnormalAllies = (battle, unit) => battle.alliesInGrid(unit).filter((a) => hasAbnormal(a)).sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq); - const cleanse = (battle, t) => { cleanseAbnormal(battle, t); }; - const skills = { - // S1 沐雨: the next heal also gives its target and the allies around it (8 tiles) aura.heal_scale × ATK healing - // per second for aura.projectile_life_time s - skchr_lumen_1: { - kind: 'instant', - heal: true, - attack: { healScale: 1 }, - onHit({ battle, unit, target }) { - if (!target) return; - const sc = bv(bb, 'heal_scale'), life = bv(bb, 'projectile_life_time', 4), iv = Math.max(0.2, bv(bb, 'interval', 1)); - if (!(sc > 0) || !(life > 0)) return; - battle.fx('healField', { x: target.x, y: target.y, id: unit.id }); - for (const a of battle.alliesInRadius(target.x, target.y, 1.5, target.ownerId)) { - if (a.kind === 'device' || a.s.flags.noHeal || a.profile?.noHeal) continue; // a heal: never on 禁疗 / 孤立 - battle.addBuff(a, { - key: `lumen:rain:${unit.id}`, duration: life, interval: iv, refresh: 'replace', visible: true, source: unit, - onTick: ({ unit: x }) => { if (x.hp < x.s.maxHp) battle.heal(unit, x, unit.s.atk * sc, { hot: true }); }, - }); - } - }, - }, - // S2 沛霖 (2 charges): heal up to max_target allies of her range for heal_scale × ATK; cast with full charges ⇒ - // also cleanse their 异常状态 (蓄力额外效果) - skchr_lumen_2: { - kind: instantKind(def), - heal: true, - onStart({ battle, unit, skill }) { - const full = skill.maxCharges > 1 && skill.charges >= skill.maxCharges - 1; // (this cast already spent one) - const inj = battle.injuredAlliesInKeys(unit.rangeKeys, unit); - const abn = full ? abnormalAllies(battle, unit).filter((a) => !inj.includes(a)) : []; - const tgts = inj.concat(abn).slice(0, Math.max(1, Math.floor(num(bb.max_target, 2)))); - for (const t of tgts) { - battle.heal(unit, t, unit.s.atk * num(bb.heal_scale, 1), { skillHeal: true }); - if (full) cleanse(battle, t); - } - battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id, n: tgts.length, charged: full }); - }, - }, - }; - return { - skills, - skill: { - kind: 'ammo', - heal: true, - ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 4))), - mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, - attack: { healScale: 1 }, - onHit({ battle, unit, target }) { if (unit.mem.lumenAbn && target) cleanse(battle, target); }, - onAttack(ctx) { // only heals on abnormal targets consume a bullet (engine: ctx.noAmmo — no bullet, no ammoUsed) - if (!ctx.unit.mem.lumenAbn) ctx.noAmmo = true; - ctx.unit.mem.lumenAbn = false; - }, - }, - talents: [ - { install(battle, unit) { // S3 targeting / bullet bookkeeping - if (!isDef) return; - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !unit.skill?.active) return; - const abn = abnormalAllies(battle, unit); - unit.mem.lumenAbn = abn.length > 0; - if (abn.length) ctx.targets = [abn[0]]; - if (ctx.profile && ctx.profile !== unit.profile) ctx.profile.healScale = abn.length ? hs : 1; - }, { owner: unit }); - battle.on('tick', () => { // abnormal allies at full HP are healed too - const sk = unit.skill; - if (!sk?.active || !unit.canAct || unit.atkCd > 0 || unit.s.flags.disarm) return; - if (battle.injuredAlliesInKeys(unit.rangeKeys, unit).length) return; - const abn = abnormalAllies(battle, unit); - if (!abn.length) return; - battle.forceAttack(unit, [abn[0]]); - unit.atkCd = unit.s.interval; - }, { owner: unit }); - } }, - { install(battle, unit) { // 凡人之愿: healed targets gain 抵抗 - const base = bv(t0, 'status_resistance[limit]', 0), special = num(t0['lumen_t_1[special].status_resistance[limit]'], base); - const res = -num(t0.one_minus_status_resistance, -0.5), thr = num(t0.hp_ratio, 0.75); - battle.on('heal', (ctx) => { - const t = ctx.target; - if (ctx.source !== unit || !t || !(res > 0) || !(base > 0)) return; - const after = Math.min(t.s.maxHp, t.hp + ctx.amount); - battle.applyStatus(t, 'resist', { duration: after / t.s.maxHp > thr ? special : base, value: Math.min(1, res), source: unit }); - }, { owner: unit, priority: -10 }); - } }, - { install(battle, unit) { // 应急处理 - const sc = num(t1.heal_scale), cd = num(t1.duration, 12); - let ready = -Infinity; - battle.on('statusApplied', (ctx) => { - const t = ctx.target; - if (!live(unit) || !t || t.side !== 'ally' || !ABNORMAL.has(ctx.status) || battle.time < ready || !(sc > 0)) return; - if (!unit.rangeKeySet?.has(t.tileR * COLS + t.tileC)) return; - ready = battle.time + cd; - battle.heal(unit, t, unit.s.atk * sc); - battle.fx('emergency', { x: t.x, y: t.y, id: t.id, src: unit.id }); - }, { owner: unit }); - } }, - { install(battle, unit) { // elite module: taunt −1 and 抵抗 - const taunt = num(tb.taunt_level), res = -num(tb.one_minus_status_resistance); - if (taunt) battle.addBuff(unit, { key: 'lumen:scope', mods: { taunt }, persist: true, allowDead: true }); - // a permanent 抵抗 (survives death/redeploy): any buff with status 'resist' counts (engine resistOf — the - // strongest one applies, never compounding with 凡人之愿 / 灵知) - if (res > 0) battle.addBuff(unit, { key: 'lumen:scopeResist', status: 'resist', visible: true, persist: true, allowDead: true, data: { value: Math.min(1, res) } }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 仇白 chess_char_6_15 (领主) — S3 问雪; 入隙; 落英; module 雪浸过的斗笠 - -function qiubai(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const extraTargets = parseN(def?.skill?.description, /额外攻击(\d+)个目标/, 0); - const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; - const gapScale = t0.atk_scale_t != null ? num(t0.atk_scale_t) : num(t0.atk_scale), bothMul = t0.atk_scale_t != null ? num(t0.value, 1) : 1; - const modArts = num(tb.atk_scale_m); - // module 欲雪时: 落英 "首次命中敌人时提升至100%且束缚时间提升至3秒" (duration_advanced); trait "攻击范围内存在2名及以上敌人时 - // 攻击速度+12" (talent-0 part cnt / attack_speed) - const firstBind = num(t1.duration_advanced), hitOnce = new WeakSet(); - const crowdCnt = num(t0.cnt), crowdAs = num(t0.attack_speed); - const skillGridQ = skillGridOf(def); - const groundIn = (battle, unit) => enemiesIn(battle, unit).filter((e) => !e.isFlying); - const skills = { - // S1 留羽 (attack SP): the next attack binds its target `duration` s; when that bind ends the target and the enemies - // near it take aoe_scale × ATK arts - skchr_qiubai_1: { - kind: 'instant', - attack: { - onHit({ battle, unit, target }) { - if (!target || !target.alive) return; - const dur = num(bb.duration, 2); - battle.applyStatus(target, 'bind', { duration: dur, source: unit }); - const seq = unit.deploySeq; - battle.after(dur, () => { - if (!unit.alive || unit.deploySeq !== seq) return; - const x = target.x, y = target.y; // (a fallen target keeps its last position) - battle.fx('aoe', { x, y, id: unit.id }); - for (const e of battle.foesInRadius(x, y, 1.2, true)) { // splash around the target: 中点判定 - if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.aoe_scale, 1), type: 'arts', isSkill: true, isSplash: e !== target, tags: ['skill'] }); - } - }, { owner: unit }); - }, - }, - }, - // S2 承影 (attack SP, 5 s): sword_begin_atk_scale × ATK arts on the ground enemies of the front range at the start, - // skill range + ATK +atk, ground enemies in range 停顿, sword_end_atk_scale × ATK physical on them at the end - skchr_qiubai_2: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - ...(skillGridQ ? { targeting: { rangeGrid: skillGridQ } } : {}), - onStart({ battle, unit }) { - battle.fx('sword', { x: unit.x, y: unit.y, id: unit.id }); - for (const e of groundIn(battle, unit)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.sword_begin_atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); - }, - onTick({ battle, unit }) { for (const e of groundIn(battle, unit)) battle.applyStatus(e, 'sluggish', { duration: 0.25, source: unit }); }, - onEnd({ battle, unit, reason }) { - if (reason === 'death' || !unit.alive) return; - for (const e of groundIn(battle, unit)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.sword_end_atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] }); - }, - }, - }; - return { - skills, - talents: crowdCnt > 0 && crowdAs ? [{ install(battle, unit) { - aura(battle, unit, 0.25, () => { - if (enemiesIn(battle, unit).length >= crowdCnt) battle.addBuff(unit, { key: 'qiubai:crowd', mods: { aspd: crowdAs }, duration: 0.4, refresh: 'replace' }); - }); - } }] : [], - trait: { - afterHit(battle, unit, target) { - if (!target || !target.alive) return; - if (modArts > 0) battle.dealDamage(unit, target, { amount: unit.s.atk * modArts, type: 'arts', tags: ['module'] }); - const slug = !!target.findBuff('sluggish'), bind = !!(target.findBuff('bind') || target.s.flags.bind); - if ((slug || bind) && gapScale > 0 && target.alive) { // 入隙 - battle.dealDamage(unit, target, { amount: unit.s.atk * gapScale * (slug && bind ? bothMul : 1), type: 'arts', tags: ['talent'] }); - } - if (!target.alive) return; - if (firstBind > 0 && !hitOnce.has(target)) { // 落英 (module): first hit on an enemy - hitOnce.add(target); - battle.applyStatus(target, 'bind', { duration: firstBind, source: unit }); - return; - } - if (num(t1.prob) > 0 && battle.rng() < num(t1.prob)) battle.applyStatus(target, 'bind', { duration: num(t1.duration), source: unit }); // 落英 - }, - }, - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - targeting: { maxTargets: 1 + extraTargets, ...(skillGrid ? { rangeGrid: skillGrid } : {}) }, - attack: { dmgType: 'arts', dmgMul: () => 1 }, - onStart({ unit }) { unit.mem.qbStack = 0; }, - onAttack({ battle, unit }) { - const max = Math.floor(num(bb.max_stack_cnt)); - if (unit.mem.qbStack >= max) return; - unit.mem.qbStack++; - battle.addBuff(unit, { key: 'qiubai:snow', mods: { aspd: num(bb.attack_speed) * unit.mem.qbStack } }); - }, - onEnd({ battle, unit }) { battle.removeBuff(unit, 'qiubai:snow'); unit.mem.qbStack = 0; }, - }, - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 溯光星源 chess_char_6_16 (凝滞师) — S3 并流连锁; 数据建模; 能源解析 - -function halo2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess); - const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); - const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; - const n = Math.max(1, Math.floor(num(bb['attack@max_target'], 1))), share = num(bb['attack@atk_share']); - const slug = (unit) => unit.profile?.onHitStatus?.key === 'sluggish' ? num(unit.profile.onHitStatus.duration) : 0; - const skills = { - // S1 星图闪烁: ATK +atk; every attack then bounces attack@chain.max_target times between enemies (back and forth - // allowed, never twice in a row on one; attack@projectile_range tiles), each bounce a full hit with her 停顿 - skchr_halo2_1: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - attack: { - onHit({ battle, unit, target }) { - if (!target) return; - const jumps = Math.max(0, Math.floor(num(bb['attack@chain.max_target'], 3))), R = num(bb['attack@projectile_range'], 1.7); - let prev = target, px = target.x, py = target.y; - for (let i = 0; i < jumps; i++) { - let best = null, bd = Infinity; - for (const e of battle.foesInRadius(px, py, R)) { - if (e === prev || !canTargetEnemy(unit, e, ANY)) continue; - const d = bodyDist(e, px, py); - if (d < bd - 1e-9) { bd = d; best = e; } - } - if (!best) break; - battle.dealDamage(unit, best, { amount: unit.s.atk, type: 'arts', isAttack: true, isSkill: true, tags: ['chain'] }); - const sd = slug(unit); - if (sd > 0 && best.alive) battle.applyStatus(best, 'sluggish', { duration: sd, source: unit }); - prev = best; px = best.x; py = best.y; - } - }, - }, - }, - // S2 星束引力 (attack SP): the next attack targets the enemy of her range farthest from its goal (install), atk_scale - // × ATK arts with a `sluggish` s 停顿, and links up to max_target enemies within ability_range_radius of it: pulled - // toward it (force) and hit by atk_scale_link × ATK arts - skchr_halo2_2: { - kind: 'instant', - attack: { - atkScale: num(bb.atk_scale, 1), - onHitStatus: { key: 'sluggish', duration: num(bb.sluggish, 3) }, - onHit({ battle, unit, target }) { - if (!target) return; - const R = num(bb.ability_range_radius, 2), k = Math.max(0, Math.floor(num(bb.max_target, 2))); - const near = battle.foesInRadius(target.x, target.y, R).filter((e) => e !== target && canTargetEnemy(unit, e, ANY)) - .sort((a, b) => bodyDist(a, target.x, target.y) - bodyDist(b, target.x, target.y) || a.spawnSeq - b.spawnSeq) - .slice(0, k); - for (const e of near) { - battle.fx('link', { x: e.x, y: e.y, id: unit.id, ids: [target.id, e.id] }); - pullToward(battle, { x: target.x, y: target.y }, e, num(bb.force), 0.3); - if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale_link, num(bb.atk_scale, 1)), type: 'arts', isSkill: true, tags: ['skill', 'link'] }); - } - }, - }, - }, - }; - return { - skills, - install(battle, unit) { - if (sid !== 'skchr_halo2_2') return; - battle.on('beforeAttack', (ctx) => { // "选择攻击范围内距离目标点最远的1个敌人为目标" - if (ctx.attacker !== unit || !unit.skill?.active || !unit.skill.pending) return; - const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile); - if (!c.length) return; - let best = c[0]; - for (const e of c) if (battle.remainingDistance(e) > battle.remainingDistance(best) + 1e-9) best = e; - ctx.targets = [best]; - }, { owner: unit }); - }, - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) }, - targeting: { maxTargets: n, ...(skillGrid ? { rangeGrid: skillGrid } : {}) }, - onStart({ unit }) { unit.mem.haloLocks = []; }, - onEnd({ unit }) { unit.mem.haloLocks = []; }, - }, - talents: [ - { install(battle, unit) { // S3: persistent locks + damage transfer between linked targets - if (!isDef) return; - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !unit.skill?.active) return; - const valid = (e) => e && e.alive && canTargetEnemy(unit, e, ANY) && bodyInKeys(e, unit.rangeKeySet); - const keep = (unit.mem.haloLocks || []).filter(valid); - for (const e of ctx.targets) if (keep.length < n && !keep.includes(e)) keep.push(e); - ctx.targets = keep.slice(0, n); - const fresh = ctx.targets.some((e) => !(unit.mem.haloLocks || []).includes(e)); - unit.mem.haloLocks = ctx.targets.slice(); - if (fresh && ctx.targets.length > 1) battle.fx('link', { x: ctx.targets[0].x, y: ctx.targets[0].y, id: unit.id, ids: ctx.targets.map((e) => e.id) }); - }, { owner: unit }); - battle.on('hit', (ctx) => { - const locks = unit.mem.haloLocks; - if (!(share > 0) || !unit.skill?.active || !locks || locks.length < 2 || ctx.dmg.type !== 'arts' || ctx.dmg.tags.includes('link')) return; - if (!locks.includes(ctx.target) || unit.mem.haloLinking) return; - // re-entrancy guard: a transferred hit (or anything it causes, e.g. enemy damage sharing) never re-links - unit.mem.haloLinking = true; - try { - for (const o of locks) if (o !== ctx.target && o.alive) { - battle.dealDamage(ctx.source ?? unit, o, { amount: ctx.dmg.amount * share, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'link'] }); - } - } finally { unit.mem.haloLinking = false; } - }, { owner: unit }); - } }, - { install(battle, unit) { // 数据建模 (+ module ATK at max stacks) - const as = num(t0.attack_speed), max = Math.max(1, Math.floor(num(t0.max_stack_cnt, 18))), fullAtk = num(mod.atk); - battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.haloStack = 0; }, { owner: unit }); - battle.on('statusApplied', (ctx) => { - if (ctx.source !== unit || ctx.status !== 'sluggish' || !live(unit) || (unit.mem.haloStack || 0) >= max) return; - unit.mem.haloStack = (unit.mem.haloStack || 0) + 1; - const k = unit.mem.haloStack; - battle.addBuff(unit, { key: 'halo2:model', mods: { aspd: as * k, atkPct: k >= max ? fullAtk : 0 } }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 能源解析 - const base = bv(t1, 'damage_scale', 1), maxS = bv(t1, 'damage_scale_max', base), iv = bv(t1, 'interval', 7); - const stay = new WeakMap(); - aura(battle, unit, 0.25, () => { - const inR = enemiesIn(battle, unit); - for (const e of battle.enemies) if (!inR.includes(e)) stay.delete(e); - for (const e of inR) { - const t = (stay.get(e) ?? 0) + 0.25; - stay.set(e, t); - const v = t > iv ? maxS : base; - // 脆弱 (ba.fragile, 同名效果取最高): the catalogue status, refreshed while the enemy stays in range - if (v > 1) battle.applyStatus(e, 'fragile', { duration: 0.4, value: v - 1, source: unit }); - } - }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 耀骑士临光 chess_char_6_17 (无畏者) — S3 耀阳颔首; 不畏苦暗; 破晓; module 耀阳锋刃 - -function nearl2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const t0Grid = def?.talents?.[0]?.rangeGrid?.length ? def.talents[0].rangeGrid : N4; - const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; - const tokId = (chess?.tokens || []).find((t) => /nearl2_sword/.test(String(t))) || 'token_10019_nearl2_sword'; - const sunBurst = (battle, unit, r, c, scale, stun) => { - const keys = new Set(absoluteRangeKeys(N4, r, c, 1, 0)); - battle.fx('sunBurst', { x: c, y: r, id: unit.id }); - for (const e of battle.enemies) { - if (!e.alive || e.hidden || !bodyInKeys(e, keys)) continue; - battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'true', isSkill: true, tags: ['skill'] }); - if (stun > 0 && e.alive) battle.applyStatus(e, 'stun', { duration: stun, source: unit }); - } - }; - const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def); - const lastOps = { map: null }; - const skills = { - // S1 灿焰长刃 (toggle, 持续时间无限): skill range, ATK / ASPD + - skchr_nearl2_1: { - kind: 'toggle', - mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) }, - ...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}), - }, - // S2 逐夜烁光 (passive, ON_DEPLOY): for the skill duration after each deployment ATK +atk and `times` 护盾 layers - // (whole hits negated); then she withdraws and this redeploy time is ×respawn_time (×1 when the operator deployed - // right before her is 【卡西米尔】) - skchr_nearl2_2: { - kind: 'passive', - onStart({ battle, unit, skill }) { - const dur = num(skill.duration, num(def?.skill?.duration, 22)); - if (!(dur > 0)) return; - const prev = lastOps.map?.get(unit.ownerId); - const combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip'); - battle.addBuff(unit, { key: 'nearl2:night', mods: { atkPct: num(bb.atk) }, duration: dur, visible: true }); - const hits = Math.floor(num(bb.times)); - if (hits > 0) battle.addBuff(unit, { key: 'nearl2:shield', shieldHits: hits, duration: dur, visible: true }); - const seq = unit.deploySeq; - battle.after(dur, () => { - if (!live(unit) || unit.deploySeq !== seq) return; - battle.retreat(unit, { reason: 'retreat' }); - const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1); - if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul; - battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo }); - }, { owner: unit }); - }, - }, - }; - // module “骑士家族”: "被击倒时不撤退且回复所有生命但生命上限-60%,攻击速度+30(单次部署只触发1次)" and 不畏苦暗 - // "部署时及首次被击倒时…" (the burst again) - const standVal = num(tb.value), standAs = num(tb.attack_speed), standHp = num(tb.hp_ratio, 1); - return { - skills, - install(battle, unit) { - if (sid === 'skchr_nearl2_2') lastOps.map = ensureDeployTracker(battle); - if (standVal > 0) { - battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.nearlStood = false; }, { owner: unit }); - battle.on('fatal', (ctx) => { - if (ctx.unit !== unit || ctx.prevented || unit.mem.nearlStood) return; - ctx.prevented = true; - unit.mem.nearlStood = true; - battle.addBuff(unit, { key: 'nearl2:stand', mods: { hpMul: Math.max(0.05, 1 - standVal), aspd: standAs }, visible: true }); - unit.hp = Math.max(1, unit.s.maxHp * standHp); - battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id }); - battle.emit('nearl2:knockdown', { unit }); - }, { owner: unit, priority: -60 }); - } - }, - trait: num(tb.atk_scale, 1) > 1 ? { dmgMul: (b, u, t) => (t.blockedBy ? num(tb.atk_scale, 1) : 1) } : null, - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, - ...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}), - onStart({ battle, unit, skill }) { - const tile = bestTile(battle, freeTiles(battle, unit, N4)); - const sword = tile ? battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: skill.timeLeft }) : null; - unit.mem.sun = sword; - if (sword) battle.fx('summon', { x: sword.x, y: sword.y, id: sword.id, src: unit.id }); - // the token kit (content/tokens.js) performs the appear burst; without it (or without a free tile) do it here - if (!sword || !sword.kit?.fromTokens) { - sunBurst(battle, unit, sword ? sword.tileR : unit.tileR, sword ? sword.tileC : unit.tileC, num(bb.value), num(bb.value2)); - } - }, - onEnd({ battle, unit }) { - const s = unit.mem.sun; - unit.mem.sun = null; - if (s && s.alive) battle.retreat(s, { reason: 'expired', permanent: true }); - }, - }, - talents: [ - { install(battle, unit) { // S3: attacks on units blocked by her or 耀阳 deal true damage - if (!isDef) return; - battle.on('hit', (ctx) => { - if (ctx.source !== unit || !unit.skill?.active || !ctx.dmg.isAttack) return; - const b = ctx.target.blockedBy; - if (b && (b === unit || b === unit.mem.sun)) ctx.dmg.type = 'true'; - }, { owner: unit }); - } }, - { install(battle, unit) { // 不畏苦暗 — the 4 tiles around her, air units too [ASSUMED: no 对空 note on PRTS, like “耀阳”] - const last = ensureDeployTracker(battle); - const sc = num(t0.atk_scale), stun = num(t0.stun); - const dawn = (times) => { - const foes = battle.unitsInGrid(unit, t0Grid, { side: 'enemy' }); - if (foes.length) battle.fx('sunBurst', { x: unit.x, y: unit.y, id: unit.id }); - for (const e of foes) { - for (let i = 0; i < times && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'true', tags: ['talent'] }); - if (stun > 0 && e.alive) battle.applyStatus(e, 'stun', { duration: stun, source: unit }); - } - }; - const kazTimes = () => { const prev = last.get(unit.ownerId); return prev && prev !== unit && hasBond(prev, 'kazimierzShip') ? 2 : 1; }; - battle.on('deploy', ({ unit: u }) => { - if (u !== unit || !(sc > 0)) return; - unit.mem.nearlDawnTimes = kazTimes(); - dawn(unit.mem.nearlDawnTimes); - }, { owner: unit }); - // module “骑士家族”: "…及首次被击倒时" — the burst again when the trait keeps her standing - battle.on('nearl2:knockdown', ({ unit: u }) => { if (u === unit && sc > 0) dawn(unit.mem.nearlDawnTimes ?? 1); }, { owner: unit }); - } }, - { install(battle, unit) { // 破晓 - const p = num(t1.def_penetrate); - if (p) battle.addBuff(unit, { key: 'nearl2:dawn', mods: { defIgnorePct: p }, persist: true, allowDead: true }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸 - -/** - * S3 终幕·浩劫's drone flight — PRTS 荒芜拉普兰德 S3 备注 "技能流程": ① for `attack@times` (1.3) s after the cast, or after - * a drone is added ("补充浮游单元"), the drones spread evenly outward from her, one along her facing ("散开的方向始终包括 - * 自身的朝向"): 初速度 0.1, 加速度 1.9, 最大速度 2.0 — [ASSUMED] after an addition (头狼 stage 3 mid-skill) only the added - * drone spreads, along her facing, while the others carry on; ② then each picks the target nearest to itself, ties - * nearest to her ("距离自身最近>距离本体最近"), anywhere on the field, and flies at it: 初速度 2.0, 加速度 1.0, 最大速度 - * 4.0, restarting at 2.0 whenever its target leaves or turns unselectable on the way; ③ once there it stays on the target - * and attacks it like a normal drone ("此状态下的攻击行为同正常浮游单元"); when that target leaves / turns unselectable it - * reappears at a random point of the 1.5-side square around it and picks again (②). With no selectable target it circles - * (radius 0.9, 1.0 tiles/s, counter-clockwise) with its heading as the tangent, the circle on its left. Speeds in tiles/s. - */ -const WHITW2_SPREAD = Object.freeze({ v0: 0.1, acc: 1.9, max: 2 }); -const WHITW2_CHASE = Object.freeze({ v0: 2, acc: 1, max: 4 }); -const WHITW2_REAPPEAR_SIDE = 1.5; -const WHITW2_ORBIT = Object.freeze({ r: 0.9, v: 1 }); - -function whitw2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1); - const sid = selectedSkill(chess, def); - const spreadTime = Math.max(0, num(bb['attack@times'], 1.3)), R = num(bb['attack@range_radius'], 0.9); - const dmgScale = num(bb['attack@magic_atk_scale'], 1), fear = num(bb['attack@fear']), slow = num(bb['attack@move_speed']); - // "非移动敌人": blocked, or not walking (stunned, bound, asleep, waiting, speed 0 …) - const still = (e) => !!e.blockedBy || e.moving === false || !(e.s.moveSpeed > 0) || !!(e.s.flags.stun || e.s.flags.freeze || e.s.flags.bind || e.s.flags.sleep || e.s.flags.noMove); - const skillGridW = skillGridOf(def); - - // ---- S3 drones (virtual: positions in unit.mem.drones, fx events for the client) -------------------------------- - const droneCount = (unit) => 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0); - // ① `k` drones leave her evenly spread, the first along her facing (row 0 is the bottom row: angles in the (col, row) - // plane, counter-clockwise) - const releaseDrones = (unit, k) => { - const a0 = Math.atan2(unit.fwd[0], unit.fwd[1]); - for (let i = 0; i < k; i++) { - const a = a0 + (2 * Math.PI * i) / k; - unit.mem.drones.push({ x: unit.x, y: unit.y, hx: Math.cos(a), hy: Math.sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null }); - } - }; - // speed v → v + acc·dt (capped); the distance covered at the mean of the two (exact under constant acceleration) - const accelerate = (d, lim, dt) => { const v1 = Math.min(lim.max, d.v + lim.acc * dt), s = ((d.v + v1) / 2) * dt; d.v = v1; return s; }; - // ② the selectable enemy nearest to the drone, ties broken by the one nearest to her. [ASSUMED] distances are measured - // to every enemy's position (a huge enemy's centre, its 判定中心) — the owner's decision of 2026-10-04: the centre, so - // leader rounds stay close to 0.1.1; the sim's general convention for operator-side distance picks, the hit rectangle - // (body.js bodyDist: targeting.js sortEnemyTargets 'nearest', tier3 enemiesAround, 异客 / 溯光星源's chains), was - // considered — it made a huge leader (胄, 管) the nearest enemy of every drone around it (DESIGN §22.9) - const pickTarget = (battle, unit, d, ok) => { - let best = null, bd = Infinity, bh = Infinity; - for (const e of battle.enemies) { - if (!ok(e)) continue; - const de = Math.hypot(e.x - d.x, e.y - d.y), dh = Math.hypot(e.x - unit.x, e.y - unit.y); - if (de < bd - 1e-9 || (de <= bd + 1e-9 && dh < bh - 1e-9)) { best = e; bd = de; bh = dh; } - } - return best; - }; - // nothing selectable: circle counter-clockwise on its left, its heading the tangent - const circle = (d, dt) => { - const { r, v } = WHITW2_ORBIT; - if (!d.orbit) d.orbit = { cx: d.x - d.hy * r, cy: d.y + d.hx * r }; - const a = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt; - d.x = d.orbit.cx + r * Math.cos(a); d.y = d.orbit.cy + r * Math.sin(a); - d.hx = -Math.sin(a); d.hy = Math.cos(a); - }; - // one tick of one drone: spread → (pick) → chase → on the target, attacking like a normal drone. [ASSUMED] its attack - // clock runs all the time (one attack per interval of hers at most, whatever it chased in between) and its first hit - // lands as it arrives when the clock is ready; [ASSUMED] a drone on a target sits at its position (a huge enemy's - // centre, its 判定中心), and it reaches a huge enemy when it touches the hit rectangle. [ASSUMED] the turn rate (PRTS - // 转向速度 1/6 per frame = attack@projectile_turn_speed × 1/30 s) is not modelled: a chasing drone heads straight at - // its target. [ASSUMED] the fear's source is her (the enemy flees from her, not from the drone). - const flyDrone = (battle, unit, d, dt, ok) => { - d.cd = Math.max(0, d.cd - dt); - if (d.phase === 'spread') { - const s = accelerate(d, WHITW2_SPREAD, dt); - d.x += d.hx * s; d.y += d.hy * s; - d.age += dt; - if (d.age + 1e-9 < spreadTime) return; - d.phase = 'seek'; - } - if (d.phase === 'lock' && !ok(d.t)) { - // ③ its target left / is no longer selectable: it reappears at a random point of the square around that spot (the - // draw taken in her facing-RIGHT frame, so a battle turned with her direction plays the same) - const h = WHITW2_REAPPEAR_SIDE / 2; - const [ar, ac] = rotateOffset(battle.rng.range(-h, h), battle.rng.range(-h, h), unit.dir); - d.x = d.t.x + ac; - d.y = d.t.y + ar; - d.t = null; d.phase = 'seek'; - } else if (d.phase === 'chase' && !ok(d.t)) { d.t = null; d.phase = 'seek'; } - if (d.phase === 'seek') { - const t = pickTarget(battle, unit, d, ok); - if (!t) { circle(d, dt); return; } - d.t = t; d.phase = 'chase'; d.v = WHITW2_CHASE.v0; d.orbit = null; - battle.fx('droneLock', { x: t.x, y: t.y, id: t.id, src: unit.id }); // PRTS: the red wolf-eye mark over the target - } - const t = d.t; - if (d.phase === 'chase') { - const s = accelerate(d, WHITW2_CHASE, dt); - if (bodyDist(t, d.x, d.y) > s + 1e-9) { - const dx = t.x - d.x, dy = t.y - d.y, L = Math.hypot(dx, dy); - if (L > 1e-9) { d.hx = dx / L; d.hy = dy / L; } - d.x += d.hx * s; d.y += d.hy * s; - return; - } - d.phase = 'lock'; // reached: "追上时使目标恐惧…并锁定其攻击" - if (fear > 0) battle.applyStatus(t, 'fear', { duration: fear, source: unit }); - } - d.x = t.x; d.y = t.y; - if (d.cd > 1e-9 || !ok(t) || !unit.alive) return; // (the fear's hooks could have knocked her out) - d.cd = unit.s.interval; - const f = unit.profile?.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; - d.ramp = d.rampId === t.id ? Math.min(f.max, d.ramp + f.delta) : f.init; - d.rampId = t.id; - battle.fx('drone', { x: d.x, y: d.y, id: unit.id }); - battle.dealDamage(unit, t, { amount: unit.s.atk * unit.s.atkScaleMul * d.ramp, type: 'arts', tags: ['droneAttack'] }); - }; - - const skills = { - // S1 慵怠者悲鸣: passive 浮游单元+1 (trait: one more hit per attack); toggled on: ATK +atk and the drones lock a random - // non-moving enemy anywhere on the field (re-locking when it moves or falls; install), else her range. The whole-field - // grid only selects those targets — no rangeId, no 攻击范围 in the text — so the card keeps her 3-1 (showOwnRange) - skchr_whitw2_1: { - kind: 'toggle', - mods: { atkPct: num(bb.atk) }, - targeting: { rangeGrid: WHOLE_FIELD, showOwnRange: true }, - onStart({ unit }) { unit.mem.lazyLock = null; }, - }, - // S2 逐猎狂飙: 浮游单元+attack@cnt, skill range, ATK +atk: every drone locks a random enemy of the range until it falls - // (install); each drone hit ramps on its own target (trait init → max) and fears it attack@fear s with attack@prob - skchr_whitw2_2: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - ...(skillGridW ? { targeting: { rangeGrid: skillGridW } } : {}), - attack: { - hits: 1, - dmgMul(battle, unit, target) { - const f = unit.profile?.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; - const m = unit.mem.hunt; - if (!m) return f.init; - const v = m.ramp.has(target.id) ? Math.min(f.max, m.ramp.get(target.id) + f.delta) : f.init; - m.ramp.set(target.id, v); - return v; - }, - onEachHit({ battle, unit, target, kind }) { - if (kind !== 'main' || !target || !target.alive || !(num(bb['attack@fear']) > 0)) return; - if (battle.rng() < num(bb['attack@prob'])) battle.applyStatus(target, 'fear', { duration: num(bb['attack@fear']), source: unit }); - }, - }, - onStart({ unit }) { unit.mem.hunt = { locks: [], ramp: new Map() }; }, - onEnd({ unit }) { unit.mem.hunt = null; }, - }, - }; - return { - skills, - // S1 "被动效果:浮游单元+1": one more drone hit with every normal attack - trait: sid === 'skchr_whitw2_1' ? { hits: 2 } : null, - install(battle, unit) { - if (sid === 'skchr_whitw2_1') { - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !unit.skill?.active) return; - const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY); - let L = unit.mem.lazyLock; - if (!ok(L) || !still(L)) { - const c = battle.enemies.filter((e) => ok(e) && still(e)); - L = unit.mem.lazyLock = c.length ? battle.rng.pick(c) : null; - } - if (L) { ctx.targets = [L]; return; } - // nothing stands still: her own (initial) range - const own = battle.enemiesInKeys(unit.baseRangeKeys || [], unit, ctx.profile); - sortEnemyTargets(battle, unit, own, ctx.profile?.priority ?? null); - ctx.targets = own.slice(0, 1); - }, { owner: unit }); - } - if (sid === 'skchr_whitw2_2') { - battle.on('beforeAttack', (ctx) => { - const m = unit.mem.hunt; - if (ctx.attacker !== unit || !unit.skill?.active || !m) return; - // the skill range, plus the enemies she blocks (always her targets, Battle.blockedTargets — DESIGN §20.3) - const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile); - for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e); - if (!c.length) { ctx.targets = []; return; } - const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0); - m.locks = m.locks.filter((e) => e.alive && c.includes(e)).slice(0, n); - while (m.locks.length < n) { - const free = c.filter((e) => !m.locks.includes(e)); - m.locks.push(battle.rng.pick(free.length ? free : c)); - } - ctx.targets = m.locks.slice(); - }, { owner: unit }); - } - }, - // S3 终幕·浩劫: ATK +atk; 1 + attack@cnt drones (+1 from 头狼 stage 3) fly the PRTS 技能流程 (WHITW2_* above). Every - // drone is out, so she makes no normal attack of her own (`noAttack`); a drone on its target attacks it every attack - // interval of hers (her live ASPD; [ASSUMED] the first hit as it arrives) for ATK × its OWN funnel ramp — the trait's - // init, +delta per hit on the same target, the cap (头狼 stage 1 raises it), back to init on a new target (PRTS 分支特性 - // 信息 驭械术师 "浮游单元攻击不同目标…时,上述的伤害立刻恢复至初始值"). That damage is arts and neither a normal attack - // nor skill damage (PRTS S3 备注 "该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害", which for this skill - // overrides the branch note "通过技能释放的浮游单元造成技能直接伤害": no 'attack' hook, isAttack / isSkill false — the - // on-attack items skip it; it is still 普通伤害, so the 叙拉古 6 proc rolls on it, bonds/core.js siracusaRolls), and - // 缴械 does not stop it (PRTS 驭械术师 "…不受缴械类效果 - // 制约"); [ASSUMED] nor do her stun, freeze or silence (PRTS names only 缴械) — the skill ticks on and so do the drones. - // Around every drone (attack@range_radius): move speed attack@move_speed and, once per second, attack@magic_atk_scale - // × ATK arts (不叠加: one hit per enemy whatever the number of drones); [ASSUMED] that area hit keeps `isSkill` (a skill - // DoT — the 备注 speaks of 直接伤害) and is 持续伤害 (tag dot: PRTS 备注 "持续法术伤害" — no 叙拉古 6 roll, 锡人's - // 凋敝魂灵 raises it). A knocked-out / withdrawn wolf (onEnd cleared the drones mid-tick, e.g. from a - // kill hook) deals nothing more in that tick. - skill: { - kind: 'duration', - mods: { atkPct: num(bb.atk) }, - attack: { noAttack: true }, - onStart({ battle, unit }) { - unit.mem.drones = []; - unit.mem.droneAcc = 0; - releaseDrones(unit, droneCount(unit)); - battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n: unit.mem.drones.length }); - }, - onTick({ battle, unit, dt }) { - const D = unit.mem.drones; - if (!D) return; - const gone = () => !unit.alive || unit.mem.drones !== D; - // 头狼 stage 3 reached while the skill runs: the extra drone is released (PRTS ① "补充浮游单元"; [ASSUMED] only it spreads) - const add = droneCount(unit) - D.length; - if (add > 0) { releaseDrones(unit, add); battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n: add }); } - const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY); - for (const d of D) { - if (gone()) return; - flyDrone(battle, unit, d, dt, ok); - } - if (gone()) return; - const near = new Set(); - for (const d of D) for (const e of battle.foesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e); - if (slow) for (const e of near) battle.addBuff(e, { key: 'whitw2:slow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow) }, source: unit }); - unit.mem.droneAcc += dt; - if (unit.mem.droneAcc + 1e-9 >= 1) { - unit.mem.droneAcc -= 1; - for (const e of near) { - if (gone()) return; - if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone', 'dot'] }); - } - // (a drone on its target already pulses with each of its attacks) - for (const d of D) if (d.phase !== 'lock') battle.fx('drone', { x: d.x, y: d.y, id: unit.id }); - } - }, - onEnd({ unit }) { unit.mem.drones = null; }, - }, - talents: [ - { install(battle, unit) { // 头狼 - const iv = Math.max(1, num(t0.interval, 20)), cap = num(t0.scale, 1), sil = num(t0['attack@silence_duration']); - const baseFunnel = unit.profile?.funnel ? { ...unit.profile.funnel } : null; - const baseHits = Math.max(1, Math.floor(num(unit.profile?.hits, 1))); - battle.on('deploy', ({ unit: u }) => { - if (u !== unit) return; - unit.mem.wolfStage = 0; - if (unit.profile) { unit.profile.hits = baseHits; if (baseFunnel) unit.profile.funnel = { ...baseFunnel }; } - }, { owner: unit }); - aura(battle, unit, 1, () => { - const st = Math.min(3, Math.floor((battle.time - unit.deployedAt + 1e-6) / iv)); - if (st <= (unit.mem.wolfStage || 0)) return; - unit.mem.wolfStage = st; - // stage 1: 伤害上限提高10% (funnel cap × scale) - if (st >= 1 && baseFunnel && unit.profile) unit.profile.funnel = { ...baseFunnel, max: baseFunnel.max * cap }; - // stage 3: 数量+1 — one more drone hits her target with every normal attack (and S3 releases one more) - if (st >= 3 && unit.profile) unit.profile.hits = baseHits + 1; - battle.fx('talent', { x: unit.x, y: unit.y, id: unit.id, name: 'alpha', stage: st }); - }); - // stage 2: 造成伤害时使目标特殊能力失效2秒 (= silence) - if (sil > 0) battle.on('damaged', (ctx) => { - if (ctx.source !== unit || (unit.mem.wolfStage || 0) < 2 || !ctx.target.alive || ctx.target.side !== 'enemy' || ctx.type === 'element') return; - battle.applyStatus(ctx.target, 'silence', { duration: sil, source: unit }); - }, { owner: unit }); - } }, - { install(battle, unit) { // 叙拉古的荣幸: one team effect per player, the strongest copy's numbers - const S = bstate(battle); - S.siracusa ??= new Map(); - const cur = S.siracusa.get(unit.ownerId); - const sp = num(t1.sp), as = num(t1.attack_speed); - if (cur) { cur.sp = Math.max(cur.sp, sp); cur.as = Math.max(cur.as, as); return; } - const cfg = { sp, as }; - S.siracusa.set(unit.ownerId, cfg); - const sira = (u) => u && u.kind === 'op' && u.ownerId === unit.ownerId && hasBond(u, 'siracusaShip'); - // 初始技力+5: every (re)deployment starts with +5 SP (not on top of the SP a unite helper carries over) - battle.on('deploy', ({ unit: u, initial }) => { - if (!(cfg.sp > 0) || !sira(u) || !u.skill) return; - if (initial && u.carry && Number.isFinite(u.carry.sp)) return; - u.skill.gainSp(cfg.sp, 'talent'); - }); - // elite: 首次触发技能后攻击速度+10 - battle.on('skillStart', ({ unit: u }) => { - if (!cfg.as || !sira(u) || u.mem.siracusaHonor === u.deploySeq) return; - u.mem.siracusaHonor = u.deploySeq; - battle.addBuff(u, { key: 'whitw2:honor', mods: { aspd: cfg.as } }); - }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 锏 chess_char_6_19 (剑豪) — S3 归于宁静; “天生的武者”; 活着的传奇; module “过往的注脚” - -function blkkgt(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const slashes = Math.max(1, parseN(def?.skill?.description, /总计(\d+)次/, 10)); - const iv = Math.max(0.05, num(bb.d_hit_interval, 0.3)), pullIv = Math.max(0.1, num(bb.p_hit_interval, 1)); - const maxT = Math.max(1, Math.floor(num(bb.max_target, 1))); - const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; - // the skill range (x-1): her range while S3 runs — also for the finisher, since onEnd runs before the engine - // removes the skill's range - const skillKeys = (unit) => unit.rangeKeys || []; - // S3 hits and pulls air units too — PRTS 锏 S3 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (her attacks and S1 / - // S2 stay ground-only: "地面敌人"); the air units of the mode are 静态刚体, so the pull leaves them in place (Battle.pull) - const victims = (battle, unit) => { - const c = battle.enemiesInKeys(skillKeys(unit), unit, { ...unit.profile, canHitFly: true, groundOnly: false }); - sortEnemyTargets(battle, unit, c, null); - return c.slice(0, maxT); - }; - const slash = (battle, unit, scale) => { - const v = victims(battle, unit); - if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length }); - for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'slash'] }); - }; - // "持续将敌人中等力度地拖拽至自身中心,之后…较大力地拖拽至自身": the official 力度 − 重量 pulls (PRTS 推与拉: the last one - // aims at her own tile centre, the others at the usual 拉力起点 half a tile ahead; 急停 0.6708 around her) - const pull = (battle, unit, force, last = false) => { - for (const e of enemiesIn(battle, unit, skillKeys(unit))) { - if (last) pullToward(battle, unit, e, force); else battle.pullToFront(e, unit, force); - } - }; - const skills = { - // S1 纯粹的武力 (attack SP): the next attack hits up to max_target ground enemies of the 3×3 around her, each twice - // (her trait) at atk_scale_s1 × ATK - skchr_blkkgt_1: { - kind: 'instant', - attack: { atkScale: num(bb.atk_scale_s1, 1) }, - targeting: { maxTargets: Math.max(1, Math.floor(num(bb.max_target, 5))), ...(skillGrid ? { rangeGrid: skillGrid } : {}) }, - }, - // S2 无声的嘲笑 (attack SP, 2 charges): up to max_target ground enemies of the front skill range take 2 slashes - // (3 when blocked) of dot_scale × ATK; the talent procs at 100 % meanwhile (skill active + bb.prob) - skchr_blkkgt_2: { - kind: instantKind(def), - onStart({ battle, unit }) { - const grid = skillGrid || [[0, 0], [0, 1]]; - const c = battle.unitsInGrid(unit, grid, { side: 'enemy' }).filter((e) => !e.isFlying && canTargetEnemy(unit, e, unit.profile)); - sortEnemyTargets(battle, unit, c, null); - const v = c.slice(0, Math.max(1, Math.floor(num(bb.max_target, 5)))); - const nFree = Math.floor(num(bb['blkkgt_s_2[not_blocked].trig_cnt'], 2)), nBlocked = Math.floor(num(bb['blkkgt_s_2[blocked].trig_cnt'], 3)); - if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length }); - for (const e of v) { - const n = e.blockedBy ? nBlocked : nFree; - for (let i = 0; i < n && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.dot_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'slash'] }); - } - }, - }, - }; - // module 新合同: trait "攻击时无视敌人70点的防御力"; talent 活着的传奇 "攻击力+8%…地面敌人首次进入自身攻击范围时,使其战栗6秒" - const penFlat = num(tb.def_penetrate_fixed), legendAtk = num(t1.atk), legendTremble = num(t1.not_combat); - return { - skills, - install(battle, unit) { - if (penFlat > 0 || legendAtk) battle.addBuff(unit, { key: 'blkkgt:contract', mods: { defIgnoreFlat: penFlat, atkPct: legendAtk }, persist: true, allowDead: true }); - // S3 slashes: 晕眩免疫, 冻结免疫 (PRTS 备注) - battle.on('beforeStatus', (c) => { - if (c.target === unit && unit.mem.dgb && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true; - }, { owner: unit }); - if (legendTremble > 0) { - const seen = new WeakSet(); - battle.on('tick', () => { - if (!live(unit)) return; - for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, ANY)) { - if (e.isFlying || seen.has(e)) continue; - seen.add(e); - battle.applyStatus(e, 'tremble', { duration: legendTremble, source: unit }); - } - }, { owner: unit }); - } - }, - skill: { - kind: 'duration', - duration: slashes * iv, - ...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}), - attack: { noAttack: true }, - // PRTS 备注 "※多段斩击期间,自身获得无敌,晕眩免疫,冻结免疫": invulnerable while the slashes run (removed in onEnd, - // before the finisher); stun / freeze refused by her `beforeStatus` hook (install) while `mem.dgb` is set - onStart({ battle, unit }) { - unit.mem.dgb = { n: 1, acc: 0, pacc: 0 }; - battle.addBuff(unit, { key: 'blkkgt:slashes', duration: slashes * iv + 1, flags: { invulnerable: true }, visible: true, source: unit }); - slash(battle, unit, num(bb.d_atk_scale, 1)); - }, - onTick({ battle, unit, dt }) { - const m = unit.mem.dgb; - if (!m) return; - m.acc += dt; - m.pacc += dt; - while (m.acc + 1e-9 >= iv && m.n < slashes) { m.acc -= iv; m.n++; slash(battle, unit, num(bb.d_atk_scale, 1)); } - if (m.pacc + 1e-9 >= pullIv) { m.pacc -= pullIv; pull(battle, unit, num(bb.p_force)); } - }, - onEnd({ battle, unit, reason }) { - unit.mem.dgb = null; - battle.removeBuff(unit, 'blkkgt:slashes'); - if (reason === 'death' || !unit.alive) return; - battle.fx('finale', { x: unit.x, y: unit.y, id: unit.id }); - unit.mem.dgbFinale = true; // the finisher is part of the skill (talent at 100 %, module +10 %) - try { slash(battle, unit, num(bb.e_atk_scale_end, 1)); } finally { unit.mem.dgbFinale = false; } - pull(battle, unit, num(bb.e_force), true); - }, - }, - talents: [ - { install(battle, unit) { // 活着的传奇 → “天生的武者” → module (skill damage +10 %) - const prob = num(t0.prob), sc = num(t0.atk_scale, 1), tr = num(t0.not_combat), pen = num(t1.def_penetrate), skillMul = num(tb.damage_scale, 1); - battle.on('hit', (ctx) => { - const t = ctx.target, d = ctx.dmg; - if (ctx.source !== unit || !t || t.side !== 'enemy' || d.type === 'element') return; - if (pen > 0 && t.findBuff('tremble')) d.defIgnorePct += pen; - const p = unit.skill?.active || unit.mem.dgbFinale ? num(bb.prob, prob) : prob; - if (p > 0 && (p >= 1 || battle.rng() < p)) { - d.amount *= sc; - if (tr > 0 && t.alive) battle.applyStatus(t, 'tremble', { duration: tr, source: unit }); - } - if (skillMul > 1 && d.isSkill) d.mul *= skillMul; - }, { owner: unit }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ -// 纯烬艾雅法拉 chess_char_6_20 (行医) — S3 火山回响; 氤氲; 火山灰疗愈 - -function agoat2(bb, chess, def) { - const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; - const hs = num(bb['attack@heal_scale'], 1); - const shots = Math.max(1, parseN(def?.skill?.description, /(\d+)连发/, 1)); - const elemLoad = (a) => (a.elem ? a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion : 0); - const skills = { - // S1 无声润物 (toggle, heal): ATK +atk, one extra heal target, every ally of her range recovers ep_heal_ratio × ATK - // 元素损伤 per second - skchr_agoat2_1: { - kind: 'toggle', - heal: true, - mods: { atkPct: num(bb.atk) }, - targeting: { maxTargets: 2 }, - onStart({ unit }) { unit.mem.agoatAcc = 0; }, - onTick({ battle, unit, dt }) { - unit.mem.agoatAcc = (unit.mem.agoatAcc || 0) + dt; - if (unit.mem.agoatAcc + 1e-9 < 1) return; - unit.mem.agoatAcc -= 1; - const v = unit.s.atk * bv(bb, 'ep_heal_ratio'); - if (v > 0) for (const a of battle.alliesInGrid(unit)) battle.reduceElement(a, v); - }, - }, - // S2 云霭荫佑 (heal): one heal (her normal amount + element recovery) on every ally of her range, then a barrier - // over that range for `duration` s absorbing agoat2_s_2[shield].atk_scale × ATK of 元素损伤 in all (install) - skchr_agoat2_2: { - kind: 'instant', - heal: true, - onStart({ battle, unit }) { - const er = num(unit.profile?.heal?.elementHealRatio, num(tb.ep_heal_ratio, 0.5)); - const allies = battle.alliesInGrid(unit); - for (const a of allies) { - if (er > 0) battle.reduceElement(a, unit.s.atk * er); - if (a.hp < a.s.maxHp) battle.heal(unit, a, unit.s.atk, { skillHeal: true }); - } - const S = bstate(battle); - S.agoatVeils ??= []; - S.agoatVeils.push({ keys: new Set(unit.rangeKeys || []), pool: unit.s.atk * bv(bb, 'atk_scale', 5), until: battle.time + num(bb.duration, 12), src: unit }); - battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, duration: num(bb.duration, 12) }); - if (!S.agoatVeilHook) { - S.agoatVeilHook = true; - battle.on('elementHit', (ctx) => { // the barrier absorbs 元素损伤 of the allies inside it - const t = ctx.target, d = ctx.dmg; - if (!t || t.side !== 'ally' || !d || d.type !== 'element' || d.cancel) return; - S.agoatVeils = S.agoatVeils.filter((v) => v.pool > 1e-9 && battle.time < v.until); - const k = t.tileR * COLS + t.tileC; - for (const v of S.agoatVeils) { - if (!v.keys.has(k)) continue; - const eff = d.amount * d.mul; - if (!(eff > 0)) return; - const take = Math.min(v.pool, eff); - v.pool -= take; - if (take >= eff - 1e-9) { d.cancel = true; return; } - d.mul *= (eff - take) / eff; - } - }, { priority: -50 }); - } - }, - }, - }; - return { - skills, - install(battle, unit) { - // module 想要留下的生命: "攻击范围内存在受到元素损伤的友方单位时,攻击速度+8" - const as = num(tb.attack_speed); - if (as) aura(battle, unit, 0.25, () => { - if (battle.alliesInGrid(unit).some((a) => elemLoad(a) > 0)) battle.addBuff(unit, { key: 'agoat2:linger', mods: { aspd: as }, duration: 0.4, refresh: 'replace' }); - }); - }, - skill: { - kind: 'duration', - heal: true, - targeting: { rangeGrid: WHOLE_FIELD, maxTargets: shots }, - attack: { healScale: hs }, - onStart({ skill, unit }) { - const h = unit.profile?.heal || { mode: 'single' }; - skill.spec.attack.heal = { ...h, elementHealRatio: num(h.elementHealRatio) * hs }; - }, - }, - talents: [ - { install(battle, unit) { // S3: 5 shots, different targets first - battle.on('beforeAttack', (ctx) => { - if (ctx.attacker !== unit || !unit.skill?.active || !ctx.targets.length || ctx.targets.length >= shots) return; - const base = ctx.targets.slice(); - const out = []; - for (let i = 0; i < shots; i++) out.push(base[i % base.length]); - ctx.targets = out; - }, { owner: unit }); - } }, - { install(battle, unit) { // 氤氲 - // "普通治疗使目标每秒额外受到一次治疗量和元素损伤回复量为10%的增益治疗,持续6秒(最多叠加3层)" — PRTS 备注: "本天赋受 - // 特性治疗倍率影响,使用缓存攻击力;叠加时,重置持续时间并更新缓存攻击力": a 10 % heal shaped like her normal one — - // HP heal_scale × ATK and 元素损伤 recovery heal_scale × the trait's ep_heal_ratio × ATK (0.5 / 0.6: 5 % / 6 %, - // it used to be the full 10 %) — per stack, with the ATK of the last application; a new stack refreshes them all. - const sc = num(t0.heal_scale), dur = num(t0.duration, 6), max = Math.max(1, Math.floor(num(t0.max_stack_cnt, 3))); - if (!(sc > 0)) return; - const epRatio = () => num(unit.profile?.heal?.elementHealRatio, num(tb.ep_heal_ratio, 0.5)); - battle.on('heal', (ctx) => { - const t = ctx.target; - if (ctx.source !== unit || ctx.opts?.hot || ctx.opts?.aura || ctx.opts?.regen || ctx.opts?.skillHeal || !t) return; - // keyed per 纯烬: two of them (co-op) each keep their own stacks, ATK and heal source - const b = battle.addBuff(t, { - key: `agoat2:mist:${unit.id}`, duration: dur, refresh: 'stack', stacks: 1, maxStacks: max, interval: 1, source: unit, data: {}, - onTick: ({ unit: a, buff }) => { - const amt = num(buff.data.atk, unit.s.atk) * sc * Math.max(1, buff.stacks); - battle.reduceElement(a, amt * epRatio()); - if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { hot: true }); - }, - }); - if (b) b.data.atk = unit.s.atk; - }, { owner: unit }); - } }, - { install(battle, unit) { // 火山灰疗愈 (×talent_scale during S3) - // "受到的元素损伤降低" is a 元素损伤 multiplier applied on the element hit (PRTS 元素 "…后续可应用元素损伤倍率 - // 提升/降低等效果"), not `elemTakenMul` (元素伤害 / 元素脆弱): the aura buff carries the cut, `elementHit` applies it - const hp = num(t1.max_hp), er = num(t1.ep_damage_resistance), mulS = num(bb.talent_scale, 1); - aura(battle, unit, 0.5, () => { - const f = unit.skill?.active ? mulS : 1; - for (const a of battle.alliesInGrid(unit)) { - battle.addBuff(a, { key: 'agoat2:ash', mods: { hpPct: hp * f }, data: { epCut: Math.min(1, er * f) }, source: unit, duration: 0.75, refresh: 'replace' }); - } - }); - battle.on('elementHit', (ctx) => { - const d = ctx.dmg, b = ctx.target?.findBuff?.('agoat2:ash'); - if (!b || b.source !== unit || !d || d.type !== 'element') return; - const cut = num(b.data?.epCut); - if (cut > 0) d.mul *= Math.max(0, 1 - cut); - }, { owner: unit, priority: 20 }); - } }, - ], - }; -} - -// ------------------------------------------------------------------------------------------------------------------ - -export default { - chess_char_1_15_a: pithst, - chess_char_6_01_a: lemuen, - chess_char_6_02_a: sbell2, - chess_char_6_03_a: yu, - chess_char_6_04_a: skadi2, - chess_char_6_05_a: pasngr, - chess_char_6_06_a: pepe, - chess_char_6_07_a: siege2, - chess_char_6_08_a: reed2, - chess_char_6_09_a: cello, - chess_char_6_10_a: nymph, - chess_char_6_11_a: mlyss, - chess_char_6_12_a: rosmon, - chess_char_6_13_a: angel2, - chess_char_6_14_a: lumen, - chess_char_6_15_a: qiubai, - chess_char_6_16_a: halo2, - chess_char_6_17_a: nearl2, - chess_char_6_18_a: whitw2, - chess_char_6_19_a: blkkgt, - chess_char_6_20_a: agoat2, -}; +export { PITHST_ELEMENTS } from './ops/chess_char_1_15-pithst.js'; +export default TIER_KITS[5]; diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index 8b739d06..3e087605 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -46,9 +46,12 @@ import { BAND_TURN_SECONDS } from '../server/match/Match.js'; import { SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS } from '../public/js/assets.js'; import { RETRY_DELAYS_MS } from '../public/js/data.js'; import { DATA, makeMatch } from './match/harness.js'; +import { KIT_FILES } from '../server/sim/content/kits/index.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const doc = (p) => readFileSync(join(ROOT, p), 'utf8'); +// the sources of a former kits/tierN.js: its helpers (kits/shared/tierN.js) and its kit files (kits/ops/, kits/index.js) +const tierSources = (t) => [`server/sim/content/kits/shared/tier${t}.js`, ...KIT_FILES[t - 1].map((f) => `server/sim/content/kits/ops/${f}`)]; const DESIGN = doc('docs/DESIGN.md'); const META = doc('docs/META.md'); const DATA_MD = doc('docs/DATA.md'); @@ -253,7 +256,7 @@ test('回环射手 boomerang and 蕾缪安 S3 shells (user playtest #3 items 4 assert.match(SIM, /`none\|arrow\|bolt\|bomb\|lob\|orb\|drone\|enemy\|boomerang\|droneBomb\|chain\|chainHeal`/); assert.match(DESIGN, /BOOMERANG_RETURN_SPEED/); // 蕾缪安: one shell every 0.3 s after the skill (PRTS), fx 'bombardShell' then 'bombard' — the kit's constants - const kit = readFileSync(join(ROOT, 'server/sim/content/kits/tier6.js'), 'utf8'); + const kit = readFileSync(join(ROOT, 'server/sim/content/kits/ops/chess_char_6_01-lemuen.js'), 'utf8'); assert.match(kit, /const LEMUEN_SHELL_INTERVAL = 0\.3;/); assert.match(kit, /battle\.fx\('bombardShell'/); assert.match(DESIGN, /'bombardShell' \{x, y, id: shooter, r, t: flight game s, i\}/); @@ -365,7 +368,7 @@ test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses, assert.match(PLAYING, /自缚、无法被阻挡/); // #3 elements: one hasHp, the pipeline guard, 脆弱 vs 元素伤害, element healing per type assert.equal(typeof DAMAGE.hasHp, 'function'); - for (const f of ['server/sim/content/kits/tier5.js', 'server/sim/content/kits/tier6.js', 'server/sim/content/items/battle.js']) { + for (const f of [...tierSources(5), ...tierSources(6), 'server/sim/content/items/battle.js']) { assert.ok(!/const hasHp = /.test(readFileSync(join(ROOT, f), 'utf8')), `${f}: no local hasHp copy`); } assert.match(s55, /元素伤害 takes 元素脆弱 \(`elementalTakenMul`\) alone, not `dmgTakenMul`/); @@ -751,7 +754,7 @@ test('batch 6 QA residuals (DESIGN §21.21–§21.25): the lock per deployment f assert.match(items, /function deploymentOf\(u\)/); assert.ok(!/S\.on\('deploy', \(c\) => \{\s*if \(c\.unit !== u \|\| c\.initial\) return;\s*hs\.undyingUsed/.test(items), 'no per-grant re-arm hook'); assert.match(doc('server/sim/content/bands/battle.js'), /revivedInPlace\(u\)/); - assert.match(doc('server/sim/content/kits/tier4.js'), /if \(holdsUndying\(battle, unit\)\) return;/); + assert.match(doc('server/sim/content/kits/ops/chess_char_4_01-rmixer.js'), /if \(holdsUndying\(battle, unit\)\) return;/); assert.match(sub(21), /\*\*QA after the integration, fixed\*\*: \(1\) the lock lived in the hooks of the carrier's hammer grants/); assert.match(sub(21), /both in-place revives now call `revivedInPlace`/); assert.match(sub(20), /the lock belongs to the deployment, so a borrowed hammer \(萨尔贡 × 娜仁图亚\) follows the same rule \| `content\/items\/battle\.js deploymentOf` returning one key/); @@ -836,7 +839,7 @@ test('the deliberate trigger deviation (DESIGN §21.29): six 重装 skills DEFAU assert.match(r03, /下半 \(act2autochess, 2026-03-14\) added `TANK \| \| \| 0 \| TAKE_DAMAGE` for every skill index/); assert.match(r03, /\*\*Deliberate deviation\*\* \(the owner, 2026-10-03/); // PR #12's kit lines stay; their comments give this reason, not the community summary - const t1 = doc('server/sim/content/kits/tier1.js'); + const t1 = tierSources(1).map(doc).join('\n'); assert.equal((t1.match(/trigger: 'DEFAULT',/g) || []).length, 2, "PR #12's two kit lines"); assert.ok(!/offensive skills activate when an enemy is in their skill range/.test(t1)); assert.ok(!/documented for skillIndex 0/.test(t1)); From 8924007e5588fb6565b5a64803a07443308af867 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 16:14:39 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feedback5=20R4:=20content/index.js=20takes?= =?UTF-8?q?=20the=20kit=20registry=20from=20kits/index.js=20(one=20guarded?= =?UTF-8?q?=20import=20per=20kit=20file)=20instead=20of=20loading=20the=20?= =?UTF-8?q?six=20tier=20shims;=20KITS=20is=20the=20same=20object=20kits/in?= =?UTF-8?q?dex.js=20exports=20=E2=80=94=20same=20keys,=20order=20and=20bui?= =?UTF-8?q?lders,=20golden=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sim/content/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/sim/content/index.js b/server/sim/content/index.js index 39b0a9f2..69e59666 100644 --- a/server/sim/content/index.js +++ b/server/sim/content/index.js @@ -1,7 +1,7 @@ // server/sim/content/index.js — installs all content into a Battle (DESIGN §7). // // installContent(battle, { mode }) — applies the per-battle loadout data view (simdata withUnitLoadouts; DESIGN §16), -// then mode 'full' (default): hand-authored kits (kits/tier1..6) with generic +// then mode 'full' (default): hand-authored kits (kits/index.js: one file per kit in kits/ops/) with generic // fallback + every domain module's install(battle); 'generic': generic kits only, no domain modules; // 'none': no kits (units never cast skills) and no modules. Every module install runs inside try/catch: // a faulty content module is logged and skipped, never crashing the battle. @@ -33,13 +33,13 @@ async function safeImport(path) { } } -const TIERS = await Promise.all([1, 2, 3, 4, 5, 6].map((t) => safeImport(`./kits/tier${t}.js`))); +const KIT_REGISTRY = await safeImport('./kits/index.js'); const DOMAIN_NAMES = ['tokens', 'devices', 'enemies', 'bosses', 'bonds', 'garrisons', 'items', 'bands', 'choices']; const DOMAINS = await Promise.all(DOMAIN_NAMES.map((n) => safeImport(`./${n}.js`))); const tokens = DOMAINS[0]; -/** Merged kit registry: baseChessId → (bb, chess, def) => Kit */ -export const KITS = Object.freeze(Object.assign({}, ...TIERS.map((m) => (m && m.default && typeof m.default === 'object' ? m.default : {})))); +/** Merged kit registry: baseChessId → (bb, chess, def) => Kit (kits/index.js; each kit file is loaded guarded too) */ +export const KITS = KIT_REGISTRY.KITS ?? Object.freeze({}); /** Domain modules in install order: tokens, devices, enemies, bosses, bonds, garrisons, items, bands, choices. */ export const MODULES = Object.freeze(DOMAIN_NAMES.map((n, i) => [n, DOMAINS[i]])); From 7adf46b03324a6984f10c91816a3cff9ec4fad52 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 16:14:48 +0800 Subject: [PATCH 3/5] feedback5 R4: .git-blame-ignore-revs lists the kit split (a pure move), so blame skips it --- .git-blame-ignore-revs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..17ab07c9 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Commits that only move code (0.2.0 refactor). git blame skips them with +# git config blame.ignoreRevsFile .git-blame-ignore-revs (or: git blame --ignore-revs-file .git-blame-ignore-revs) +# and follows moved lines into other files with -C -C. + +# feedback5 R4: kits/tier1..6.js → kits/ops/ (one file per kit), kits/shared/, kits/index.js +1169102d46742474bf0f0189d467c791984d9e05 From 37b1c0a644098193c70de471f05df6ec4bc4db54 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 16:18:58 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feedback5=20R4:=20paths=20after=20the=20kit?= =?UTF-8?q?=20split=20=E2=80=94=20DESIGN=20=C2=A77=20and=20SIM.md=20(layou?= =?UTF-8?q?t,=20=C2=A77.2=20kit=20contract,=20kit=20citations),=20research?= =?UTF-8?q?=2009,=20code=20comments=20(content/generic,=20tokens,=20items/?= =?UTF-8?q?battle,=20shared/loadoutRecord,=20render=20fx=20/=20demo),=20te?= =?UTF-8?q?st=20comments,=20and=20the=20kits'=20own=20comments=20that=20na?= =?UTF-8?q?med=20tierN.js;=20DESIGN=20=C2=A717=E2=80=93=C2=A723=20history?= =?UTF-8?q?=20citations=20are=20left=20as=20they=20are?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 6 ++++-- docs/SIM.md | 15 ++++++++------- docs/research/09-ux-official.md | 2 +- public/dev/render-demo.js | 2 +- public/js/render/fx.js | 2 +- server/sim/content/generic.js | 2 +- server/sim/content/items/battle.js | 2 +- .../content/kits/ops/chess_char_1_16-tinman.js | 2 -- .../content/kits/ops/chess_char_2_19-tinman.js | 2 +- .../content/kits/ops/chess_char_4_25-cetsyr.js | 2 +- .../content/kits/ops/chess_char_5_20-aglina.js | 2 +- .../content/kits/ops/chess_char_6_18-whitw2.js | 2 +- server/sim/content/kits/shared/tier1.js | 5 +++-- server/sim/content/kits/shared/tier2.js | 6 +++--- server/sim/content/kits/shared/tier5.js | 2 +- server/sim/content/kits/shared/tier6.js | 2 +- server/sim/content/tokens.js | 7 ++++--- shared/loadoutRecord.js | 6 +++--- test/content/kits_alt_t1.test.js | 2 +- test/content/kits_alt_t2.test.js | 2 +- test/content/kits_alt_t3.test.js | 2 +- test/content/kits_alt_t4.test.js | 2 +- test/content/kits_alt_t5.test.js | 2 +- test/content/kits_alt_t6.test.js | 2 +- test/content/kits_t1t2.test.js | 2 +- test/content/kits_t3.test.js | 2 +- test/content/kits_t4.test.js | 2 +- test/content/kits_t5.test.js | 4 ++-- test/content/kits_t6.test.js | 2 +- test/content/playtest4_summons.test.js | 2 +- test/content/playtest6_summons.test.js | 4 ++-- test/helpers/battleHarness.js | 2 +- test/render/fxwall.test.js | 2 +- test/sim/fidelity.test.js | 2 +- 34 files changed, 54 insertions(+), 51 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 54821f00..a40fa2a6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -75,7 +75,9 @@ server/ snapshot.js compact serialization for clients content/ index.js installs all content into a Battle (kits, bonds, garrisons, items, bands, enemies, devices) - kits/tier1.js … tier6.js operator kits (skill + talents) keyed by base chessId + kits/index.js operator kit registry keyed by base chessId (guide: kits/README.md) + kits/ops/*.js one operator kit (skills + talents) per file, -.js + kits/shared/*.js helpers the kits share (shared/tier1.js: the general ones) tokens.js summon/token definitions bonds.js battle side of the 23 bonds garrisons.js battle side (IN_BATTLE) garrison effect keys @@ -392,7 +394,7 @@ Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for ## 7. Content modules (server/sim/content + match/effectsMeta handlers) -Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live in `content/kits/tier1..6.js` (`export default { [baseChessId]: (bb, chess) => Kit }`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core). +Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live one per file in `content/kits/ops/` (`export default { [baseChessId]: (bb, chess, def) => Kit }`), listed in `content/kits/index.js` (guide: `content/kits/README.md`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core). - kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens. - bonds: 23 (battle + meta), exact per-layer formulas from research 02. diff --git a/docs/SIM.md b/docs/SIM.md index 65c4c99a..53d78508 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -26,7 +26,8 @@ server/sim/ simdata.js data access + normalisation (data/*.json, research fallback) content/index.js installContent / setupUnitKit / registerAllMeta content/generic.js generic kit from skill blackboards - content/kits/tier1..6.js, content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js (content phase) + content/kits/index.js + kits/ops/*.js (one operator kit per file) + kits/shared/*.js, + content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js (content phase) ``` --- @@ -123,7 +124,7 @@ device or 海嗣 that was not placed never appears (the hidden 待部署区 depl 流形 still come as their 援军 on a tactical point without a piece (content/tokens.js `tacticalPoint`). Their piece stands inside the tactician's attack range — the prep enforces "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`, player report #9 after 0.1.0) — and the kits re-use the piece's tile for a re-summoned pack only while it is a free, standable -tile of her initial range (tier3 `tacticalPoint`, tokens.js `ensureReinforcement`). The start deploy +tile of her initial range (kits/shared/tier3.js `tacticalPoint`, tokens.js `ensureReinforcement`). The start deploy is the user's call after playtest #6 (DESIGN §20); `shared/constants.js SKILL_SUMMON_START_DEPLOY = false` would bring back the playtest #4 reading (only with the skill) in the sim and the summon card's hint (docs/PLAYING.md §4 and this passage must follow; test/ui/playtest6_summons.test.js checks). @@ -768,7 +769,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged | `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` | | `mapTile(ps, row, col, abs?)` / `mapDir(ps, dir, abs?)` | board → field tile / direction of a player (the FA right-side mirror) | | `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | `relocate` only changes the tile (state kept, no event) | -| `moveRedeploy(unit, r, c, { clearSp })` | a 【移动】 (PRTS 术语释义: "不退场,以当前血量在目标位置部署", a special retreat + redeploy): `relocate`'s checks (false = refused, nothing changed), then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy {initial:false, move:true}` (deploy effects fire again); no `die` / `death`, timer or cost (不屈 / 阿戈尔's revive never see it); HP, buffs and a running skill are kept (owner's deviation, DESIGN §22.3); `clearSp` empties the SP before the deploy handlers run — 乌尔比安 S3's move (kits/tier5.js) and 【返回】 (`clearSp`; also the 从不混淆的方向 fallback, content/tokens.js) | +| `moveRedeploy(unit, r, c, { clearSp })` | a 【移动】 (PRTS 术语释义: "不退场,以当前血量在目标位置部署", a special retreat + redeploy): `relocate`'s checks (false = refused, nothing changed), then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy {initial:false, move:true}` (deploy effects fire again); no `die` / `death`, timer or cost (不屈 / 阿戈尔's revive never see it); HP, buffs and a running skill are kept (owner's deviation, DESIGN §22.3); `clearSp` empties the SP before the deploy handlers run — 乌尔比安 S3's move (kits/ops/chess_char_5_05-ulpia.js) and 【返回】 (`clearSp`; also the 从不混淆的方向 fallback, content/tokens.js) | | `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies | | `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 | | `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) | @@ -842,7 +843,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged ### 7.2 Kits ```js -// server/sim/content/kits/tierN.js +// server/sim/content/kits/ops/-.js — one kit per file, listed in kits/index.js (kits/README.md) export default { [baseChessId]: (bb, chess, def) => Kit, // bb = flattened blackboard of the SELECTED skill (normal Lv4 / elite Lv7) }; // chess = the record as the unit's loadout makes it (simdata loadoutRecord), @@ -884,11 +885,11 @@ Element conventions of the kits (user playtest #5 #3; official term dictionary: constraint (priority 0 — enemies carry no 损伤屏障). - **Element attached to a damage** (a `damaged` hook: 迭代元素, 灼燃维式重锤, 炎佑, 妮芙 S2, 余 S3 火墙): the hook runs before `battle.kill`, so a killing blow still sees the target `alive` at 0 HP — these riders check HP left (damage.js `hasHp`, - boss: pool HP; tier6 `elementDmg`) and applyElement itself refuses such a target, so nothing bursts on the corpse. + boss: pool HP; kits/shared/tier6.js `elementDmg`) and applyElement itself refuses such a target, so nothing bursts on the corpse. 塑心 S2 (安魂的弥撒) is the exception: PRTS "…凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" — a late `hit` handler (priority −1000, after any cancel), so its 凋亡 lands (and may burst) before the damage; a hit dodged or absorbed after that still carried it [ASSUMED]. -- **盟约·辅助干员 迭代元素** (tier6.js `pithst`): every damage she deals (HP damage > 0; "造成伤害时") attaches `ep_damage_ratio +- **盟约·辅助干员 迭代元素** (kits/ops/chess_char_1_15-pithst.js `pithst`): every damage she deals (HP damage > 0; "造成伤害时") attaches `ep_damage_ratio × ATK` of 神经, then 灼燃, then 凋亡 (`PITHST_ELEMENTS`); the element applied first bursts, so alone she bursts 神经. ### 7.3 SkillSpec schema @@ -1082,7 +1083,7 @@ const bombard = (battle, unit, locks) => { }; ``` (`blast` emits fx `'bombard'` at (x, y) and deals `proj_atk_scale_1` / `_2` × the cached ATK to every enemy within -`dist_2`, once each; the full kit is `content/kits/tier6.js lemuen`.) +`dist_2`, once each; the full kit is `content/kits/ops/chess_char_6_01-lemuen.js`.) --- diff --git a/docs/research/09-ux-official.md b/docs/research/09-ux-official.md index 8058f522..5ed7a559 100644 --- a/docs/research/09-ux-official.md +++ b/docs/research/09-ux-official.md @@ -332,7 +332,7 @@ The public mirror (ArknightsAssets2 `cn`, `ui/emoticon/theme/[uc]/icon/ - `professions.js` front checks (the `(x − unit.x)·facing` tests) - `skills.js` trigger grids - `tokens.js` placement grids and Mech/device helpers - - kits tier3 (`displace` direction, 薄绿 pull tile) and tier4 device placement + - the tier-3 kits (`displace` direction, 薄绿 pull tile) and tier-4 device placement (server/sim/content/kits/ops/) - `content/devices.js` blower relation: `equal` if the operator dir == blower dir, `opposite` if reversed, otherwise none. Today only fx is compared, so the DOWN blowers never buff anyone. - Items 叙拉古正装 (perpendicular pair), 歌利亚头盔 (front tile), 画卷 `1-1`. 4. **`server/match/finalAssault.js`.** The right-side mirror becomes col `c→20−c` with dir RIGHT↔LEFT; UP and DOWN are kept (not a flat `facing=−1`). FA prep on the right half shows the mirrored layout, and moves there go through the wheel in mirrored coordinates. diff --git a/public/dev/render-demo.js b/public/dev/render-demo.js index 6ea37ec6..693d950a 100644 --- a/public/dev/render-demo.js +++ b/public/dev/render-demo.js @@ -463,7 +463,7 @@ function fxScene(view, stageId) { }; // `pt` kinds name the shooter in `id` and happen at an enemy's spot; a 'flame' happens at its target const posFor = (k, ex) => unitAt(FX_KINDS[k].pt ? [21, 23, 20][i % 3] : FX_KINDS[k].a === 'flame' ? ex.target : ex.id); - // 蕾缪安 S3 as the sim plays it (sim/content/kits/tier6.js): a lock every 0.5 s, then after the skill one shell every + // 蕾缪安 S3 as the sim plays it (sim/content/kits/ops/chess_char_6_01-lemuen.js): a lock every 0.5 s, then after the skill one shell every // 0.3 s on the locks in order, each landing 0.3 s later with its bombard — queued [game time, event] const queue = []; const lemuenS3 = () => { diff --git a/public/js/render/fx.js b/public/js/render/fx.js index 0e13888a..14034cb4 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -292,7 +292,7 @@ export function tilesAround(x, y, r, tiles) { /** * Tiles of a straight wall through tile (round(y), round(x)): `axis` 'col' = that column, 'row' = that row (余 S3 - * fire wall: perpendicular to his facing, sim/content/kits/tier6.js). Clipped to the field `rect` (inclusive + * fire wall: perpendicular to his facing, sim/content/kits/ops/chess_char_6_03-yu.js). Clipped to the field `rect` (inclusive * { r0, r1, c0, c1 }); without one ±4 tiles. */ export function wallTiles(x, y, axis, rect) { diff --git a/server/sim/content/generic.js b/server/sim/content/generic.js index 94f610a5..50c750a2 100644 --- a/server/sim/content/generic.js +++ b/server/sim/content/generic.js @@ -35,7 +35,7 @@ // 推与拉 方向力) / 朝部署方向 / 向前 / 身前方向 or for 推击手 (directional), otherwise away from the unit (radial); a skill of // constants.js PUSH_EFFECT_SKILLS (见行者 S1) pushes by PRTS 推与拉's 特效 column. These keywords are only a fallback: // the hand-written kits follow the client's buff templates (knockback[dir] = directional, knockback[relative] = radial), -// and the text can mislead — 琳琅诗怀雅 S3's "向前推开" is knockback[relative] (kits/tier3.js). No pool skill pushes +// and the text can mislead — 琳琅诗怀雅 S3's "向前推开" is knockback[relative] (kits/ops/chess_char_3_04-swire2.js). No pool skill pushes // through this path with 向前 / 往攻击方向; the only generic push is 见行者 S1, a 推击手. import { normalizeSkill } from '../simdata.js'; diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index 34e16f63..9ec6277d 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -253,7 +253,7 @@ export function revivedInPlace(u) { if (u && u.mem) u.mem.revives = (u.mem.reviv * Does `u` hold 坚固维式重锤's 不死 right now — a window started in this deployment that has not run out? The window lives * on the unit (`mem.undyingUntil`, `mem.undyingAt`) and a battle-level hook holds it (hammerAcquire), so it outlives the * grant that started it — a lend running out mid-window leaves the 不死 for its 8 s [ASSUMED: the 异常效果 outlasts its - * source] — and ends with the deployment. 信仰搅拌机 S2 steps aside while it holds (kits/tier4.js). + * source] — and ends with the deployment. 信仰搅拌机 S2 steps aside while it holds (kits/ops/chess_char_4_01-rmixer.js). */ export function holdsUndying(battle, u) { return !!u && u.mem.undyingAt != null && battle.time < u.mem.undyingUntil && u.mem.undyingAt === deploymentOf(u); diff --git a/server/sim/content/kits/ops/chess_char_1_16-tinman.js b/server/sim/content/kits/ops/chess_char_1_16-tinman.js index a6bde74e..9056f824 100644 --- a/server/sim/content/kits/ops/chess_char_1_16-tinman.js +++ b/server/sim/content/kits/ops/chess_char_1_16-tinman.js @@ -59,8 +59,6 @@ export function tinmanKit(bb, chess, def) { } export default { - // 1_15 盟约·辅助干员 (hidden, band 优等生): its kit is tier6.js `pithst` (迭代元素 — one implementation only). - // --------------------------------------------------------------------------------------------------------------- // 1_16 锡人 (hidden tier-1 entry of chess_char_2_19): see tinmanKit. chess_char_1_16_a: tinmanKit, diff --git a/server/sim/content/kits/ops/chess_char_2_19-tinman.js b/server/sim/content/kits/ops/chess_char_2_19-tinman.js index 4cb06c08..e2cf67f5 100644 --- a/server/sim/content/kits/ops/chess_char_2_19-tinman.js +++ b/server/sim/content/kits/ops/chess_char_2_19-tinman.js @@ -6,7 +6,7 @@ import { tinmanKit } from './chess_char_1_16-tinman.js'; export default { // --------------------------------------------------------------------------------------------------------------- - // 2_19 锡人 “大拉里”: see tinmanKit (tier1.js). + // 2_19 锡人 “大拉里”: see tinmanKit (chess_char_1_16-tinman.js). // S1 “老科利” (alt, attack SP, needs a target): throws an alchemy unit at the target (ground first); for // projectile_delay_time s ground enemies within projectile_range tiles are 虚弱 (weaken −atk) and take atk_scale × // ATK arts per second (ATK cached at the cast; damage over time, so the elite 凋敝魂灵 ×skill@damage_scale applies; diff --git a/server/sim/content/kits/ops/chess_char_4_25-cetsyr.js b/server/sim/content/kits/ops/chess_char_4_25-cetsyr.js index 9340c177..e8408d6e 100644 --- a/server/sim/content/kits/ops/chess_char_4_25-cetsyr.js +++ b/server/sim/content/kits/ops/chess_char_4_25-cetsyr.js @@ -12,7 +12,7 @@ import { /** * 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK (or max HP) on top of the target's own * percentage multipliers (the flat mod is compensated, so an ATK+% skill does not scale it), the strongest source wins - * (the ATK key `inspire` is shared with tier6's 鼓舞); units flagged `mem.noInspire` (bards: 自身不受鼓舞影响) never + * (the ATK key `inspire` is shared with 浊心斯卡蒂's 鼓舞); units flagged `mem.noInspire` (bards: 自身不受鼓舞影响) never * receive it. Refreshed by an aura: lapses within AURA_DUR once the source stops. */ function inspire(battle, target, val, src, stat = 'atk') { diff --git a/server/sim/content/kits/ops/chess_char_5_20-aglina.js b/server/sim/content/kits/ops/chess_char_5_20-aglina.js index 0692a79c..b24e9d1e 100644 --- a/server/sim/content/kits/ops/chess_char_5_20-aglina.js +++ b/server/sim/content/kits/ops/chess_char_5_20-aglina.js @@ -22,7 +22,7 @@ export default { // off, all allies regenerate 20 HP/s. Module (elite): longer sluggish (trait bb, profession tunables). // S1 秘杖·速充模式 (duration, attack SP): ATK +; she attacks normally with S1 ("技能未开启时无法普通攻击" is only in the // S2/S3 texts). S2 秘杖·微粒模式 (duration, SEARCH): attack interval ×base_attack_time ("极大幅度缩短": a positive value - // described as a shortening is the new interval ratio, tier1 batMod convention), each attack damage_scale × ATK arts. + // described as a shortening is the new interval ratio, the shared/tier1.js batMod convention), each attack damage_scale × ATK arts. // Module DEC-X (elite): SP +0.2/s with an enemy in range. chess_char_5_20_a: (bb, chess, def) => { const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1); diff --git a/server/sim/content/kits/ops/chess_char_6_18-whitw2.js b/server/sim/content/kits/ops/chess_char_6_18-whitw2.js index 96a015f6..057f6309 100644 --- a/server/sim/content/kits/ops/chess_char_6_18-whitw2.js +++ b/server/sim/content/kits/ops/chess_char_6_18-whitw2.js @@ -50,7 +50,7 @@ function whitw2(bb, chess, def) { // ② the selectable enemy nearest to the drone, ties broken by the one nearest to her. [ASSUMED] distances are measured // to every enemy's position (a huge enemy's centre, its 判定中心) — the owner's decision of 2026-10-04: the centre, so // leader rounds stay close to 0.1.1; the sim's general convention for operator-side distance picks, the hit rectangle - // (body.js bodyDist: targeting.js sortEnemyTargets 'nearest', tier3 enemiesAround, 异客 / 溯光星源's chains), was + // (body.js bodyDist: targeting.js sortEnemyTargets 'nearest', 空弦's enemiesAround, 异客 / 溯光星源's chains), was // considered — it made a huge leader (胄, 管) the nearest enemy of every drone around it (DESIGN §22.9) const pickTarget = (battle, unit, d, ok) => { let best = null, bd = Infinity, bh = Infinity; diff --git a/server/sim/content/kits/shared/tier1.js b/server/sim/content/kits/shared/tier1.js index 78a5b15d..52689355 100644 --- a/server/sim/content/kits/shared/tier1.js +++ b/server/sim/content/kits/shared/tier1.js @@ -9,7 +9,8 @@ // // Covered (normal + elite): 1_01 隐现 1_02 角峰 1_03 惊蛰 1_04 深巡 1_05 红豆(H) 1_06 刺玫 1_07 普罗旺斯 1_08 德克萨斯 // 1_09 跃跃 1_10 古米 1_11 地灵(H) 1_12 艾丝黛尔 1_13 波登可 1_14 格雷伊 1_16 锡人(H) 1_17 深靛 -// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.) 1_15 盟约·辅助干员(H) lives in tier6.js. +// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.) 1_15 盟约·辅助干员(H) is registered +// with the tier-6 kits. // Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's // `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite); // talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active @@ -227,7 +228,7 @@ export const skillBbOf = (chess, skillId) => skillRec(chess, skillId)?.bb ?? {}; /** * Skill blackboard `base_attack_time` → engine batPct of this chess's base attack time. The value is a FLAT change of * the base attack time in seconds (AK attribute ADDITION: 送葬人 −0.5 on 2.3 s "少量缩短(-0.5)" ⇒ 1.8 s, 红豆 +0.5 - * "略微增大(+0.5)" — the same convention as tier4/tier5), except a positive value described as a shortening, which is + * "略微增大(+0.5)" — the same convention as the tier-4 / tier-5 kits), except a positive value described as a shortening, which is * the new interval ratio (深靛 0.7 "攻击间隔略微缩短" ⇒ ×0.7). The convention is per skill in AK: skills PRTS lists as * a ratio (雷蛇 反击电弧 "+70%", 古米 食粮烹制 "+130%", 深靛 灯塔守卫者 "-80%") use batPct = value directly, not this. */ diff --git a/server/sim/content/kits/shared/tier2.js b/server/sim/content/kits/shared/tier2.js index 85a694f8..75cc6d81 100644 --- a/server/sim/content/kits/shared/tier2.js +++ b/server/sim/content/kits/shared/tier2.js @@ -1,9 +1,9 @@ // server/sim/content/kits/shared/tier2.js — helpers and notes of the Tier 2 operator kits (formerly tier2.js; the kits // live one per file in ../ops/). // -// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). Shared helpers live in tier1.js (named -// exports). Numbers come from the skill / talent / trait blackboards (elite module upgrades are in the elite record's -// trait.bb and hidden index −1 talents). fx kinds: see the tier1.js header. +// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). Shared helpers live in shared/tier1.js +// (named exports). Numbers come from the skill / talent / trait blackboards (elite module upgrades are in the elite +// record's trait.bb and hidden index −1 talents). fx kinds: see the shared/tier1.js header. // // Covered (normal + elite): 2_01 送葬人 2_02 赫默 2_03 崖心(H) 2_04 小满 2_05 哈洛德 2_06 莎草 2_07 幽灵鲨 2_08 泡泡 // 2_09 休谟斯 2_10 洛洛 2_11 风丸 2_12 砾 2_13 蒂比 2_14 调香师 2_15 协律(H) 2_16 拉普兰德 2_17 折桠 2_18 灰毫 2_19 锡人. diff --git a/server/sim/content/kits/shared/tier5.js b/server/sim/content/kits/shared/tier5.js index 2f55059f..18d1b8d2 100644 --- a/server/sim/content/kits/shared/tier5.js +++ b/server/sim/content/kits/shared/tier5.js @@ -159,7 +159,7 @@ function lowHpHealUp(battle, unit, tb) { /** * Modules "攻击范围扩大" (夕 SPC-X, 白面鸮 RIN-X): the range becomes the SELECTED module's own grid — the data's - * range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + ONE centre tile [0,3] — like tier4's + * range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + ONE centre tile [0,3] — like the tier-4 kits' * 莫斯提马 / 莱恩哈特 / 白面鸮 (integration review: a flat forward +1 added a whole column, 3 tiles, and widened the skill * ranges too). It replaces the unit's own range, so the DEFAULT trigger's initial range follows; skills with their own * range keep it. Without such a grid in the data: one extra forward tile [ASSUMED]. diff --git a/server/sim/content/kits/shared/tier6.js b/server/sim/content/kits/shared/tier6.js index 0276acb3..5e040169 100644 --- a/server/sim/content/kits/shared/tier6.js +++ b/server/sim/content/kits/shared/tier6.js @@ -165,7 +165,7 @@ const selectedSkill = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? /** * Skill blackboard `base_attack_time` → engine batPct, the rule of the AK damage calculator (akdata attributes.js, * checked against the game): a shortening (v < 0) is a FLAT change of the base attack time in seconds (送葬人 −0.5 on - * 2.3 s ⇒ 1.8 s, the tier1 `batMod` convention), a lengthening (v > 0) scales it (迷迭香 S2 +0.5 ⇒ ×1.5, 佩佩 S3 +0.2 ⇒ + * 2.3 s ⇒ 1.8 s, the shared/tier1.js `batMod` convention), a lengthening (v > 0) scales it (迷迭香 S2 +0.5 ⇒ ×1.5, 佩佩 S3 +0.2 ⇒ * ×1.2). `mul` = a skill the calculator lists as "攻击间隔缩短,但是是乘算负数" (异客 S2 −0.3 ⇒ ×0.7, 迷迭香 S3 −0.5 ⇒ ×0.5). */ function batOf(v, def, mul = false) { diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 72faa1ff..5e0b56fc 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -32,7 +32,7 @@ // its blast fx carries `consumed: true` so clients play the explosion, not a death sound) // 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile // 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs; 维娜 S3 places one on every -// free deployable tile around her (kits/tier6.js) — no per-owner deploy limit (SKILL_SUMMON_UNCAPPED) +// free deployable tile around her (kits/ops/chess_char_6_07-siege2.js) — no per-owner deploy limit (SKILL_SUMMON_UNCAPPED) // 防护单元 untargetable, invulnerable device placed by the player (a hand piece, user playtest #6): shield = // 凯瑟琳 max HP × max_shield_ratio on the operator in its range (range 1-1: the tile it faces; effects do // not stack — `cathy:shield`, read by 凯瑟琳 S1 岁月锻打), in full whenever it takes a new operator, @@ -691,7 +691,7 @@ function wolfPack(bb, raw, def) { * The operator a 流形 copies ("可复制待部署区一名干员"): the nearest (Chebyshev tiles) living operator of its player on * the field, not the summoner; ties → higher base ATK → lower id. (The remake deploys the whole board at battle start, * so the official "operator waiting to deploy" is read as the nearest deployed operator — same pick as the 缪尔赛思 - * kit, content/kits/tier6.js.) Null when there is none: the 流形 then waits with its copy skill ready. + * kit, content/kits/ops/chess_char_6_11-mlyss.js.) Null when there is none: the 流形 then waits with its copy skill ready. */ export function pickCopyTarget(battle, unit) { let best = null, bs = null; @@ -1438,7 +1438,8 @@ function deployLimitOf(u) { /** * Tactical point (战术点) of a tactician when the player placed no 援军 piece: `Battle.findTacticalPoint` — a free * walkable tile of its initial range, on an enemy ground path first (where a player would put the blocker), then - * nearest to the tactician. Shared by every tactician kit (tier3 伺夜, the tokens' own fallback). + * nearest to the tactician. Shared by every tactician kit (伺夜, kits/ops/chess_char_3_19-vigil.js; the tokens' own + * fallback). */ export function tacticalPoint(battle, owner) { return battle.findTacticalPoint(owner); diff --git a/shared/loadoutRecord.js b/shared/loadoutRecord.js index 0a40383d..e9210e8a 100644 --- a/shared/loadoutRecord.js +++ b/shared/loadoutRecord.js @@ -106,9 +106,9 @@ export function loadoutRecord(rec, lo) { * The attack range a (loadout-resolved) chess record fights with from its deployment — the detail card without a live * entry, the board's range overlay and the deploy wheel (DESIGN §16), the same tiles the battle unit starts with (prep * m.unitStats `range`): the selected skill's grid when it reads "被动效果:攻击范围扩大" (引星棘刺 S3 3-9: her own range - * while she carries it, tier5 kit); else an elite whose equipped module reads "攻击范围扩大" uses that module's own grid + * while she carries it, her kit); else an elite whose equipped module reads "攻击范围扩大" uses that module's own grid * — its range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + the centre tile [0,3] — as the - * kits do (tier4 moduleRangeGrid, tier5 moduleRangeUp); anything else its `rangeGrid`. Then grown by the 特性's + * kits do (kits/shared/tier4.js moduleRangeGrid, shared/tier5.js moduleRangeUp); anything else its `rangeGrid`. Then grown by the 特性's * permanent 攻击距离 (traitRangeExtend: 信仰搅拌机 SPT-Y "攻击距离+1"). A running skill's range is the live entry's. * @param {object|null} rec loadoutRecord(…) output (or a data/chess.json record: its default module) * @returns {number[][]|null} @@ -131,7 +131,7 @@ export function attackRangeGrid(rec) { /** * The permanent 攻击距离 (ability_range_forward_extend) a record's 特性 grants — a module's, e.g. 信仰搅拌机 SPT-Y - * "攻击距离+1" (tier4 rangeUp: a persistent rangeExtend buff, s.baseRangeExtend); 0 for one that works "在集成战略中" only + * "攻击距离+1" (its kit's rangeUp: a persistent rangeExtend buff, s.baseRangeExtend); 0 for one that works "在集成战略中" only * (空弦 ISW-A). The other 攻击距离 of the mode are skills' (their running range). * @param {object|null} rec loadoutRecord(…) output */ diff --git a/test/content/kits_alt_t1.test.js b/test/content/kits_alt_t1.test.js index 993b9a6c..328a2158 100644 --- a/test/content/kits_alt_t1.test.js +++ b/test/content/kits_alt_t1.test.js @@ -1,4 +1,4 @@ -// Tier 1 alternate skills & modules (DESIGN §16 operator loadouts; server/sim/content/kits/tier1.js `skills` maps). +// Tier 1 alternate skills & modules (DESIGN §16 operator loadouts; the `skills` maps of the tier-1 kits, server/sim/content/kits/ops/). // Every selectable non-default skill of every visible tier-1 chess runs a real battle — normal (Lv4) and elite (Lv7) — // through the harness with its loadout (`skillIndex` / `moduleId` on the board entry, as a BattleSpec carries them), // and its signature effect is asserted with numbers from that skill's own blackboard (data/chess.json skills[]). diff --git a/test/content/kits_alt_t2.test.js b/test/content/kits_alt_t2.test.js index 0577a25f..09f1f07a 100644 --- a/test/content/kits_alt_t2.test.js +++ b/test/content/kits_alt_t2.test.js @@ -1,4 +1,4 @@ -// Operator loadouts (DESIGN §16) for the tier-2 kits (server/sim/content/kits/tier2.js): every selectable NON-default +// Operator loadouts (DESIGN §16) for the tier-2 kits (server/sim/content/kits/ops/): every selectable NON-default // skill of every visible tier-2 chess is hand-authored (tools/kit-coverage.mjs) and shows its signature effect for the // normal (Lv4) and the elite (Lv7) chess — numbers from the selected skill's blackboard (data/chess.json skills[]) — // and the elite's module choice ('none' instead of the default module) changes what the kit / profile does. diff --git a/test/content/kits_alt_t3.test.js b/test/content/kits_alt_t3.test.js index 6d4fb48f..620825b0 100644 --- a/test/content/kits_alt_t3.test.js +++ b/test/content/kits_alt_t3.test.js @@ -1,4 +1,4 @@ -// Tier-3 operator loadouts (DESIGN §16, server/sim/content/kits/tier3.js): every selectable NON-default skill of every +// Tier-3 operator loadouts (DESIGN §16, server/sim/content/kits/ops/): every selectable NON-default skill of every // visible tier-3 chess is hand-authored (`skills[skillId]`) and proves its signature effect for the normal (Lv4) and the // elite (Lv7) chess with its own blackboard; non-default modules that change behaviour are exercised too. import { test } from 'node:test'; diff --git a/test/content/kits_alt_t4.test.js b/test/content/kits_alt_t4.test.js index c5c5849d..091f15aa 100644 --- a/test/content/kits_alt_t4.test.js +++ b/test/content/kits_alt_t4.test.js @@ -1,5 +1,5 @@ // Tier-4 operator loadouts (DESIGN §16): every selectable NON-default skill of every visible tier-4 chess has a -// hand-authored spec in server/sim/content/kits/tier4.js (`skills[skillId]`), proven here by its signature effect for +// hand-authored spec in its kit file (server/sim/content/kits/ops/, `skills[skillId]`), proven here by its signature effect for // the normal (Lv4) and the elite (Lv7) chess; non-default modules (and 'none') change what their text says. Numbers // are read back from the data blackboards (the SELECTED skill's `bb`), real battles through the harness. import { test } from 'node:test'; diff --git a/test/content/kits_alt_t5.test.js b/test/content/kits_alt_t5.test.js index 12d0b54e..1b201a5f 100644 --- a/test/content/kits_alt_t5.test.js +++ b/test/content/kits_alt_t5.test.js @@ -1,4 +1,4 @@ -// Tier 5 alternate skills & modules (DESIGN §16 operator loadouts; server/sim/content/kits/tier5.js `skills` maps). +// Tier 5 alternate skills & modules (DESIGN §16 operator loadouts; the `skills` maps of the tier-5 kits, server/sim/content/kits/ops/). // Every selectable non-default skill of every visible tier-5 chess runs a real battle — normal (Lv4) and elite (Lv7) — // through the harness with its loadout (`skillIndex` / `moduleId` on the board entry, as a BattleSpec carries them), // and its signature effect is asserted with numbers from that skill's own blackboard (data/chess.json skills[]). diff --git a/test/content/kits_alt_t6.test.js b/test/content/kits_alt_t6.test.js index 196d082a..390baaa1 100644 --- a/test/content/kits_alt_t6.test.js +++ b/test/content/kits_alt_t6.test.js @@ -1,4 +1,4 @@ -// Operator loadouts (DESIGN §16) for the tier-6 kits (server/sim/content/kits/tier6.js): every selectable NON-default +// Operator loadouts (DESIGN §16) for the tier-6 kits (server/sim/content/kits/ops/): every selectable NON-default // skill of every visible tier-6 chess is hand-authored (tools/kit-coverage.mjs) and shows its signature effect for the // normal (Lv4) and the elite (Lv7) chess — numbers from the selected skill's blackboard (data/chess.json skills[]) — // and the elite's non-default module choices (Y / RA modules, or 'none') change what the kit does. diff --git a/test/content/kits_t1t2.test.js b/test/content/kits_t1t2.test.js index d1b3bb8f..23964030 100644 --- a/test/content/kits_t1t2.test.js +++ b/test/content/kits_t1t2.test.js @@ -1,4 +1,4 @@ -// Content tests for the tier 1 / tier 2 operator kits (server/sim/content/kits/tier1.js, tier2.js). +// Content tests for the tier 1 / tier 2 operator kits (server/sim/content/kits/ops/chess_char_1_*.js, chess_char_2_*.js). // Every chess (normal + elite where the elite adds something) runs a real battle through the harness and the test // asserts its signature effect with numbers taken from the chess blackboards (data/chess.json). import { test } from 'node:test'; diff --git a/test/content/kits_t3.test.js b/test/content/kits_t3.test.js index d2dac81f..f2d5e461 100644 --- a/test/content/kits_t3.test.js +++ b/test/content/kits_t3.test.js @@ -1,4 +1,4 @@ -// Tier-3 operator kits (server/sim/content/kits/tier3.js): every chess runs a real battle through the harness and +// Tier-3 operator kits (server/sim/content/kits/ops/chess_char_3_*.js): every chess runs a real battle through the harness and // its signature skill / talent / module effect is asserted with numbers taken from its own blackboards. import { test } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/test/content/kits_t4.test.js b/test/content/kits_t4.test.js index 18369006..80a58c75 100644 --- a/test/content/kits_t4.test.js +++ b/test/content/kits_t4.test.js @@ -1,4 +1,4 @@ -// Tier-4 operator kits (server/sim/content/kits/tier4.js): one signature test per chess (+ elite checks), real battles +// Tier-4 operator kits (server/sim/content/kits/ops/chess_char_4_*.js): one signature test per chess (+ elite checks), real battles // through the harness. Numbers are read back from the data blackboards so the tests follow data changes. import { test } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/test/content/kits_t5.test.js b/test/content/kits_t5.test.js index dbace6bf..876e3ac2 100644 --- a/test/content/kits_t5.test.js +++ b/test/content/kits_t5.test.js @@ -1,4 +1,4 @@ -// Tier-5 operator kits (server/sim/content/kits/tier5.js): every chess runs a real battle through the harness and its +// Tier-5 operator kits (server/sim/content/kits/ops/chess_char_5_*.js): every chess runs a real battle through the harness and its // signature effect is asserted with numbers taken from its own blackboards (normal Lv4 / elite Lv7). import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -998,7 +998,7 @@ test('录武官 S2: ATK +45 %; healed allies heal 80 HP per hit taken for 10 s; }); // ------------------------------------------------------------------------------------------------------------------ -// regressions of the 2026-09-28 fidelity review (PRTS 备注 of the base operators; see the tier5.js header) +// regressions of the 2026-09-28 fidelity review (PRTS 备注 of the base operators; see the kits/shared/tier5.js header) test('圣约送葬人 T1 受选之人: the extra attack consumes no ammo and never reaches ammoUsed listeners', () => { const log = []; diff --git a/test/content/kits_t6.test.js b/test/content/kits_t6.test.js index 7364a317..b92b3b76 100644 --- a/test/content/kits_t6.test.js +++ b/test/content/kits_t6.test.js @@ -1,4 +1,4 @@ -// Content tests for the tier-6 kits (server/sim/content/kits/tier6.js) + 盟约·辅助干员 (chess_char_1_15). +// Content tests for the tier-6 kits (server/sim/content/kits/ops/chess_char_6_*.js) + 盟约·辅助干员 (chess_char_1_15). // Every test runs a real battle through the harness and checks the signature effect of the kit. import { test } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/test/content/playtest4_summons.test.js b/test/content/playtest4_summons.test.js index 2e761dc9..6e0c9f14 100644 --- a/test/content/playtest4_summons.test.js +++ b/test/content/playtest4_summons.test.js @@ -1,4 +1,4 @@ -// Content tests for user playtest #4 items 11 and 12 (server/sim/content/tokens.js, kits/tier2.js): +// Content tests for user playtest #4 items 11 and 12 (server/sim/content/tokens.js, kits/ops/…2_02-silent.js): // #11 赫默's 医疗无人机 comes with her S2 — since user playtest #6 it is a hand piece the player places, it deploys // once on that tile at the battle start (PRTS 卫戍协议/帮助 §作战阶段; settled by the user after playtest #6 — // shared/constants.js SKILL_SUMMON_START_DEPLOY) and re-appears there each time S2 fires diff --git a/test/content/playtest6_summons.test.js b/test/content/playtest6_summons.test.js index 35bafde2..eb06a54d 100644 --- a/test/content/playtest6_summons.test.js +++ b/test/content/playtest6_summons.test.js @@ -1,7 +1,7 @@ // User playtest #6 items 1 and 2 (from a friend): 凯瑟琳's device and 赫默's drone could not be placed by hand. The // prep side is test/match/playtest6_summons.test.js; this file is the battle side of the placed pieces -// (server/sim/content/tokens.js dockSkillSummons / releaseSkillSummon, Battle.start `deferDeploy`, kits tier2 / tier3 / -// tier4, the 爬行号·防护单元 token kit): +// (server/sim/content/tokens.js dockSkillSummons / releaseSkillSummon, Battle.start `deferDeploy`, the summoner +// kits in kits/ops/, the 爬行号·防护单元 token kit): // * PRTS 卫戍协议/帮助 §作战阶段: the board summons deploy after the operators; "若战场区初始部署有召唤物,若召唤物在 // 战斗期间退场,将在满足条件后立即原地再部署1个" — a placed piece marks the tile its summon deploys on; // * a skill's summon (赫默 医疗探机, 巫恋 诅咒娃娃: "获得一个…") deploys once on its tile at the battle start, for free diff --git a/test/helpers/battleHarness.js b/test/helpers/battleHarness.js index 7a70fe7f..93107238 100644 --- a/test/helpers/battleHarness.js +++ b/test/helpers/battleHarness.js @@ -19,7 +19,7 @@ // routes RouteSpec[] (default: template routes, or flat-stage routes 0..3 = walk low, walk high, fly low, fly high) // timeLimit seconds (default: template maxPlayTime, else 60; boss/hidden: Infinity) // content 'full' | 'generic' | 'none' (default 'full') -// kits { [baseChessId]: (bb, chess, def) => Kit } injected kits (take precedence over kits/tier*.js) +// kits { [baseChessId]: (bb, chess, def) => Kit } injected kits (take precedence over kits/index.js) // extraContent [{ install(battle) }] extra content modules installed after the domain modules (content tests) // defs { chess: {id: record}, enemies: {key: record}, tokens: {id: record} } extra/override data records // flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true). The harness sets diff --git a/test/render/fxwall.test.js b/test/render/fxwall.test.js index f3cbce10..959a6dc0 100644 --- a/test/render/fxwall.test.js +++ b/test/render/fxwall.test.js @@ -1,5 +1,5 @@ // test/render/fxwall.test.js — 余 S3 fire wall visual (render/fx.js): the sim's `firewall` fx carries `axis` ('col' for -// a RIGHT / LEFT facing, 'row' for UP / DOWN — sim/content/kits/tier6.js); the renderer lights that straight line of +// a RIGHT / LEFT facing, 'row' for UP / DOWN — sim/content/kits/ops/chess_char_6_03-yu.js); the renderer lights that straight line of // tiles through his tile, clipped to the battle rect (it used to be a round zone whatever the facing). import { test } from 'node:test'; diff --git a/test/sim/fidelity.test.js b/test/sim/fidelity.test.js index a6669cc7..694ddbdc 100644 --- a/test/sim/fidelity.test.js +++ b/test/sim/fidelity.test.js @@ -222,7 +222,7 @@ test('generic text rules: 泡泡 stops attacking; 小满 sleeps ≤3 enemies onc }); test('generic element: 塑心 charges add 85 % ATK of 凋亡 (apoptosis) damage to the gauge', () => { - // the generic kit's numbers (tier6.js has a hand-written 塑心 kit whose talent amplifies apoptosis in range) + // the generic kit's numbers (kits/ops/chess_char_6_09-cello.js is a hand-written 塑心 kit whose talent amplifies apoptosis in range) const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_6_09_a', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], timeLimit: 60, content: 'generic' }); const u = h.unit('chess_char_6_09_a'); assert.ok(h.runUntil(() => u.skill.activations >= 1 && h.enemy('enemy_dummy').elem.apoptosis > 0, 40)); From 920624e743e2311e243e92c7783432bbdc861948 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 16:26:29 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feedback5=20R4:=20kits/README.md=20?= =?UTF-8?q?=E2=80=94=20how=20a=20kit=20file=20is=20built,=20what=20it=20ma?= =?UTF-8?q?y=20use=20(shared=20helpers,=20the=20battle=20API=20of=20docs/S?= =?UTF-8?q?IM.md),=20naming,=20registration,=20tests,=20the=20fidelity=20r?= =?UTF-8?q?ule=20and=20a=20ten-item=20fidelity=20checklist=20per=20operato?= =?UTF-8?q?r=20with=20example=20kits=20and=20tests=20(for=20the=20stand-in?= =?UTF-8?q?s,=20self-select=206=E2=98=85=20operators=20and=20GitHub=20#136?= =?UTF-8?q?);=20test/content/kits=5Flayout.test.js=20checks=20that=20every?= =?UTF-8?q?=20ops/=20file=20is=20listed=20in=20kits/index.js,=20named=20af?= =?UTF-8?q?ter=20its=20chess=20id=20and=20charId,=20registers=20exactly=20?= =?UTF-8?q?its=20kit,=20and=20imports=20nothing=20Node-only=20or=20server-?= =?UTF-8?q?only=20and=20no=20clock=20/=20Math.random?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sim/content/kits/README.md | 185 ++++++++++++++++++++++++++++++ test/content/kits_layout.test.js | 58 ++++++++++ 2 files changed, 243 insertions(+) create mode 100644 server/sim/content/kits/README.md create mode 100644 test/content/kits_layout.test.js diff --git a/server/sim/content/kits/README.md b/server/sim/content/kits/README.md new file mode 100644 index 00000000..0ac4b7d1 --- /dev/null +++ b/server/sim/content/kits/README.md @@ -0,0 +1,185 @@ +# Operator kits + +A kit is the battle behaviour of one chess (棋子): its skills, talents, trait specifics and module effects, on top of +the profession defaults of `server/sim/professions.js`. Kits run inside the deterministic battle simulation that the +server and every browser share, so the same seed and inputs give the same battle everywhere. New kits — the stand-in +operators, the self-select 6★ operators, contributions from GitHub issue #136 — go here, one file each. + +## Layout + +| path | what | +|---|---| +| `index.js` | the registry: `KIT_FILES` lists every kit file, grouped by tier; `KITS` (base chess id → kit builder) and `TIER_KITS` (one registry per tier group) are built from it | +| `ops/-.js` | one kit per file, with the helpers and constants only that kit uses | +| `shared/tier1.js` | the general kit helpers (blackboard readers, unit predicates, hit hooks, area queries, buffs, zones, free tiles, skill records) and the notes of the tier-1 kits | +| `shared/tier2.js` … `tier6.js` | helpers two or more kits of that tier use, and that tier's notes (conventions, simplifications, fx kinds) | +| `tier1.js` … `tier6.js` | re-export shims for the old import paths; removed after the 0.2.0 refactor | + +`content/index.js` takes `KITS` from `index.js`: a unit's kit is `KITS[def.baseId]` (also the exact or the suffix-less +id), else the generic kit built from the skill blackboard (`content/generic.js`, docs/SIM.md §7.4). + +## Naming + +`ops/-.js`, where the code name is the operator's official `charId` without its +`char__` prefix (`data/chess.json` `charId`): `chess_char_3_04_a` (琳琅诗怀雅, `char_1033_swire2`) → +`ops/chess_char_3_04-swire2.js`. + +- The chess id is the registry key and the one unique name: one operator can be two chess (锡人 `chess_char_1_16` and + `chess_char_2_19`, 耶拉 3_20 / 4_03, 华法琳 4_26 / 5_04 …), so a code name alone collides. One file ⇔ one registry + entry, and a directory listing sorts like the data: by tier, then shop order. +- The code name finds the operator by name; the docs and the code already call kits by it (`lemuen`, `whitw2`, + `sbell2`, `pithst`). It is ASCII and stable across languages — no Unicode file names (the macOS NFD / Windows / URL + encoding differences of `/sim/` in the browser) — and the Chinese name is in the file's first line, so + `grep -l 玛恩纳 ops/` works too. +- `-` separates the two parts, which both contain `_`. + +## A kit file + +```js +// server/sim/content/kits/ops/chess_char_6_21-codename.js — 名字 (char_xxxx_codename) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { num, talentBb, statBuff } from '../shared/tier1.js'; // shared helpers, engine modules (relative paths) + +/** 名字 S2 "…": the radius exists only in the PRTS 备注 (no blackboard key). */ +const SOME_RADIUS = 1.5; // helpers / constants of this kit only + +export default { + // 6_21 名字 — S3 …: what each skill / talent / module does, which blackboard key gives each number, [ASSUMED] where + // no source says. + chess_char_6_21_a: (bb, chess, def) => ({ skill: { … }, skills: { … }, talents: [ … ], trait: { … }, install() {} }), +}; +``` + +- **The default export** is `{ [baseChessId]: (bb, chess, def) => Kit }` with exactly one key, the `_a` id; the same kit + serves the elite `_b` record. `bb` = the SELECTED skill's blackboard at the chess's level (normal Lv4, elite Lv7), + `chess` = the record as the unit's loadout makes it (talents, `trait.bb`, `module`), `def` = the normalised def. The + Kit fields (`skill`, `skills: { [skillId]: SkillSpec }`, `talents`, `trait`, `install`) and how the loader picks the + selected skill's spec: docs/SIM.md §7.2; the SkillSpec schema: §7.3; worked examples with real operators: §7.5. +- Small real files to start from: `ops/chess_char_1_11-skgoat.js`, `ops/chess_char_1_02-yak.js`; one with every + selectable skill: `ops/chess_char_3_04-swire2.js`; a large one with its own helpers: `ops/chess_char_6_18-whitw2.js`. +- The tier-4 files wrap their entry in `withDefaults(…)` (`shared/tier4.js`: it rebuilds `def` for callers that pass + `(bb, chess)` only); `content/index.js` always passes all three, so a new kit needs no wrapper. + +## What a kit may use + +- **Helpers**: `../shared/tier1.js` first — `num` / `talentBb` / `talentGrid` / `moduleBb` / `traitBb` / `moduleOn`, + `skillRec` / `skillBbOf` (another selectable skill's record and blackboard), `instantKind`, `batMod`, `up` / `cheb`, + `onHitBy` / `onHitOn` / `onDamagedOn`, `enemiesInGrid` / `alliesInGridOf` / `enemyInRange`, `statBuff` / `toggleBuff` + / `installAura` / `spTimeBonus` / `installReveal`, `makeZone`, `freeTileAround`, `giveSp`, `once`. The other + `../shared/tierN.js` are usable too, but same-named helpers are not interchangeable: `num` of tier 1 / 4 also reads + numeric strings, those of tiers 3 / 5 / 6 do not; `toggleBuff`, `talentBb`, `moduleBb`, `instantKind` take different + arguments per tier. Read the helper before using it. +- **The battle API** documented in docs/SIM.md: units (§2), buffs / mods / statuses (§3), the damage & heal pipeline + (§4), the hook bus and its re-entrancy rule (§5), the engine helpers content must use (§6), the skill runtime (§7.1); + engine modules by relative path (`../../../constants.js`, `targeting.js`, `body.js` for huge enemies, `dir.js` for + facing, `damage.js` `hasHp` / `isHpLoss` / `mitigate`, `buffs.js`, `simdata.js` `normalizeChess`); content modules + (`../../tokens.js` summons, `../../items/battle.js`). +- **Never**: Node-only modules (`node:*`, `server/sim/nodeData.js`) or anything of `server/match`, `server/net`, + `public/` — the sim is served to browsers as-is (`/sim/`, no bundler); `Math.random`, `Date.now`, timers — use + `battle.rng()` (`rng.int / range / chance / pick / shuffle / weighted`), `battle.after` / `battle.every`; state kept + across battles in module variables — key it by battle (`WeakMap`) or keep it in `unit.mem`; mutating `bb`, `def`, + `chess` or range grids (deep-frozen, shared by every battle: copy them). Register hooks with `{ owner: unit }` and + check `unit.alive` in handlers. `test/content/kits_layout.test.js` enforces the import / clock rules. +- Order matters: hooks run by priority, then registration order, and every random draw comes from one stream — the + golden corpus (`test/golden/`) notices any change in either. + +## Registering a kit + +1. The chess needs its data record first: `data/chess.json` is generated from the official tables by + `tools/build-data.mjs` (docs/DATA.md) — skills, talents, trait, modules, ranges, tokens and the resolved auto-cast + trigger of every skill. +2. Add the kit file to `ops/`, named as above. +3. Append its file name to its tier's group in `index.js` `KIT_FILES` (never reorder the existing entries). Each file + is loaded with a guarded dynamic import: a file that fails to load is logged and its chess falls back to the + generic kit, so check the server log when a kit seems to do nothing. +4. `node --test test/content/kits_layout.test.js` checks the name, the registry entry and the imports; + `node tools/kit-coverage.mjs --missing` lists selectable skills that still fall back to the generic spec. + +## Testing a kit + +- The harness is `test/helpers/battleHarness.js` (`makeBattle`, `runUntil`, `hooksOf`, `eventsOf`, `checkInvariants`; + docs/SIM.md §10 has an example and every option). Seed everything, use synthetic `enemyRec` targets for exact + numbers, assert on ids, end with `checkInvariants(h.b)`. +- Patterns: `test/content/kits_t1t2.test.js` … `kits_t6.test.js` (the default skill and the talents of every chess), + `test/content/kits_alt_t1.test.js` … `kits_alt_t6.test.js` (every selectable non-default skill and every module; + `kits_alt_t4.test.js` also runs every chess × skill × module against a mixed wave), + `test/content/feedback1b_kits.test.js` (real-match checks). + A new kit gets its own test file (for example `test/content/kits_.test.js`). +- Golden results: a new or changed kit moves the digests of the scenarios that field its chess. Run + `npm run golden:update` and commit `test/golden/*.json` in the same change, saying which scenarios moved and why + (`test/golden/README.md`). A refactor never changes them. +- Run `node --test test/content/kits_layout.test.js test/golden.test.js `, then + `GOLDEN_FULL=1 node --test test/golden.test.js` before the pull request. + +## The fidelity rule + +Every official skill, talent and module is implemented exactly as the game has it — no approximation. + +- Sources: the official tables (`.cache/gamedata/excel/*.json`, which `tools/build-data.mjs` downloads: character_table, + skill_table, battle_equip_table / uniequip_table, activity_table → act2autochess) as built into `data/chess.json`; + PRTS (prts.wiki: the operator page, its 备注, 卫戍协议 pages) for mechanics the data does not carry — cite it. +- Numbers come from the blackboards whenever a key exists; a number that only exists in the text is parsed from it + (with a fallback) or a named constant with its source in the comment. +- When the text and the blackboard seem to disagree, find out what the game does and say so in the comment (DESIGN + §19.9: 至简's text says 购买价格为1 while `bb.price` is 2 — the blackboard is the discount off the tier price 3). +- If the engine cannot express a mechanic, extend the engine in a reviewed change of its own instead of faking it. +- Anything no source backs is marked `[ASSUMED]` in the comment and in the pull request; a decision of the owner is + cited as "owner's decision YYYY-MM-DD". + +## Fidelity checklist (one per operator) + +Each item is a mistake this project already made once. Tick every one for every operator in the pull request. + +- [ ] **1. Skills** — every effect of each skill at the level the chess uses (normal Lv4, elite Lv7: the skill_table + blackboard and the text, reconciled), durations, charges, ammo, SP type (time / attack / hit) and initial SP (the + data's `spType`, `initSp`; SkillSpec overrides need a reason). Examples: ammo — 隐现 `ops/chess_char_1_01-inside.js`, + `kits_t1t2.test.js` "1_01 隐现: ammo skill"; charges — 松果 S1, `kits_alt_t3.test.js` "3_10 松果 S1 RMA长钉 (charges)"; + hit SP — `feedback1d-solvent.test.js` (the drain feeds 受击回复 SP). +- [ ] **2. Talents** — every talent at the chess's level / potential (the talent blackboards), conditional ones too. + Example: `kits_t1t2.test.js` "1_01 隐现 elite: +self_ammo after `duration` s on field; a random other 【拉特兰】 ammo + operator +ally_ammo". +- [ ] **3. Modules** — for every module the chess offers (loadout, DESIGN §16): the trait override + (battle_equip_table `overrideTraitDataBundle`, the trait text), the talent changes (`addOrOverrideTalentDataBundle`) + and the stat bonuses (`attributeBlackboard`); `chess` is already the loadout-resolved record. Examples: + `kits_alt_t4.test.js` "信仰搅拌机 modules: SPT-Y 老朋友 = range +1 …" and "莫斯提马 SPC-X: the range becomes the module + grid …", and its "every chess × selectable skill × module" run. +- [ ] **4. Attack range** — the base range, the range while a skill that changes it is on, and back when it ends; a + skill's own 技能范围. Examples: 烛煌 S3, `test/sim/feedback1e-skillrange.test.js`; DESIGN §21.16 (the live range on the + card); 见行者 / 松果, `kits_t3.test.js` ("fires on an enemy inside the skill range only"). +- [ ] **5. Auto-cast trigger** — the data resolves it (`tools/build-data.mjs resolveTrigger`, `data/chess.json` + `skills[].trigger`); check it, and change the data rule rather than hand-coding a trigger in the kit. The owner's + rules (0.2.0): a skill whose attack range is larger than the normal one casts as soon as an enemy is inside the + SKILL range (a deliberate deviation from the official 技能策略, like the 重装 exception; existing kits that predate + the rule, such as 烛煌 S3 in `feedback1e-skillrange.test.js`, are not examples of it); an AUTO skill that acts on + allies or itself fires at full SP (引星棘刺 S1, `kits_alt_t5.test.js` "… fires as soon as its SP is full, no enemy + needed (GitHub #124)"); the 重装 exception list (`TRIGGER_DEVIATIONS`: 深巡 S2, 雷蛇 S2, 号角 S2 / S3, 灰毫 S1 / S2, + 余 S2; the 预备干员-重装 / Mechanist stand-ins join it), DESIGN §21.29 / §22.10, + `test/sim/feedback1-tank-triggers.test.js`. +- [ ] **6. Targeting** — can it hit air units (`canHitFly`), ground-only attacks, block count, target priority; can + ground enemies hit it (起飞 / 对地规避, stealth, camouflage flags). Examples: `test/sim/professions.test.js` "fortress + (号角 / 灰毫) is ground-only and never fires at FLY enemies" (DESIGN §22.13); `feedback1b_kits.test.js` "B4 玛恩纳 S3 … + hits the air units" vs "S1 / S2 … stay ground-only"; 蒂比 `ops/chess_char_2_13-tippi.js` (`LIFTOFF_FLAGS`, DESIGN + §21.22); `kits_t3.test.js` "忍冬 迷彩: … ranged enemies stop targeting her". +- [ ] **7. Damage typing** — physical / arts / true / element (元素损伤 vs 元素伤害: docs/SIM.md §7.2), splash vs + normal (普通伤害), dodgeable or not, `isSkill`, and what triggers on-hit effects (a trait that heals on any damage + dealt, e.g. 咒愈师). Examples: `enemies_bosses.test.js` "its attack splash (法术普通伤害) can be dodged; its death blast + (法术溅射伤害) cannot"; DESIGN §23.29 (叙拉古 rolls on every 普通伤害 hit); `test/sim/professions.test.js` "incantation + medics heal an ally for 50 % of damage dealt"; the re-entrancy rule of docs/SIM.md §5. +- [ ] **8. Summons / tokens** — placement rules (the owner's range, deployable tiles), redeploy, what they inherit from + the owner's skill / module (`data/tokens.json` variants, `battle.tokenDef`). Examples: + `test/match/feedback1-placement.test.js` "#9 data: 狼群 and 流形 are owner-range summons" (DESIGN §20.1, §21.3); + `test/content/playtest6_summons.test.js`, `tokens_summons.test.js`; `feedback1b_kits.test.js` "B3 维娜·维多利亚 S3". +- [ ] **9. Status effects** — every status applied, with its official duration and stacking (寒冷 on 寒冷 ⇒ 冻结 for the + longer cold, 沉默 removes abilities, 恐惧, 睡眠, 浮空 …; "同名效果取最高" = `applyStrongest`). Examples: + `test/sim/combat.test.js` "cold on cold ⇒ freeze for the longer cold …", "sleep: target is untargetable and + inactive; fear stops enemy attacks", "statuses: sleep = invulnerable except `hitSleep` attackers; levitate …"; + `enemies_bosses.test.js` "沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced". +- [ ] **10. Tests** — at least one per skill and per talent, plus the trigger rule, the range toggle, anti-air and + every module; the golden files updated in the same pull request (an intended change). + +## Old references + +DESIGN, the changelog and issues cite kits as `kits/tierN.js ` (before 0.2.0). That kit is now +`ops/-.js` — search `ops/` for the chess id, the code name or the Chinese name — and the helpers of +that file are in `shared/tierN.js`. diff --git a/test/content/kits_layout.test.js b/test/content/kits_layout.test.js new file mode 100644 index 00000000..738bfeda --- /dev/null +++ b/test/content/kits_layout.test.js @@ -0,0 +1,58 @@ +// The operator kit layout (server/sim/content/kits/README.md): every file of kits/ops/ is listed once in kits/index.js +// KIT_FILES and nothing else is; a file `-.js` registers exactly the kit of `_a`, whose +// data record has that charId code name; the registry is the tier groups in order, and content/index.js serves that +// object; no kit file imports a Node-only or server-only module or reads the clock / Math.random (the sim is +// deterministic and also runs in the browser, /sim/). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { KIT_FILES, TIER_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { KITS as CONTENT_KITS } from '../../server/sim/content/index.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const KIT_DIR = join(ROOT, 'server/sim/content/kits'); +const CHESS = JSON.parse(readFileSync(join(ROOT, 'data/chess.json'), 'utf8')); +const jsIn = (dir) => readdirSync(join(KIT_DIR, dir)).filter((f) => f.endsWith('.js')).sort(); + +test('kits/index.js lists every kit file of kits/ops/ exactly once, and only those', () => { + const listed = KIT_FILES.flat(); + assert.equal(new Set(listed).size, listed.length, 'a file listed twice'); + assert.deepEqual([...listed].sort(), jsIn('ops')); +}); + +test('a kit file -.js registers the kit of _a (codename: its charId without char__)', async () => { + for (const file of KIT_FILES.flat()) { + const m = /^(chess_char_\w+?)-([a-z0-9]+)\.js$/.exec(file); + assert.ok(m, `${file}: name`); + const key = `${m[1]}_a`; + const rec = CHESS[key]; + assert.ok(rec, `${file}: no data record ${key}`); + assert.equal(String(rec.charId).replace(/^char_\d+_/, ''), m[2], `${file}: code name of ${rec.charId}`); + const mod = await import(`../../server/sim/content/kits/ops/${file}`); + assert.deepEqual(Object.keys(mod.default), [key], `${file}: default export`); + assert.equal(typeof mod.default[key], 'function', `${file}: kit builder`); + assert.equal(KITS[key], mod.default[key], `${file}: registered`); + } +}); + +test('the registry is the tier groups in KIT_FILES order, one key per file; content/index.js serves the same object', () => { + assert.deepEqual(Object.keys(KITS), TIER_KITS.flatMap((t) => Object.keys(t))); + assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length, 'one key per file, none twice'); + assert.equal(CONTENT_KITS, KITS); +}); + +test('kit files stay browser-safe and deterministic: no Node-only or server-only import, no clock, no Math.random', () => { + for (const f of ['index.js', ...['shared', 'ops'].flatMap((d) => jsIn(d).map((x) => `${d}/${x}`))]) { + const src = readFileSync(join(KIT_DIR, f), 'utf8'); + assert.ok(!/from\s+['"]node:|import\(\s*['"]node:|\brequire\(|nodeData\.js/.test(src), `${f}: Node-only import`); + const specifiers = [...src.matchAll(/^(?:import|export)\s[^;]*?\sfrom\s+['"]([^'"]+)['"]/gm), ...src.matchAll(/\bimport\(\s*['"`]([^'"`]+)['"`]/g)]; + for (const [, p] of specifiers) { + assert.ok(p.startsWith('.'), `${f}: bare import ${p}`); + // never server / client code, never an index module (content/index.js, kits/index.js: an import cycle) + assert.ok(!/(^|\/)(match|net|public)\/|(^|\/)index\.js$/.test(p), `${f}: imports ${p}`); + } + assert.ok(!/Math\.random|Date\.now|performance\.now|setTimeout|setInterval/.test(src), `${f}: clock / Math.random (use battle.rng, battle.after / every)`); + } +});