Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 14:31:15 -04:00
1 parent ab1434a3b8
commit e284f4fc76
60 files changed
+2704 -118

No files matched your search

+11
View File
@@ -4,6 +4,17 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-09-27 Shadowfiend summon visibility
- The dedicated `scripts/combat-effects/shadowfiend-preview.html` page cast the
level-80 priest spell and displayed `Shadowfiend summon active`. The live
`CombatSummonPopulation` renderer loaded creature 19668's textured client
FleshBeast model, with its feet near the purple ground marker after placement
adjustment. Evidence: `playtest-artifacts/shadowfiend/summon-preview.png`.
- The isolated model preview also loaded the same graph as one mesh layer. The
combat test verified a fiend-owned damage event and 5% maximum-mana return
from the successful hit. Full dungeon entry was not part of this check.
## 2026-09-26 RuneWaker dungeon minimap and M-key map
- Created a fresh RoM Rogue, downloaded Forsaken Abbey, entered as damage, and
@@ -1 +1 @@
{"schemaVersion":1,"generatedAt":null,"sources":[{"kind":"wow335","path":"../wow335a/Data/enUS/patch-enUS.MPQ","sha256":"9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1"},{"kind":"ascension","path":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","sha256":"2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce"},{"kind":"runewaker","path":"../Runewaker/Resource/data","sha256":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9"}],"coverage":{"requested":6841,"authentic":5617,"fallback":1224,"fallbackKeys":["wow335:20647","wow335:2687","wow335:64380","wow335:20187","wow335:25781","wow335:20185","wow335:20186","wow335:5502","wow335:20184","wow335:53651","wow335:31898","wow335:31804","wow335:53733","wow335:53726","wow335:75","wow335:13481","wow335:781","wow335:34026","wow335:34754","wow335:47633","wow335:46584","wow335:26372","wow335:49278","wow335:49279","wow335:44450","wow335:34913","wow335:43043","wow335:43044","wow335:55342","wow335:29886","wow335:58889","wow335:5225","wow335:52610","ascension:705153","ascension:705154","ascension:560456","ascension:800193","ascension:561322","ascension:804138","ascension:805821","ascension:801767","ascension:707583","ascension:705160","ascension:705184","ascension:705185","ascension:804746","ascension:706346","ascension:706347","ascension:705238","ascension:92081","ascension:706354","ascension:92083","ascension:560906","ascension:707207","ascension:706326","ascension:706327","ascension:92082","ascension:705209","ascension:705210","ascension:705215","ascension:707721","ascension:705196","ascension:706804","ascension:706350","ascension:706351","ascension:705206","ascension:561314","ascension:705172","ascension:561321","ascension:705171","ascension:807861","ascension:800943","ascension:561394","ascension:705202","ascension:705241","ascension:705174","ascension:705170","ascension:705169","ascension:706353","ascension:560564","ascension:503408","ascension:503409","ascension:503410","ascension:503411","ascension:503412","ascension:503413","ascension:503414","ascension:705921","ascension:301297","ascension:572870","ascension:92085","ascension:705923","ascension:712396","ascension:802756","ascension:705906","ascension:705888","ascension:705852","ascension:681465","ascension:705889","ascension:705890","ascension:705873","ascension:806283","ascension:705849","ascension:705907","ascension:706368","ascension:705936","ascension:705937","ascension:705861","ascension:705909","ascension:503748","ascension:92084","ascension:705840","ascension:705841","ascension:92086","ascension:705911","ascension:705878","ascension:706538","ascension:300663","ascension:705903","ascension:705904","ascension:501220","ascension:501221","ascension:501222","ascension:501223","ascension:501224","ascension:501225","ascension:501226","ascension:501227","ascension:501228","ascension:705843","ascension:706482","ascension:501162","ascension:705848","ascension:705844","ascension:807296","ascension:707617","ascension:704500","ascension:560822","ascension:706266","ascension:707844","ascension:92089","ascension:681376","ascension:300465","ascension:92087","ascension:520251","ascension:705102","ascension:707842","ascension:520257","ascension:705108","ascension:804613","ascension:704607","ascension:705120","ascension:705151","ascension:92088","ascension:705105","ascension:560573","ascension:704360","ascension:301287","ascension:707513","ascension:704374","ascension:804609","ascension:300468","ascension:705128","ascension:805244","ascension:704613","ascension:524947","ascension:704612","ascension:807430","ascension:705113","ascension:705125","ascension:705133","ascension:705521","ascension:705522","ascension:705523","ascension:705498","ascension:707862","ascension:92091","ascension:680529","ascension:681484","ascension:806195","ascension:802011","ascension:705488","ascension:92094","ascension:705466","ascension:92093","ascension:705493","ascension:705454","ascension:789256","ascension:707230","ascension:706242","ascension:706243","ascension:705528","ascension:681092","ascension:804024","ascension:680253","ascension:705526","ascension:803638","ascension:705534","ascension:800165","ascension:520211","ascension:520212","ascension:520213","ascension:578297","ascension:574319","ascension:574320","ascension:807462","ascension:807887","ascension:705690","ascension:705691","ascension:92097","ascension:705675","ascension:707053","ascension:705720","ascension:705701","ascension:570722","ascension:92098","ascension:705647","ascension:300595","ascension:92096","ascension:705640","ascension:567542","ascension:705693","ascension:705641","ascension:705642","ascension:705656","ascension:705648","ascension:705649","ascension:707652","ascension:705651","ascension:705696","ascension:705697","ascension:705698","ascension:800699","ascension:807233","ascension:705659","ascension:705660","ascension:804040","ascension:705677","ascension:705689","ascension:567533","ascension:705Line truncated
{"schemaVersion":1,"generatedAt":null,"sources":[{"kind":"wow335","path":"../wow335a/Data/enUS/patch-enUS.MPQ","sha256":"9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1"},{"kind":"ascension","path":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","sha256":"2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce"},{"kind":"runewaker","path":"../Runewaker/Resource/data","sha256":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9"}],"coverage":{"requested":6844,"authentic":5620,"fallback":1224,"fallbackKeys":["wow335:20647","wow335:2687","wow335:64380","wow335:20187","wow335:25781","wow335:20185","wow335:20186","wow335:5502","wow335:20184","wow335:53651","wow335:31898","wow335:31804","wow335:53733","wow335:53726","wow335:75","wow335:13481","wow335:781","wow335:34026","wow335:34754","wow335:47633","wow335:46584","wow335:26372","wow335:49278","wow335:49279","wow335:44450","wow335:34913","wow335:43043","wow335:43044","wow335:55342","wow335:29886","wow335:58889","wow335:5225","wow335:52610","ascension:705153","ascension:705154","ascension:560456","ascension:800193","ascension:561322","ascension:804138","ascension:805821","ascension:801767","ascension:707583","ascension:705160","ascension:705184","ascension:705185","ascension:804746","ascension:706346","ascension:706347","ascension:705238","ascension:92081","ascension:706354","ascension:92083","ascension:560906","ascension:707207","ascension:706326","ascension:706327","ascension:92082","ascension:705209","ascension:705210","ascension:705215","ascension:707721","ascension:705196","ascension:706804","ascension:706350","ascension:706351","ascension:705206","ascension:561314","ascension:705172","ascension:561321","ascension:705171","ascension:807861","ascension:800943","ascension:561394","ascension:705202","ascension:705241","ascension:705174","ascension:705170","ascension:705169","ascension:706353","ascension:560564","ascension:503408","ascension:503409","ascension:503410","ascension:503411","ascension:503412","ascension:503413","ascension:503414","ascension:705921","ascension:301297","ascension:572870","ascension:92085","ascension:705923","ascension:712396","ascension:802756","ascension:705906","ascension:705888","ascension:705852","ascension:681465","ascension:705889","ascension:705890","ascension:705873","ascension:806283","ascension:705849","ascension:705907","ascension:706368","ascension:705936","ascension:705937","ascension:705861","ascension:705909","ascension:503748","ascension:92084","ascension:705840","ascension:705841","ascension:92086","ascension:705911","ascension:705878","ascension:706538","ascension:300663","ascension:705903","ascension:705904","ascension:501220","ascension:501221","ascension:501222","ascension:501223","ascension:501224","ascension:501225","ascension:501226","ascension:501227","ascension:501228","ascension:705843","ascension:706482","ascension:501162","ascension:705848","ascension:705844","ascension:807296","ascension:707617","ascension:704500","ascension:560822","ascension:706266","ascension:707844","ascension:92089","ascension:681376","ascension:300465","ascension:92087","ascension:520251","ascension:705102","ascension:707842","ascension:520257","ascension:705108","ascension:804613","ascension:704607","ascension:705120","ascension:705151","ascension:92088","ascension:705105","ascension:560573","ascension:704360","ascension:301287","ascension:707513","ascension:704374","ascension:804609","ascension:300468","ascension:705128","ascension:805244","ascension:704613","ascension:524947","ascension:704612","ascension:807430","ascension:705113","ascension:705125","ascension:705133","ascension:705521","ascension:705522","ascension:705523","ascension:705498","ascension:707862","ascension:92091","ascension:680529","ascension:681484","ascension:806195","ascension:802011","ascension:705488","ascension:92094","ascension:705466","ascension:92093","ascension:705493","ascension:705454","ascension:789256","ascension:707230","ascension:706242","ascension:706243","ascension:705528","ascension:681092","ascension:804024","ascension:680253","ascension:705526","ascension:803638","ascension:705534","ascension:800165","ascension:520211","ascension:520212","ascension:520213","ascension:578297","ascension:574319","ascension:574320","ascension:807462","ascension:807887","ascension:705690","ascension:705691","ascension:92097","ascension:705675","ascension:707053","ascension:705720","ascension:705701","ascension:570722","ascension:92098","ascension:705647","ascension:300595","ascension:92096","ascension:705640","ascension:567542","ascension:705693","ascension:705641","ascension:705642","ascension:705656","ascension:705648","ascension:705649","ascension:707652","ascension:705651","ascension:705696","ascension:705697","ascension:705698","ascension:800699","ascension:807233","ascension:705659","ascension:705660","ascension:804040","ascension:705677","ascension:705689","ascension:567533","ascension:705Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"wow-m2-model","source":"wow335","sourceModel":"creature/fleshbeast/fleshbeast.m2","sourceSha256":"a8866450127a97e0c7fbaafb07995191f5715fb9603baee7b4ccc43c82bdf441","sourceSkinSha256":"1b0c48ffb01222aae30af8b447b5b0bd15b8110d769349741588afe2143951bb","animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":2000,"moveSpeed":0,"flags":32},{"index":1,"id":25,"variationIndex":0,"durationMs":2000,"moveSpeed":0,"flags":32},{"index":2,"id":4,"variationIndex":0,"durationMs":1000,"moveSpeed":2.5,"flags":32},{"index":3,"id":0,"variationIndex":1,"durationMs":8000,"moveSpeed":0,"flags":32},{"index":4,"id":16,"variationIndex":0,"durationMs":1200,"moveSpeed":0,"flags":33},{"index":5,"id":16,"variationIndex":1,"durationMs":1066,"moveSpeed":0,"flags":33},{"index":6,"id":5,"variationIndex":0,"durationMs":666,"moveSpeed":6.666666507720947,"flags":32},{"index":7,"id":9,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":33},{"index":8,"id":10,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":97},{"index":9,"id":118,"variationIndex":0,"durationMs":1500,"moveSpeed":0,"flags":33},{"index":10,"id":32,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":33},{"index":11,"id":1,"variationIndex":0,"durationMs":1500,"moveSpeed":0,"flags":35},{"index":12,"id":42,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":32},{"index":13,"id":41,"variationIndex":0,"durationMs":2000,"moveSpeed":0,"flags":32},{"index":14,"id":14,"variationIndex":0,"durationMs":1333,"moveSpeed":0,"flags":32},{"index":15,"id":131,"variationIndex":0,"durationMs":2000,"moveSpeed":0,"flags":33},{"index":16,"id":132,"variationIndex":0,"durationMs":2000,"moveSpeed":0,"flags":32}],"globalSequences":[],"bones":[{"parent":-1,"flags":512,"pivot":[0,0,0],"translation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[],"values":[]},{"timestamps":[0],"values":[[0,0,-1.86085045337677]]},{"timestamps":[0],"values":[[0,0,-2.566676616668701]]},{"timestamps":[],"values":[]},{"timestamps":[0],"values":[[0,0,-2.566676616668701]]},{"timestamps":[0],"values":[[0,0,-2.566676616668701]]}]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":0,"flags":512,"pivot":[0.11175063997507095,0,1.0639888048171997],"translation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0,0,-0.7830999493598938]]},{"timestamps":[0],"values":[[-0.41513705253601074,0.01963108219206333,-0.7679443359375]]},{"timestamps":[0,33,67,100,133,167,200,233,267,300,333,367,400,433,467,500,533,567,600,633,667,700,733,767,800,833,867,933,967,1000],"values":[[-0.6023945808410645,-0.04324355348944664,-0.7831001281738281],[-0.6276834607124329,-0.06799580901861191,-0.7831001281738281],[-0.6875438094139099,-0.09416808187961578,-0.7831001281738281],[-0.7579663395881653,-0.119630366563797,-0.7831001877784729],[-0.8149415850639343,-0.1422526240348816,-0.7831001877784729],[-0.8344603180885315,-0.1599048376083374,-0.7831001877784729],[-0.7892459630966187,-0.1568554788827896,-0.7831001877784729],[-0.6839448809623718,-0.12558872997760773,-0.7831001281738281],[-0.5424321293830872,-0.0762987807393074,-0.7831000685691833],[-0.38858309388160706,-0.019179778173565865,-0.7831000089645386],[-0.24627283215522766,0.035574112087488174,-0.7831000089645386],[-0.13937658071517944,0.0777687206864357,-0.7830999493598938],[-0.09176940470933914,0.09720994532108307,-0.7830999493598938],[-0.09426072984933853,0.10052938759326935,-0.7830999493598938],[-0.11896859109401703,0.10130877792835236,-0.7830999493598938],[-0.16173118352890015,0.09977900236845016,-0.7830999493598938],[-0.2183866798877716,0.09617097675800323,-0.7830999493598938],[-0.2847732901573181,0.09071563929319382,-0.7831000089645386],[-0.3567291796207428,0.08364392071962357,-0.7831000089645386],[-0.43009257316589355,0.07518666982650757,-0.7831000685691833],[-0.5007016658782959,0.06557486206293106,-0.7831000685691833],[-0.5643945932388306,0.05503939092159271,-0.7831001281738281],[-0.6170095205307007,0.043811194598674774,-0.7831001281738281],[-0.6543846726417542,0.032121140509843826,-0.7831001281738281],[-0.6723467111587524,0.02020016685128212,-0.7831001281738281],[-0.6730246543884277,0.008279222995042801,-0.7831001281738281],[-0.6617112159729004,-0.0034108334220945835,-0.7831001281738281],[-0.6242804527282715,-0.025174500420689583,-0.7831001281738281],[-0.608748197555542,-0.03478633239865303,-0.7831001281738281],[-0.6023945808410645,-0.04324355348944664,-0.7831001281738281]]},{"timestamps":[0],"values":[[0,0,-0.7830999493598938]]},{"timestamps":[0,167,267,334,400,434,467,500Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"wow-m2-model","source":"wow335","sourceModel":"spells/priest_pennance_missile.m2","sourceSha256":"4854701a41006084ae16ec1efb18ddaca279bb8c34e01de664b19c9cc5eff403","sourceSkinSha256":null,"animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":33},{"index":1,"id":144,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":97}],"globalSequences":[267],"bones":[{"parent":-1,"flags":512,"pivot":[0,0,0],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":0,"sequences":[{"timestamps":[0,33,67,100,133,167,200,233,267],"values":[[-1,0,0,0],[-0.9238868373668631,0,0,0.3826715903195288],[-0.7071138645588549,0,0,0.707083346049379],[-0.3827021088290048,0,0,0.9238563188573871],[0,0,0,1],[0.3826715903195288,0,0,0.9238563188573871],[0.707083346049379,0,0,0.707083346049379],[0.9238563188573871,0,0,0.3826715903195288],[1,0,0,0]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":512,"pivot":[0,0,0],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0,0.6940519425031282,0,0.7198706015198217]]},{"timestamps":[0],"values":[[0,0.6940519425031282,0,0.7198706015198217]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":512,"pivot":[0,0,0],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0,0.6940519425031282,0,0.7198706015198217]]},{"timestamps":[0],"values":[[0,0.6940519425031282,0,0.7198706015198217]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":0,"flags":0,"pivot":[-0.2454105019569397,-0.4859084486961365,0.47723108530044556],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":0,"flags":0,"pivot":[-0.2454105019569397,-0.4859084486961365,0.47723129391670227],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":0,"flags":0,"pivot":[-0.2454071044921875,-0.4859084486961365,0.5319986343383789],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":0,"flags":0,"pivot":[-1.0162807703018188,-0.6030136346817017,0.3895825445652008],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}}],"textureTransforms":[],"materials":[],"textures":[{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_RING_BOW.BLP","sourcePath":"spells/t_vfx_ring_bow.blp","sourceSha256":"5e0e436b692a087f9ef81a663779f6d61500e3948ea59e7bb7a581daed4d307d","url":"/assets/game/combat-effects/original/wow335/all/textures/5e0e436b692a087f9ef8.png"},{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_FLAR_BLUR_64.BLP","sourcePath":"spells/t_vfx_flar_blur_64.blp","sourceSha256":"3d487e285b54cc092e2dd152dcca80161e2c6b00ed2b02f61dab7be28466668f","url":"/assets/game/combat-effects/original/wow335/all/textures/3d487e285b54cc092e2d.png"},{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_FLARE05_A.BLP","sourcePath":"spells/t_vfx_flare05_a.blp","sourceSha256":"51d3d5ff5f72b17be35caa0113e384934dd0ab17b6fe1b41041069112b282508","url":"/assets/game/combat-effects/original/wow335/all/textures/51d3d5ff5f72b17be35c.png"},{"type":0,"flags":0,"filename":"SPELLS\\STAR5A.BLP","sourcePath":"spells/star5a.blp","sourceSha256":"af8b9a0098956c5acf1829f343ad2823d6dd841783d0ccd8685e91c6288f56f6","url":"/assets/game/combat-effects/original/wow335/all/textures/af8b9a0098956c5acf18.png"},{"type":0,"flags":0,"filename":"WORLD\\EXPANSION01\\DOODADS\\SUNWELL\\PASSIVEDOODADS\\SUNWELL\\T_VFX_FLARE01_SAT.BLP","sourcePath":"world/expansion01/doodads/sunwell/passivedoodads/sunwell/t_vfx_flare01_sat.blp","sourceSha256":"77579b90a097661a148812b3fc0edaa6cd99d6b777b62d351a2a07fb56097206","url":"/assets/game/combat-effects/original/wow335/all/textures/77579b90a097661a1488.png"}],"particleGraph":{"schemaVersion":1,"kind":"wow-m2-particle-effect","source":"wow335","sourceModel":"spells/priest_pennance_missile.m2","normalizedSourceModel":"spells/priest_pennance_missile.m2","sourceSha256":"4854701a41006084ae16ec1efb18ddaca279bb8c34e01de664b19c9cc5eff403","durationMs":1000,"animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":33},{"index":1,"id":144,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":97}],"textures":[{"type":0,"flags":0,"filename"Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+31
View File
@@ -0,0 +1,31 @@
# Bite-Sized balance sweep
Run the fixed Ready Discipline Priest party against every selectable boss in the
12 authored Bite-Sized dungeons at each supported level:
```powershell
npx vite-node --mode balance-lab scripts/bite-sized-balance/run.ts --all-levels --output artifacts/bite-sized-balance/final-measured-levels-1-80.json
```
Add `--resume` to continue a partially completed output file. Use `--levels 60`
or `--dungeons bite-sized-hoto-teeth` for a focused check. Add `--trash` to also
measure one compact 4–6 mob pull per selected dungeon; `--trash-only` skips
bosses.
The report records every boss's kill time, HP, outcome, ability damage, and
five-seat damage totals. It uses the live companion AI and a deterministic
baseline Priest rotation with a fixed party seed. Boss attacks, travel, and
real scene navigation are excluded. The trash diagnostic clusters imported
mobs into an ideal visible pull, so it cannot validate live pathing.
Render the report with the Codex bundled Python, which includes Pillow:
```powershell
$python = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe'
& $python scripts/bite-sized-balance/plot.py artifacts/bite-sized-balance/final-measured-levels-1-80.json --output artifacts/bite-sized-balance/final
```
The two PNGs show measured boss times across levels 1–80 and the five-seat
effective damage share for every boss at level 60. Pass `--seat-level 40` (or
any other measured level) to render that level's per-boss five-seat chart. The
source JSON preserves the five-seat breakdown at every measured level.
+193
View File
@@ -0,0 +1,193 @@
"""Render measured Bite-Sized boss fight times and five-seat damage as PNGs."""
import argparse
import json
import statistics
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
SEAT_COLORS = ("#5a9bd5", "#8c76c7", "#e2a34f", "#d96276", "#68b596")
def font(size):
for name in ("arial.ttf", "DejaVuSans.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
pass
return ImageFont.load_default()
def label(boss):
dungeon = boss["dungeonId"].replace("bite-sized-", "").replace("-", " ").title()
return f"{dungeon} · {boss['name']}"
def time_color(seconds):
if seconds is None:
return "#353c48"
if seconds < 25:
return "#5388bf"
if seconds <= 45:
return "#54aa79"
if seconds <= 60:
return "#e2b34f"
return "#d96460"
def render_times(data, output):
bosses = data["bosses"]
levels = data["levels"]
rows = {(row["dungeonId"], row["bossId"], row["level"]): row for row in data["results"]}
x0, y0, cell_w, cell_h = 370, 105, 14, 19
width = x0 + len(levels) * cell_w + 130
height = y0 + len(bosses) * cell_h + 90
image = Image.new("RGB", (width, height), "#f7f7f5")
draw = ImageDraw.Draw(image)
draw.text((20, 12), "Bite-Sized boss kill times by level", font=font(24), fill="#17212b")
draw.text((20, 46), "Measured 5-seat Discipline Priest party · normal difficulty · isolated boss, no travel or boss attacks",
font=font(13), fill="#4a5562")
for x in levels:
if x == 1 or x % 10 == 0 or x == 80:
cx = x0 + levels.index(x) * cell_w
draw.text((cx - 4, y0 - 25), str(x), font=font(11), fill="#334155")
draw.line((cx, y0 - 3, cx, y0 + len(bosses) * cell_h), fill="#d8dce0", width=1)
for index, boss in enumerate(bosses):
y = y0 + index * cell_h
if index and bosses[index - 1]["dungeonId"] != boss["dungeonId"]:
draw.line((15, y - 1, width - 20, y - 1), fill="#9ca9b2", width=1)
draw.text((20, y + 2), label(boss)[:47], font=font(11), fill="#24303a")
times = []
for level_index, level in enumerate(levels):
row = rows.get((boss["dungeonId"], boss["id"], level))
x = x0 + level_index * cell_w
seconds = row["seconds"] if row else None
draw.rectangle((x + 1, y + 1, x + cell_w - 1, y + cell_h - 2),
fill=time_color(seconds) if row else "#dce0e4")
if row and seconds is not None:
times.append(seconds)
if times:
draw.text((x0 + len(levels) * cell_w + 12, y + 2), f"{min(times):.0f}–{max(times):.0f}s",
font=font(11), fill="#334155")
y = y0 + len(bosses) * cell_h + 18
for index, (color, caption) in enumerate((
("#5388bf", "<25s"), ("#54aa79", "25–45s target"),
("#e2b34f", "45–60s"), ("#d96460", ">60s"),
("#353c48", "timeout"), ("#dce0e4", "unmeasured"),
)):
x = 20 + index * 175
draw.rectangle((x, y, x + 18, y + 15), fill=color)
draw.text((x + 25, y), caption, font=font(12), fill="#334155")
image.save(output)
def render_seats(data, output, level=60):
bosses = data["bosses"]
rows = {(row["dungeonId"], row["bossId"]): row for row in data["results"] if row["level"] == level}
x0, y0, bar_w, cell_h = 370, 125, 820, 21
width, height = x0 + bar_w + 150, y0 + len(bosses) * cell_h + 70
image = Image.new("RGB", (width, height), "#f7f7f5")
draw = ImageDraw.Draw(image)
draw.text((20, 12), f"Bite-Sized boss damage by party seat · level {level}", font=font(24), fill="#17212b")
draw.text((20, 46), "Measured effective boss damage · fixed Ready Discipline Priest party · each row totals 100%",
font=font(13), fill="#4a5562")
for index, name in enumerate(("Priest player", "Prot Warrior", "MM Hunter", "Fire Mage", "Assassination Rogue")):
x = 20 + index * 225
draw.rectangle((x, 80, x + 16, 95), fill=SEAT_COLORS[index])
draw.text((x + 22, 79), name, font=font(12), fill="#334155")
for index, boss in enumerate(bosses):
y = y0 + index * cell_h
if index and bosses[index - 1]["dungeonId"] != boss["dungeonId"]:
draw.line((15, y - 1, width - 20, y - 1), fill="#9ca9b2", width=1)
draw.text((20, y + 2), label(boss)[:47], font=font(11), fill="#24303a")
row = rows.get((boss["dungeonId"], boss["id"]))
if not row:
continue
damages = [row["damageByActor"].get(seat["id"], 0) for seat in row["seats"]]
total = max(sum(damages), 1)
offset = x0
for seat_index, damage in enumerate(damages):
segment = round(bar_w * damage / total)
if segment:
draw.rectangle((offset, y + 2, offset + segment - 1, y + cell_h - 3),
fill=SEAT_COLORS[seat_index])
offset += segment
draw.text((x0 + bar_w + 12, y + 2),
f"{row['seconds']:.1f}s" if row["seconds"] is not None else "timeout",
font=font(11), fill="#334155")
image.save(output)
def render_summary(data, output):
rows = data["results"]
levels = data["levels"]
bosses = data["bosses"]
measured = [row for row in rows if row["seconds"] is not None]
timeouts = [row for row in rows if row["seconds"] is None]
low = [row for row in measured if row["seconds"] < 25]
high = [row for row in measured if row["seconds"] > 45]
middle = [row for row in measured if 25 <= row["seconds"] <= 45]
lines = [
"# Bite-Sized boss balance sweep",
"",
f"- Measured fights: {len(rows)} of {len(levels) * len(bosses)} ({len(bosses)} bosses × {len(levels)} levels).",
f"- Target window (25–45 s): {len(middle)}; under 25 s: {len(low)}; over 45 s: {len(high)}; timeouts: {len(timeouts)}.",
"- Method: fixed Ready Discipline Priest party, normal difficulty, live companion AI and deterministic player rotation.",
"- Limits: isolated stationary boss, no boss attacks, no travel, and one party seed. These are measured simulations, not live clear times.",
"",
"## Outlier levels",
"",
"| Level | Under 25 s | Over 45 s | Timeouts | Fastest | Slowest |",
"| ---: | ---: | ---: | ---: | ---: | ---: |",
]
for level in levels:
level_rows = [row for row in rows if row["level"] == level]
level_times = [row["seconds"] for row in level_rows if row["seconds"] is not None]
under = sum(row["seconds"] is not None and row["seconds"] < 25 for row in level_rows)
over = sum(row["seconds"] is not None and row["seconds"] > 45 for row in level_rows)
timed_out = sum(row["seconds"] is None for row in level_rows)
if under or over or timed_out:
lines.append(f"| {level} | {under} | {over} | {timed_out} | "
f"{min(level_times):.1f}s | {max(level_times):.1f}s |")
level_60 = [row for row in measured if row["level"] == 60]
if level_60:
shares = [[] for _ in range(5)]
for row in level_60:
amounts = [row["damageByActor"].get(seat["id"], 0) for seat in row["seats"]]
total = max(sum(amounts), 1)
for index, amount in enumerate(amounts):
shares[index].append(amount / total * 100)
lines += [
"",
"## Level 60",
"",
f"- Kill-time range: {min(row['seconds'] for row in level_60):.1f}–{max(row['seconds'] for row in level_60):.1f} s; median {statistics.median(row['seconds'] for row in level_60):.1f} s.",
"- Mean per-boss effective damage share: "
+ ", ".join(f"{name} {statistics.mean(share):.1f}%" for name, share in zip(
("Priest player", "Prot Warrior", "MM Hunter", "Fire Mage", "Assassination Rogue"), shares)) + ".",
]
output.write_text("\n".join(lines).rstrip() + "\n", encoding="utf8")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("--output", type=Path, default=None)
parser.add_argument("--seat-level", type=int, default=60)
arguments = parser.parse_args()
data = json.loads(arguments.input.read_text(encoding="utf8"))
if arguments.seat_level not in data["levels"]:
parser.error("--seat-level must be one of the measured levels in the report")
output = arguments.output or arguments.input.parent
output.mkdir(parents=True, exist_ok=True)
render_times(data, output / "boss-kill-times-levels-1-80.png")
render_seats(data, output / f"boss-damage-shares-level-{arguments.seat_level}.png",
arguments.seat_level)
render_summary(data, output / "summary.md")
print(output)
if __name__ == "__main__":
main()
+141
View File
@@ -0,0 +1,141 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { loadCanonicalBenchmarkBuilds } from "../../src/game/benchmark/benchmarkBuilds";
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
import { runBiteSizedTrashBenchmark, runDungeonBossBenchmark } from "../../src/game/benchmark/dungeonBossBenchmark";
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
BITE_SIZED_BARREN_CAVES_DUNGEON_ID,
BITE_SIZED_HOTO_DUNGEON_ID,
BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID,
BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
dungeonContentSourceId,
} from "../../src/game/dungeonContentSource";
import { requireDungeonDefinition } from "../../src/game/dungeonRegistry";
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "../../src/game/dungeons/biteSizedStockades";
import { seededBossIndex } from "../../src/game/dungeons/seededBossSelection";
const ids = [
BITE_SIZED_STOCKADES_DUNGEON_ID, BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID, BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID, BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID, BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
BITE_SIZED_BARREN_CAVES_DUNGEON_ID, BITE_SIZED_HOTO_DUNGEON_ID,
BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID, BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
] as const;
function valueAfter(flag: string): string | null {
const index = process.argv.indexOf(flag);
return index < 0 ? null : process.argv[index + 1] ?? null;
}
function selectedLevels(): number[] {
if (process.argv.includes("--all-levels")) return Array.from({ length: 80 }, (_, index) => index + 1);
const requested = valueAfter("--levels");
const levels = requested ? requested.split(",").map(Number) : [1, 10, 20, 30, 40, 50, 60, 70, 80];
if (levels.some((level) => !Number.isInteger(level) || level < 1 || level > 80)) {
throw new Error("Levels must be integers from 1 through 80.");
}
return [...new Set(levels)].sort((left, right) => left - right);
}
function bossCatalog(dungeonId: string) {
const candidates = dungeonId === BITE_SIZED_STOCKADES_DUNGEON_ID
? BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => ({ id: boss.sourceSpawnId, name: boss.name }))
: requireDungeonDefinition(dungeonContentSourceId(dungeonId)).bosses;
const found = new Map<number, string>();
for (let index = 0; index < 2_000 && found.size < candidates.length; index += 1) {
const seed = `bite-sized-balance-${index}`;
const candidateIndex = seededBossIndex(seed, candidates.length);
if (!found.has(candidateIndex)) found.set(candidateIndex, seed);
}
if (found.size !== candidates.length) {
throw new Error(`${dungeonId}: ${candidates.length - found.size} source bosses could not be selected.`);
}
return candidates.map((boss, index) => ({ dungeonId, id: boss.id, name: boss.name, seed: found.get(index)! }));
}
async function main(): Promise<void> {
const selectedDungeons = (valueAfter("--dungeons")?.split(",") ?? ids).filter((id) => ids.includes(id as typeof ids[number]));
const bosses = selectedDungeons.flatMap(bossCatalog);
if (process.argv.includes("--catalog")) {
console.log(JSON.stringify(bosses, null, 2));
return;
}
const levels = selectedLevels();
const output = resolve(valueAfter("--output") ?? "artifacts/bite-sized-balance/ready-priest-boss-sweep.json");
const previous = process.argv.includes("--resume") && existsSync(output)
? JSON.parse(readFileSync(output, "utf8")) as {
results?: Record<string, unknown>[]; trashResults?: Record<string, unknown>[];
}
: null;
const results: Record<string, unknown>[] = previous?.results ?? [];
const trashResults: Record<string, unknown>[] = previous?.trashResults ?? [];
const includeBosses = !process.argv.includes("--trash-only");
const includeTrash = process.argv.includes("--trash") || process.argv.includes("--trash-only");
const writeReport = () => {
mkdirSync(resolve(output, ".."), { recursive: true });
writeFileSync(output, `${JSON.stringify({
method: "Deterministic isolated boss simulation with live stats, companion AI, and baseline Discipline Priest rotation; no boss attacks or travel time.",
scope: "All authored Bite-Sized dungeon boss choices; one fixed party seed; normal difficulty; 150-second timeout.",
policy: BENCHMARK_POLICY_ID, levels, bosses, results, trashResults,
}, null, 2)}\n`);
};
for (const level of levels) {
if (results.filter((result) => result.level === level).length >= (includeBosses ? bosses.length : 0)
&& trashResults.filter((result) => result.level === level).length >= (includeTrash ? selectedDungeons.length : 0)) {
console.log(`Level ${level}: resumed from checkpoint.`);
continue;
}
const build = (await loadCanonicalBenchmarkBuilds(level)).find((candidate) => (
candidate.classId === "priest" && candidate.role === "healer"
));
if (!build) throw new Error(`No Ready discipline priest build exists at level ${level}.`);
for (const boss of includeBosses ? bosses : []) {
if (results.some((result) => result.level === level && result.bossId === boss.id
&& result.dungeonId === boss.dungeonId)) continue;
const result = await runDungeonBossBenchmark(build, boss.id, boss.seed, false, {
dungeonId: boss.dungeonId, durationMs: 150_000, partySeed: "bite-sized-balance-party",
});
const seats = [
{ id: PLAYER_AGGRO_ID, label: "Player · Discipline Priest" },
...result.party.map((label, index) => ({
id: result.partyActorIds[index],
label: `Companion ${index + 1} · ${label}`,
})),
];
results.push({ level, dungeonId: boss.dungeonId, bossId: boss.id, bossName: boss.name,
seconds: result.seconds, outcome: result.outcome, elapsedSeconds: result.elapsedSeconds,
maxHealth: result.maxHealth, remainingHealth: "remainingHealth" in result ? result.remainingHealth : 0,
damageByActor: result.damageByActor, seats, damageByAbility: result.damageByAbility,
party: result.party });
}
if (includeTrash) {
for (const dungeonId of selectedDungeons) {
if (trashResults.some((result) => result.level === level && result.dungeonId === dungeonId)) continue;
const sample = bosses.find((boss) => boss.dungeonId === dungeonId)!;
const result = await runBiteSizedTrashBenchmark(build, dungeonId, sample.seed, {
partySeed: "bite-sized-balance-party", durationMs: 120_000,
});
trashResults.push({ level, dungeonId, ...result,
seats: [{ id: PLAYER_AGGRO_ID, label: "Player · Discipline Priest" },
...result.party.map((label, index) => ({ id: result.partyActorIds[index],
label: `Companion ${index + 1} · ${label}` }))] });
}
}
writeReport();
console.log(`Level ${level}: ${includeBosses ? bosses.length : 0} bosses and ${includeTrash ? selectedDungeons.length : 0} trash pulls measured.`);
}
writeReport();
console.log(output);
}
await main();
+16 -1
View File
@@ -327,6 +327,10 @@ function collectAbilitySpellIds() {
for (const chains of Object.values(wow.classes ?? {})) {
for (const chain of chains) for (const rank of chain.ranks ?? []) add("wow335", Number(rank.spellId));
}
// Penance's player spell delegates visible healing and damage bolts to
// child spells that are absent from the learnable progression catalog.
add("wow335", 47750);
add("wow335", 47666);
const coa = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json"), "utf8"));
for (const abilities of Object.values(coa.abilitiesByClass ?? {})) {
@@ -474,10 +478,21 @@ function buildWowEntries(source, requestedIds, tables) {
}
sounds.push(...soundEntry(soundTable, row.missileSound), ...soundEntry(soundTable, row.animationSound));
}
// Creature 19668 uses display 19110: the FleshBeast model with its black skin.
// The summon is a persistent actor, so retain its model beside the cast kit.
if (source === "wow335" && spellId === 34433) {
addAssets("summon", [{ model: "Creature/FleshBeast/FleshBeast.mdx" }]);
}
for (const [phase, assets] of Object.entries(phases)) {
phases[phase] = [...new Map(assets.map((asset) => [`${asset.model}:${asset.attachment ?? ""}`, asset])).values()];
}
const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()];
const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()]
.map((sound) => {
if (source !== "wow335" || ![47540, 47666, 47750].includes(spellId)) return sound;
if (spellId === 47540) return { ...sound, phase: "cast-start" };
const phase = /ShadowCast|Pennance_MissileLoop/i.test(sound.sourcePath) ? "tick" : "impact";
return { ...sound, phase };
});
if (!visualIds.length && !uniqueSounds.length) continue;
entries.push({
key: `${source}:${spellId}`,
@@ -116,7 +116,11 @@ export function packageWowOriginalEffects(manifest, options = {}) {
const skin = original.buffer.readUInt32LE(60) ? source.read(skinName) : null;
if (original.buffer.readUInt32LE(60) && !skin) throw new Error(`Missing skin: ${skinName}`);
const mesh = skin ? decodeWotlkM2Mesh(original.buffer, skin.buffer, name) : null;
const packagedTextures = effects.textures.map(packageTexture);
const packagedTextures = effects.textures.map((texture) => packageTexture(
name === "creature/fleshbeast/fleshbeast.m2" && texture.type === 11 && !texture.filename
? { ...texture, filename: "Creature/FleshBeast/FleshBeastBlack.blp" }
: texture,
));
const readyParticleIndices = effects.particles.flatMap((particle, index) => (
packagedTextures[particle.textureIndex]?.url
&& particle.samples.emissionRate.some((sample) => (sample.value?.value ?? sample.value) > 0)
@@ -0,0 +1,7 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Shadowfiend summon preview</title></head>
<body style="margin:0;background:#0b121b;color:#e8eef8;font:14px system-ui">
<div id="root"></div><script type="module" src="/src/dev/ShadowfiendPreview.tsx"></script>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const out = path.resolve('playtest-artifacts/dungeon-race-affixes');
fs.mkdirSync(out, { recursive: true });
const base = process.env.RACE_TEST_URL || 'http://127.0.0.1:5173';
const cases = [
['volatile-remains', 'falling-stars', 'volatile', 'star'],
['last-stand', 'stormcall', 'last-stand', 'storm'],
['lingering-ash', 'arcane-pulse', 'ash', 'ring'],
['unstable-orb', 'predator-mark', 'orb', 'predator'],
['risky-boon', 'falling-stars', 'boon', 'star'],
];
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const errors = [];
try {
for (const [role, className, specialization] of [
['Tank', 'Warrior', 'Protection'], ['Healer', 'Priest', 'Discipline'],
['Damage', 'Hunter', 'Marksmanship'],
].filter(([role]) => !process.env.RACE_TEST_ROLE || role === process.env.RACE_TEST_ROLE)) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.on('pageerror', (error) => errors.push(error.message));
try {
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.class-grid .class-option').filter({ hasText: className }).first().click();
await page.locator('.creator-specializations button').filter({ hasText: specialization }).first().click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill(`Affix${role}`);
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().click({ force: true });
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await page.locator('.main-menu-screen').waitFor();
for (const pool of ['authored', 'randomized']) {
await page.getByRole('button', { name: /Dungeon Race/ }).click();
await page.locator('.dungeon-race-screen').waitFor();
if (pool === 'randomized') {
await page.getByRole('button', { name: 'Randomized Bite-Sized' }).click();
await page.locator('.dungeon-race-screen__setup select').first()
.selectOption('randomized-bite-sized-stormwind-stockade-available');
} else {
await page.locator('.dungeon-race-screen__setup select').first()
.selectOption('bite-sized-stormwind-stockade');
}
await page.getByRole('group', { name: 'Your role' })
.getByRole('button', { name: role === 'Damage' ? 'DPS' : role, exact: true }).click();
await page.getByRole('button', { name: 'Start practice' }).click();
await page.locator('.dungeon-race-hud').waitFor({ timeout: 120_000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'visible', timeout: 120_000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 });
await page.locator('.dungeon-race-countdown').waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.dungeon-race-countdown').waitFor({ state: 'hidden', timeout: 30_000 });
const boot = await page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const { dungeonRaceAffix } = await import('/src/game/dungeonRaceAffixes.ts');
const race = useDungeonRaceStore.getState();
return { dungeonId: race.dungeonId, pair: race.affixIds, status: race.status,
names: race.affixIds.map((id) => dungeonRaceAffix(id).name) };
});
assert.equal(boot.status, 'active');
assert.equal(boot.dungeonId.startsWith(pool === 'authored' ? 'bite-sized-' : 'randomized-bite-sized-'), true);
assert.equal(boot.pair.length, 2);
const timerText = await page.locator('.dungeon-race-hud').innerText();
assert.ok(timerText.includes('Mechanics:'));
for (const name of boot.names) assert.ok(timerText.includes(name), `${pool}/${role}: timer omits ${name}`);
for (const [killId, movementId, killKind, movementKind] of cases) {
const observed = await page.evaluate(async ({ killId, movementId }) => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const { useDungeonRaceAffixStore, clearDungeonRaceAffixes, advanceDungeonRaceAffixes } =
await import('/src/game/dungeonRaceAffixRuntime.ts');
const { useCombatStore } = await import('/src/game/combatStore.ts');
const { dungeonDefinitionById } = await import('/src/game/dungeonRegistry.ts');
const { setMobPosition } = await import('/src/game/mobRuntimeRegistry.ts');
const race = useDungeonRaceStore.getState();
const definition = dungeonDefinitionById(race.dungeonId);
const trash = definition.staticSpawns.find((spawn) => !definition.bosses.some((boss) => boss.id === spawn.id));
if (!trash) throw new Error('No original trash spawn for affix playtest');
const now = Date.now();
const player = useCombatStore.getState().playerPosition;
clearDungeonRaceAffixes();
useDungeonRaceStore.setState({ affixIds: [killId, movementId] });
advanceDungeonRaceAffixes(now, []);
setMobPosition(trash.id, player);
useCombatStore.getState().registerMob(trash.id, { name: 'Affix trigger', maxHealth: 100, boss: false });
const original = useCombatStore.getState().mobs[trash.id];
useCombatStore.setState((state) => ({ mobs: { ...state.mobs,
[trash.id]: { ...original, dead: true, health: 0 } } }));
advanceDungeonRaceAffixes(now + 100, [trash.id]);
useCombatStore.setState((state) => ({ mobs: { ...state.mobs,
[trash.id]: { ...state.mobs[trash.id], dead: false, health: 100 } } }));
useDungeonRaceAffixStore.setState({ nextMovementAt: now + 101 });
advanceDungeonRaceAffixes(now + 101, []);
return { kinds: useDungeonRaceAffixStore.getState().effects.map((effect) => effect.kind),
pair: useDungeonRaceStore.getState().affixIds };
}, { killId, movementId });
if (killKind !== 'last-stand') assert.ok(observed.kinds.includes(killKind), `${pool}/${role}: ${killId}`);
assert.ok(observed.kinds.includes(movementKind), `${pool}/${role}: ${movementId}`);
assert.deepEqual(observed.pair, [killId, movementId]);
await page.waitForTimeout(150);
if (role === 'Healer' || (role === 'Damage' && pool === 'randomized')) {
await page.screenshot({ path: path.join(out, `${pool}-${role.toLowerCase()}-${killId}-${movementId}.png`) });
}
}
await page.keyboard.press('Escape');
await page.getByRole('dialog', { name: 'Paused' }).getByRole('button', { name: 'Main menu' }).click();
await page.locator('.main-menu-screen').waitFor();
}
} catch (error) {
await page.screenshot({ path: path.join(out, `failure-${role.toLowerCase()}.png`) });
console.error((await page.locator('body').innerText()).slice(0, 4000));
throw error;
} finally { await page.close(); }
}
assert.deepEqual(errors, []);
console.log(JSON.stringify({ ok: true, runs: process.env.RACE_TEST_ROLE ? 2 : 6,
mechanicsPerRun: 9, screenshots: out }));
} finally { await browser.close(); }
})().catch((error) => { console.error(error); process.exitCode = 1; });
+4 -1
View File
@@ -1182,7 +1182,7 @@ export function openGameDatabase(options = {}) {
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
...(() => {
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError });
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError, isGameMaster });
return {
getDungeonRaceMatch: races.getMatch,
createDungeonRaceMatch: races.createMatch,
@@ -1190,6 +1190,9 @@ export function openGameDatabase(options = {}) {
startDungeonRace: races.start,
settleDungeonRace: races.settleMatch,
completeDungeonRaceRun: races.completeRaceRun,
savePartialDungeonRaceCombatLog: races.savePartialCombatLog,
getDungeonRaceCombatLog: races.getCombatLog,
listDungeonRaceCombatLogs: races.listCombatLogs,
replaceDungeonRaceWithBot: races.replaceWithBot,
voidDungeonRace: races.void,
dungeonRaceRecordForAccount: races.recordForAccount,
+84
View File
@@ -0,0 +1,84 @@
export const MAX_RACE_COMBAT_LOG_BYTES = 4 * 1024 * 1024;
const kinds = new Set(["damage", "healing", "absorb", "resource", "aura", "cast",
"interrupt", "death", "resurrection"]);
function text(value, maximum = 128) {
return typeof value === "string" && value.length > 0 && value.length <= maximum
&& !/[\u0000-\u001f\u007f]/.test(value);
}
function amount(value, maximum = 1_000_000_000) {
return Number.isSafeInteger(value) && value >= 0 && value <= maximum;
}
/** Client-reported diagnostics only; race outcome still comes from the boss clear. */
export function normalizeRaceCombatLog(input, { runId, dungeonId }, ErrorType) {
const invalid = () => { throw new ErrorType("Choose a valid race combat log.", 400, "invalid_race_combat_log"); };
if (!input || typeof input !== "object" || input.version !== 1 || input.runId !== runId
|| typeof input.completed !== "boolean"
|| input.dungeonId !== dungeonId || !amount(input.durationMs, 2 * 60 * 60_000)
|| !amount(input.droppedEventCount) || !amount(input.droppedKillCount)
|| !Array.isArray(input.events) || input.events.length > 10_000
|| !Array.isArray(input.kills) || input.kills.length > 1_000
|| !Array.isArray(input.packs) || input.packs.length > 1_000
|| !Array.isArray(input.party) || input.party.length > 5
|| !Array.isArray(input.partyDamage) || input.partyDamage.length > 5) invalid();
const events = input.events.map((event) => {
if (!event || !amount(event.atMs, 2 * 60 * 60_000) || !kinds.has(event.kind)
|| !text(event.source) || !text(event.target) || (event.ability !== undefined && !text(event.ability))
|| !amount(event.raw) || !amount(event.effective) || !amount(event.overheal)
|| !amount(event.absorbed) || typeof event.critical !== "boolean"
|| (event.outcome !== undefined && !text(event.outcome))) invalid();
return { atMs: event.atMs, kind: event.kind, source: event.source, target: event.target,
...(event.ability ? { ability: event.ability } : {}), raw: event.raw,
effective: event.effective, overheal: event.overheal, absorbed: event.absorbed,
critical: event.critical, ...(event.outcome ? { outcome: event.outcome } : {}) };
});
const kills = input.kills.map((kill) => {
if (!kill || !text(kill.id) || !text(kill.name) || typeof kill.boss !== "boolean"
|| !amount(kill.maxHealth) || !amount(kill.engagedAtMs, 2 * 60 * 60_000)
|| (kill.firstDamageAtMs !== null && !amount(kill.firstDamageAtMs, 2 * 60 * 60_000))
|| !amount(kill.diedAtMs, 2 * 60 * 60_000) || kill.diedAtMs < kill.engagedAtMs
|| !amount(kill.durationMs, 2 * 60 * 60_000)) invalid();
return { id: kill.id, name: kill.name, boss: kill.boss, maxHealth: kill.maxHealth,
engagedAtMs: kill.engagedAtMs, firstDamageAtMs: kill.firstDamageAtMs,
diedAtMs: kill.diedAtMs, durationMs: kill.durationMs };
});
const party = input.party.map((member) => {
if (!member || !text(member.actorId) || !text(member.label) || !text(member.classId)
|| !amount(member.level, 80) || member.level < 1
|| !["tank", "healer", "damage"].includes(member.role)) invalid();
return { actorId: member.actorId, label: member.label, classId: member.classId,
level: member.level, role: member.role };
});
const partyIds = new Set(party.map((member) => member.actorId));
if (partyIds.size !== party.length) invalid();
const partyDamage = input.partyDamage.map((entry) => {
if (!entry || !partyIds.has(entry.actorId) || !amount(entry.damage)) invalid();
return { actorId: entry.actorId, damage: entry.damage };
});
if (new Set(partyDamage.map((entry) => entry.actorId)).size !== partyDamage.length) invalid();
const knownMobIds = new Set(kills.map((kill) => kill.id));
const packs = input.packs.map((pack) => {
if (!pack || !amount(pack.startedAtMs, 2 * 60 * 60_000)
|| !amount(pack.endedAtMs, 2 * 60 * 60_000) || pack.endedAtMs < pack.startedAtMs
|| !amount(pack.durationMs, 2 * 60 * 60_000)
|| !Array.isArray(pack.mobIds) || pack.mobIds.length < 1 || pack.mobIds.length > 1_000
|| pack.mobIds.some((id) => !knownMobIds.has(id))
|| !Array.isArray(pack.partyDamage) || pack.partyDamage.length > 5) invalid();
const damage = pack.partyDamage.map((entry) => {
if (!entry || !partyIds.has(entry.actorId) || !amount(entry.damage)) invalid();
return { actorId: entry.actorId, damage: entry.damage };
});
return { startedAtMs: pack.startedAtMs, endedAtMs: pack.endedAtMs,
durationMs: pack.durationMs, mobIds: [...pack.mobIds], partyDamage: damage };
});
const normalized = { version: 1, runId, dungeonId, durationMs: input.durationMs,
completed: input.completed,
events, droppedEventCount: input.droppedEventCount, kills, packs,
droppedKillCount: input.droppedKillCount, party, partyDamage };
if (Buffer.byteLength(JSON.stringify(normalized)) > MAX_RACE_COMBAT_LOG_BYTES) invalid();
return normalized;
}
+16 -2
View File
@@ -1,5 +1,6 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import bots from "../src/game/dungeonRaceBotCatalog.json" with { type: "json" };
import affixes from "../src/game/dungeonRaceAffixes.json" with { type: "json" };
export const RACE_QUEUE_WAIT_MS = 5_000;
export const RACE_PREPARE_MS = 5 * 60_000;
@@ -31,6 +32,19 @@ export function hashRaceSeed(value) {
return hash >>> 0;
}
export function rollRaceAffixes(seed) {
return [
affixes.kill[hashRaceSeed(`${seed}:kill-affix`) % affixes.kill.length].id,
affixes.movement[hashRaceSeed(`${seed}:movement-affix`) % affixes.movement.length].id,
];
}
export function raceAffixPace(pair = []) {
return pair.reduce((factor, id) => factor * (
[...affixes.kill, ...affixes.movement].find((entry) => entry.id === id)?.botMultiplier ?? 1
), 1);
}
export function botTierForRecord(wins, losses, seed) {
const total = wins + losses;
const rate = total < 5 ? 0.5 : wins / total;
@@ -39,11 +53,11 @@ export function botTierForRecord(wins, losses, seed) {
return bots.tiers[Math.max(0, Math.min(3, base + (roll === 0 ? -1 : roll === 3 ? 1 : 0)))].id;
}
export function botOpponent(seed, tierId, source = null) {
export function botOpponent(seed, tierId, source = null, affixIds = []) {
const tier = bots.tiers.find((entry) => entry.id === tierId) ?? bots.tiers[1];
const resolvedSource = source ?? (hashRaceSeed(`${seed}:source`) % 2 ? "rom" : "wow");
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
const finishMs = Math.round(bots.baselineMs * tier.multiplier * variance);
const finishMs = Math.round(bots.baselineMs * tier.multiplier * variance * raceAffixPace(affixIds));
return { type: "bot", name: `${tier.label} ${resolvedSource === "rom" ? "RuneWaker" : "Azeroth"} Team`,
tier: tier.id, source: resolvedSource, roster: bots.teams[resolvedSource], finishMs,
milestones: bots.milestones.map((entry) => ({ ...entry, atMs: Math.round(finishMs * entry.at) })) };
+9 -8
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { GameDatabaseError } from "./database.mjs";
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons,
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons, rollRaceAffixes,
RACE_COUNTDOWN_MS, RACE_DISCONNECT_MS, RACE_MAX_MS, RACE_PREPARE_MS, RACE_QUEUE_WAIT_MS } from "./dungeon-race-rules.mjs";
export function createDungeonRaceService({ database, now = () => Date.now(), autoPump = true } = {}) {
@@ -30,7 +30,7 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
const elapsedMs = match.startAt ? Math.max(0, timestamp - match.startAt) : 0;
return { status: match.status === "active" && timestamp < match.startAt ? "countdown" : match.status,
serverTime: timestamp, matchId: match.id, seed: match.seed, dungeonId: match.dungeonId,
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty,
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty, affixIds: match.affixIds,
prepareDeadline: match.createdAt + RACE_PREPARE_MS, startAt: match.startAt,
ready: self?.readyAt != null, opponent: match.bot ?? (rival ? { type: "human", name: rival.username,
source: rival.source, ready: rival.readyAt != null, finished: rival.clearAt != null } : null),
@@ -47,19 +47,20 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
const updated = database.settleDungeonRace(match.id, winnerSide, timestamp);
if (updated) matches.set(match.id, updated);
}
function makeBot(seed, accountId) {
function makeBot(seed, accountId, affixIds) {
const { wins, losses } = database.dungeonRaceRecordForAccount(accountId, now());
return botOpponent(seed, botTierForRecord(wins, losses, seed));
return botOpponent(seed, botTierForRecord(wins, losses, seed), null, affixIds);
}
function createMatch(left, right, choices, timestamp) {
const id = `race-${randomUUID()}`;
const dungeon = choices[hashRaceSeed(id) % choices.length];
const bot = right ? null : makeBot(id, left.accountId);
const affixIds = rollRaceAffixes(id);
const bot = right ? null : makeBot(id, left.accountId, affixIds);
const participants = [left, ...(right ? [right] : [{ side: 1, accountId: null,
characterId: null, username: bot.name, source: bot.source, role: null }])]
.map((entry, side) => ({ side, accountId: entry.accountId, characterId: entry.characterId,
username: entry.username, source: entry.source, role: entry.role }));
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id,
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id, affixIds,
difficultyId: dungeon.defaultDifficultyId, createdAt: timestamp, participants, bot });
matches.set(id, match);
for (const participant of participants) if (participant.accountId) {
@@ -74,7 +75,7 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
if (leaving?.accountId) { accountMatch.delete(leaving.accountId); lastSeen.delete(leaving.accountId); }
const surviving = match.participants.find((entry) => entry.side !== side && entry.accountId);
if (!surviving) { matches.set(match.id, database.voidDungeonRace(match.id, timestamp)); return; }
const bot = makeBot(match.seed, surviving.accountId);
const bot = makeBot(match.seed, surviving.accountId, match.affixIds);
const updated = database.replaceDungeonRaceWithBot(match.id, side, bot);
matches.set(match.id, updated);
if (updated.participants.find((entry) => entry.accountId === surviving.accountId)?.readyAt != null) {
@@ -163,7 +164,7 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
if (input?.runId !== match.seed)
throw new GameDatabaseError("This clear belongs to a different race run.", 400, "race_run_mismatch");
pump();
const updated = database.completeDungeonRaceRun(id, account.id, input.bossIds, now());
const updated = database.completeDungeonRaceRun(id, account.id, input.bossIds, now(), input.combatLog ?? null);
matches.set(id, updated);
if (updated.status === "active") settle(updated, updated.participants.find((entry) => entry.accountId === account.id).side, now());
return publicState(account.id);
+76 -1
View File
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import { openGameDatabase } from "./database.mjs";
import { createDungeonRaceService } from "./dungeon-race-service.mjs";
import { botOpponent, botProgress, botTierForRecord, hashRaceSeed } from "./dungeon-race-rules.mjs";
import { botOpponent, botProgress, botTierForRecord, hashRaceSeed, raceAffixPace, rollRaceAffixes } from "./dungeon-race-rules.mjs";
import { createGameServer } from "./server.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { DatabaseSync } from "node:sqlite";
@@ -20,6 +20,19 @@ const bossIdsFor = (match) => {
? `${match.seed}:boss` : match.seed;
return [ids[hashRaceSeed(seed) % ids.length]];
};
const combatLogFor = (match, completed) => ({
version: 1, runId: match.seed, dungeonId: match.dungeonId, durationMs: 1_000, completed,
events: [{ atMs: 100, kind: "damage", source: "player", target: "mob-1",
ability: "test-aoe", raw: 100, effective: 90, overheal: 0, absorbed: 10, critical: false }],
droppedEventCount: 0,
kills: [{ id: "mob-1", name: "Mob One", boss: false, maxHealth: 90,
engagedAtMs: 100, firstDamageAtMs: 100, diedAtMs: 500, durationMs: 400 }],
packs: [{ startedAtMs: 100, endedAtMs: 500, durationMs: 400, mobIds: ["mob-1"],
partyDamage: [{ actorId: "player", damage: 90 }] }],
droppedKillCount: 0,
party: [{ actorId: "player", label: "Player", classId: "priest", level: 60, role: "healer" }],
partyDamage: [{ actorId: "player", damage: 90 }],
});
test("NPC tiers follow rolling records with seeded adjacent variation and milestones", () => {
for (const seed of ["race-tier-a", "race-tier-b", "race-tier-c", "race-tier-d"]) {
@@ -34,6 +47,17 @@ test("NPC tiers follow rolling records with seeded adjacent variation and milest
}
});
test("a shared seed rolls one stable pair and scales an NPC's entire progress model", () => {
const seed = "race-affix-parity";
const pair = rollRaceAffixes(seed);
assert.deepEqual(pair, rollRaceAffixes(seed));
assert.equal(pair.length, 2);
const plain = botOpponent(seed, "regular", "wow");
const tuned = botOpponent(seed, "regular", "wow", pair);
assert.equal(tuned.finishMs, Math.round(plain.finishMs * raceAffixPace(pair)));
assert.equal(tuned.milestones.at(-1).atMs, tuned.finishMs);
});
test("migrates existing race matches onto their seeded boss boards", () => {
const database = new DatabaseSync(":memory:");
try {
@@ -58,6 +82,7 @@ test("migrates existing race matches onto their seeded boss boards", () => {
const seed = `race-old-${dungeonId}`;
assert.equal(database.prepare("SELECT boss_id FROM dungeon_race_matches WHERE id = ?").get(seed).boss_id,
bossIdsFor({ dungeonId, difficultyId: dungeon.defaultDifficultyId, seed })[0]);
assert.equal(database.prepare("SELECT affix_ids_json FROM dungeon_race_matches WHERE id = ?").get(seed).affix_ids_json, "[]");
}
} finally { database.close(); }
});
@@ -87,6 +112,8 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
dungeonId: dungeon.id });
assert.equal(paired.status, "preparing");
assert.equal(service.state(wow).seed, paired.seed);
assert.deepEqual(service.state(wow).affixIds, paired.affixIds);
assert.deepEqual(paired.affixIds, rollRaceAffixes(paired.seed));
assert.equal(paired.opponent.source, "wow");
service.ready(wow, paired.matchId);
assert.equal(service.ready(rom, paired.matchId).status, "countdown");
@@ -104,6 +131,8 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
assert.equal(database.listDungeonRaceLeaderboard({ view: "wins", period: "30d" }, time()).entries[0].username, "race_wow");
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).total, 2);
assert.deepEqual(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).entries[0].affixIds, paired.affixIds);
const otherBossId = dungeon.difficulties[0].finalBossIds.find((id) => id !== bossIds[0]);
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: otherBossId }, time()).total, 0);
@@ -112,6 +141,34 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
assert.equal(service.finish(rom, paired.matchId, { runId: paired.seed, bossIds }).clearDurationMs, late.clearDurationMs);
}));
test("records a partial race log, upgrades it on clear, and scopes review to its participant", async () => fixture(async ({ database, service, player, advance, time }) => {
const owner = await player("race_log_owner");
const stranger = await player("race_log_stranger");
service.queue(owner, { characterId: "race_log_owner-character", role: "healer", pool: "authored",
dungeonId: dungeon.id });
advance(5_000);
const match = service.state(owner);
service.ready(owner, match.matchId);
advance(3_000);
const partial = combatLogFor(match, false);
assert.deepEqual(database.savePartialDungeonRaceCombatLog(match.matchId, owner.id, partial, time()),
{ ok: true, matchId: match.matchId, completed: false });
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).completed, false);
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).log.packs[0].durationMs, 400);
assert.throws(() => database.getDungeonRaceCombatLog(stranger, match.matchId),
{ code: "race_combat_log_not_found" });
assert.throws(() => database.listDungeonRaceCombatLogs(owner), { code: "gm_required" });
assert.throws(() => database.savePartialDungeonRaceCombatLog(match.matchId, stranger.id,
partial, time()), { code: "race_not_found" });
assert.throws(() => service.finish(owner, match.matchId, { runId: match.seed,
bossIds: bossIdsFor(match), combatLog: { ...combatLogFor(match, true), events: [{}] } }),
{ code: "invalid_race_combat_log" });
service.finish(owner, match.matchId, { runId: match.seed, bossIds: bossIdsFor(match),
combatLog: combatLogFor(match, true) });
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).completed, true);
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).log.events[0].ability, "test-aoe");
}));
test("both randomized creature pools validate and rank their cross-dungeon final boss", async () => fixture(async ({ database, service, player, advance, time }) => {
for (const [index, dungeonId] of ["randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available"].entries()) {
@@ -224,6 +281,24 @@ test("production HTTP routes expose ranked queue and public boards without shelv
assert.equal((await fetch(`${base}/api/dungeon-races/state`, { headers })).status, 200);
assert.equal((await fetch(`${base}/api/dungeon-races/leaderboard?period=30d`)).status, 200);
assert.equal((await fetch(`${base}/api/dungeon-races/queue`, { method: "DELETE", headers })).status, 200);
const matchId = "race-http-combat-log";
runtime.database.createDungeonRaceMatch({ id: matchId, dungeonId: dungeon.id, seed: matchId,
difficultyId: dungeon.defaultDifficultyId, createdAt: Date.now() - 2_000,
participants: [{ side: 0, accountId: auth.account.id, characterId: "http-character",
username: auth.account.username, source: "wow", role: "healer" },
{ side: 1, accountId: null, characterId: null, username: "Bot", source: "wow", role: null }],
bot: null });
runtime.database.startDungeonRace(matchId, Date.now() - 1_000);
const logResponse = await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`, {
method: "POST", headers, body: JSON.stringify({ combatLog: combatLogFor({
seed: matchId, dungeonId: dungeon.id }, false) }),
});
assert.equal(logResponse.status, 200);
const review = await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`, { headers });
assert.equal(review.status, 200);
assert.equal((await review.json()).log.kills[0].durationMs, 400);
assert.equal((await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`)).status, 401);
assert.equal((await fetch(`${base}/api/gm/dungeon-races/combat-logs`, { headers })).status, 403);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(dataDir, { recursive: true, force: true });
+90 -7
View File
@@ -1,5 +1,6 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
import { normalizeRaceCombatLog } from "./dungeon-race-combat-log.mjs";
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
@@ -10,7 +11,7 @@ function raceBossId(dungeonId, difficultyId, seed) {
return finalIds?.length ? finalIds[hashRaceSeed(objectiveSeed) % finalIds.length] : null;
}
export function createDungeonRaceRepository(database, { ErrorType }) {
export function createDungeonRaceRepository(database, { ErrorType, isGameMaster = () => false }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, seed TEXT NOT NULL,
@@ -32,10 +33,26 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
ON dungeon_race_matches(status, settled_at, dungeon_id);
CREATE INDEX IF NOT EXISTS dungeon_race_account_idx
ON dungeon_race_participants(account_id, outcome, clear_at);
CREATE TABLE IF NOT EXISTS dungeon_race_combat_logs (
match_id TEXT NOT NULL REFERENCES dungeon_race_matches(id) ON DELETE CASCADE,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
received_at INTEGER NOT NULL,
event_count INTEGER NOT NULL,
kill_count INTEGER NOT NULL,
dropped_event_count INTEGER NOT NULL,
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
payload_json TEXT NOT NULL,
PRIMARY KEY (match_id, account_id)
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_race_combat_log_recent_idx
ON dungeon_race_combat_logs(received_at DESC);
`);
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((column) => column.name === "boss_id")) {
database.exec("ALTER TABLE dungeon_race_matches ADD COLUMN boss_id TEXT");
}
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((column) => column.name === "affix_ids_json")) {
database.exec("ALTER TABLE dungeon_race_matches ADD COLUMN affix_ids_json TEXT NOT NULL DEFAULT '[]'");
}
const missingBosses = database.prepare("SELECT id, dungeon_id, difficulty_id, seed FROM dungeon_race_matches WHERE boss_id IS NULL").all();
const updateBoss = database.prepare("UPDATE dungeon_race_matches SET boss_id = ? WHERE id = ?");
for (const match of missingBosses) {
@@ -49,8 +66,8 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
const matchById = database.prepare("SELECT * FROM dungeon_race_matches WHERE id = ?");
const participantsById = database.prepare("SELECT * FROM dungeon_race_participants WHERE match_id = ? ORDER BY side");
const insertMatch = database.prepare(`INSERT INTO dungeon_race_matches
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id)
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?, ?)`);
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id, affix_ids_json)
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?, ?, ?)`);
const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants
(match_id, side, account_id, character_id, username, source, role)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
@@ -69,6 +86,14 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
const updateVoid = database.prepare(`UPDATE dungeon_race_matches
SET status = 'void', settled_at = ? WHERE id = ? AND status IN ('preparing', 'active')`);
const updateOutcome = database.prepare(`UPDATE dungeon_race_participants SET outcome = ? WHERE match_id = ? AND side = ?`);
const insertCombatLog = database.prepare(`INSERT INTO dungeon_race_combat_logs
(match_id, account_id, received_at, event_count, kill_count, dropped_event_count, completed, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(match_id, account_id) DO UPDATE SET received_at = excluded.received_at,
event_count = excluded.event_count, kill_count = excluded.kill_count,
dropped_event_count = excluded.dropped_event_count, completed = excluded.completed,
payload_json = excluded.payload_json
WHERE dungeon_race_combat_logs.completed = 0`);
function transaction(action) {
database.exec("BEGIN IMMEDIATE");
@@ -83,6 +108,7 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
status: match.status, createdAt: match.created_at, startAt: match.start_at,
settledAt: match.settled_at, winnerSide: match.winner_side,
bot: match.bot_json ? JSON.parse(match.bot_json) : null,
affixIds: JSON.parse(match.affix_ids_json ?? "[]"),
participants: participantsById.all(id).map((row) => ({ side: row.side, accountId: row.account_id,
characterId: row.character_id, username: row.username, source: row.source, role: row.role,
readyAt: row.ready_at, clearAt: row.clear_at, durationMs: row.duration_ms, outcome: row.outcome })) };
@@ -90,7 +116,8 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
function createMatch(match) {
transaction(() => {
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed));
match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed),
JSON.stringify(match.affixIds ?? []));
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
});
@@ -105,7 +132,7 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
return getMatch(id);
});
}
function completeRaceRun(id, accountId, bossIds, timestamp) {
function completeRaceRun(id, accountId, bossIds, timestamp, combatLog = null) {
const match = getMatch(id);
if (!match || !match.participants.some((entry) => entry.accountId === accountId))
throw new ErrorType("That race is not yours.", 404, "race_not_found");
@@ -118,11 +145,59 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
|| !bossIds.some((value) => finalIds.includes(value))
|| (selectedBossId && (bossIds.length !== 1 || bossIds[0] !== selectedBossId)))
throw new ErrorType("Defeat the race's final boss before reporting a clear.", 400, "invalid_race_clear");
const normalizedLog = combatLog === null ? null
: normalizeRaceCombatLog(combatLog, { runId: match.seed, dungeonId: match.dungeonId }, ErrorType);
if (normalizedLog && !normalizedLog.completed)
throw new ErrorType("A race clear needs a completed combat log.", 400, "invalid_race_combat_log");
return transaction(() => {
updateClear.run(timestamp, Math.max(1, timestamp - match.startAt), id, accountId);
if (normalizedLog) insertCombatLog.run(id, accountId, timestamp,
normalizedLog.events.length, normalizedLog.kills.length,
normalizedLog.droppedEventCount, 1, JSON.stringify(normalizedLog));
return getMatch(id);
});
}
function savePartialCombatLog(id, accountId, combatLog, timestamp) {
const match = getMatch(id);
const participant = match?.participants.find((entry) => entry.accountId === accountId);
if (!participant) throw new ErrorType("That race is not yours.", 404, "race_not_found");
if (!match.startAt || timestamp < match.startAt || timestamp - match.startAt > 24 * 60 * 60_000)
throw new ErrorType("This race is not accepting combat logs.", 409, "race_combat_log_unavailable");
const normalized = normalizeRaceCombatLog(combatLog,
{ runId: match.seed, dungeonId: match.dungeonId }, ErrorType);
if (normalized.completed)
throw new ErrorType("Report a clear to submit a complete combat log.", 400, "invalid_race_combat_log");
insertCombatLog.run(id, accountId, timestamp, normalized.events.length,
normalized.kills.length, normalized.droppedEventCount, 0, JSON.stringify(normalized));
return { ok: true, matchId: id, completed: false };
}
function getCombatLog(account, id, requestedAccountId = null) {
const ownerId = isGameMaster(account) && requestedAccountId ? requestedAccountId : account.id;
const row = database.prepare(`SELECT log.*, match.dungeon_id, match.boss_id,
account.username FROM dungeon_race_combat_logs log
JOIN dungeon_race_matches match ON match.id = log.match_id
JOIN accounts account ON account.id = log.account_id
WHERE log.match_id = ? AND log.account_id = ?`).get(id, ownerId);
if (!row) throw new ErrorType("That race combat log was not found.", 404, "race_combat_log_not_found");
return { matchId: row.match_id, accountId: row.account_id, username: row.username,
dungeonId: row.dungeon_id, bossId: row.boss_id, receivedAt: row.received_at,
completed: Boolean(row.completed), log: JSON.parse(row.payload_json) };
}
function listCombatLogs(account, limit = 20) {
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
throw new ErrorType("Choose a valid combat log limit.", 400, "invalid_race_combat_log_limit");
return database.prepare(`SELECT log.match_id AS matchId, log.account_id AS accountId,
account.username, match.dungeon_id AS dungeonId, match.boss_id AS bossId,
log.received_at AS receivedAt, log.event_count AS eventCount,
log.kill_count AS killCount, log.dropped_event_count AS droppedEventCount,
log.completed AS completed
FROM dungeon_race_combat_logs log
JOIN dungeon_race_matches match ON match.id = log.match_id
JOIN accounts account ON account.id = log.account_id
ORDER BY log.received_at DESC LIMIT ?`).all(limit)
.map((row) => ({ ...row, completed: Boolean(row.completed) }));
}
function replaceWithBot(id, side, bot) {
transaction(() => {
deleteParticipant.run(id, side);
@@ -172,18 +247,26 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
const all = database.prepare(query).all(...args);
let lastValue = null;
let rank = 0;
const bestAffixes = view === "times" ? database.prepare(`SELECT m.affix_ids_json AS affixIdsJson
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
WHERE p.account_id = ? AND m.dungeon_id = ? AND m.boss_id = ?
AND p.duration_ms = ? AND p.clear_at >= ?
ORDER BY p.clear_at ASC LIMIT 1`) : null;
const ranked = all.map((row, index) => {
const value = view === "wins" ? Number(row.wins) : Number(row.durationMs);
if (value !== lastValue) rank = index + 1;
lastValue = value;
return { ...row, rank, ...(view === "wins"
? { wins: Number(row.wins), losses: Number(row.losses) }
: { durationMs: Number(row.durationMs) }) };
: { durationMs: Number(row.durationMs), affixIds: JSON.parse(bestAffixes.get(
row.accountId, dungeonId, selectedBossId, row.durationMs, since)?.affixIdsJson ?? "[]") }) };
});
return { view, period, dungeonId, bossId: selectedBossId,
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
}
return { getMatch, createMatch, ready: (id, accountId, at) => (updateReady.run(at, id, accountId), getMatch(id)),
start: (id, at) => (updateStart.run(at, id), getMatch(id)), settleMatch, completeRaceRun,
replaceWithBot, void: (id, at) => (updateVoid.run(at, id), getMatch(id)), recordForAccount, leaderboard };
savePartialCombatLog,
getCombatLog, listCombatLogs, replaceWithBot,
void: (id, at) => (updateVoid.run(at, id), getMatch(id)), recordForAccount, leaderboard };
}
+22 -1
View File
@@ -331,12 +331,33 @@ export function createGameServer(options = {}) {
json(response, 200, dungeonRaceService.state(account));
return;
}
if (url.pathname === "/api/gm/dungeon-races/combat-logs" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, { entries: gameDatabase.listDungeonRaceCombatLogs(account,
Number(url.searchParams.get("limit") ?? 20)) });
return;
}
const raceCombatLogMatch = /^\/api\/dungeon-races\/(race-[A-Za-z0-9-]+)\/combat-log$/.exec(url.pathname);
if (raceCombatLogMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
const requestedAccountId = url.searchParams.get("accountId");
json(response, 200, gameDatabase.getDungeonRaceCombatLog(account, raceCombatLogMatch[1],
requestedAccountId));
return;
}
if (raceCombatLogMatch && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 4 * 1024 * 1024);
json(response, 200, gameDatabase.savePartialDungeonRaceCombatLog(raceCombatLogMatch[1],
account.id, body?.combatLog, Date.now()));
return;
}
const dungeonRaceMatch = /^\/api\/dungeon-races\/(race-[A-Za-z0-9-]+)\/(ready|finish|forfeit)$/.exec(url.pathname);
if (dungeonRaceMatch && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const [, id, action] = dungeonRaceMatch;
json(response, 200, action === "ready" ? dungeonRaceService.ready(account, id)
: action === "finish" ? dungeonRaceService.finish(account, id, await readJson(request, 4 * 1024))
: action === "finish" ? dungeonRaceService.finish(account, id, await readJson(request, 4 * 1024 * 1024))
: dungeonRaceService.leave(account, id));
return;
}
+7 -3
View File
@@ -1,4 +1,5 @@
import catalog from "../game/dungeonRaceBotCatalog.json";
import { dungeonRaceAffixPace, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
export type RaceBotTier = "novice" | "regular" | "veteran" | "elite";
export type RaceBotSource = "wow" | "rom";
@@ -18,14 +19,17 @@ export function hashRaceSeed(value: string): number {
return hash >>> 0;
}
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource): RaceBot {
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource,
affixIds?: DungeonRaceAffixPair): RaceBot {
const tier = catalog.tiers.find((entry) => entry.id === tierId) ?? catalog.tiers[1];
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
const pace = affixIds ? dungeonRaceAffixPace(affixIds) : 1;
const finishMs = Math.round(catalog.baselineMs * tier.multiplier * variance * pace);
return { type: "bot", name: `${tier.label} ${source === "rom" ? "RuneWaker" : "Azeroth"} Team`,
tier: tier.id as RaceBotTier, source, roster: catalog.teams[source],
finishMs: Math.round(catalog.baselineMs * tier.multiplier * variance),
finishMs,
milestones: catalog.milestones.map((entry) => ({ ...entry,
atMs: Math.round(catalog.baselineMs * tier.multiplier * variance * entry.at) })) };
atMs: Math.round(finishMs * entry.at) })) };
}
export function raceBotProgress(elapsedMs: number, bot: RaceBot): number {
+51
View File
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDungeonRaceStore, type RaceServerState } from "./dungeonRaceStore";
import type { CharacterProfile, PlayerSession } from "./types";
import { appendCombatEvent } from "../game/combatEvents";
import { startRaceCombatLog } from "../game/dungeonRaceCombatLog";
const request = vi.hoisted(() => vi.fn());
vi.mock("./onlineAccountClient", () => ({ requestOnlineJson: request }));
@@ -80,4 +82,53 @@ describe("dungeon race exit", () => {
matchId: null, startAt: null, result: null });
expect(request).not.toHaveBeenCalled();
});
it("sends the full recorded stream with a completed online clear", async () => {
request.mockResolvedValue(queued);
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
const startedAt = Date.now() - 1_000;
useDungeonRaceStore.setState({ mode: "online", status: "active", inGame: true,
matchId: "race-combat-clear", seed: "race-combat-clear",
dungeonId: "bite-sized-stormwind-stockade", startAt: startedAt, serverOffset: 0 });
startRaceCombatLog("race-combat-clear", "bite-sized-stormwind-stockade");
appendCombatEvent([], { occurredAt: startedAt + 100, kind: "damage",
sourceActorId: "player", targetActorId: "mob-1", rawAmount: 20,
effectiveAmount: 20, overheal: 0, absorbed: 0, critical: false }, 1);
await useDungeonRaceStore.getState().reportClear(["boss-1"]);
const finish = request.mock.calls.find(([path]) => path === "/api/dungeon-races/race-combat-clear/finish");
expect(finish).toBeDefined();
const body = JSON.parse(finish![1].body);
expect(body.combatLog).toMatchObject({ completed: true, runId: "race-combat-clear",
events: [{ kind: "damage", effective: 20 }] });
});
it("uploads a partial log before leaving a started online race", async () => {
request.mockResolvedValue(queued);
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
useDungeonRaceStore.setState({ mode: "online", status: "active", inGame: true,
matchId: "race-combat-leave", seed: "race-combat-leave",
dungeonId: "bite-sized-stormwind-stockade", startAt: Date.now() - 1_000, serverOffset: 0 });
startRaceCombatLog("race-combat-leave", "bite-sized-stormwind-stockade");
await useDungeonRaceStore.getState().leave();
const partial = request.mock.calls.find(([path]) => path === "/api/dungeon-races/race-combat-leave/combat-log");
expect(partial).toBeDefined();
expect(JSON.parse(partial![1].body).combatLog.completed).toBe(false);
expect(request.mock.calls.map(([path, init]) => `${init.method}:${path}`)).toEqual([
"POST:/api/dungeon-races/queue",
"POST:/api/dungeon-races/race-combat-leave/combat-log",
"DELETE:/api/dungeon-races/queue",
]);
});
it("rejects an unknown server mechanic pair before entering or readying", async () => {
request.mockImplementation((path: string) => Promise.resolve(path === "/api/dungeon-races/queue"
? { status: "preparing", serverTime: Date.now(), matchId: "future-race",
seed: "future-race", dungeonId: "bite-sized-stormwind-stockade",
affixIds: ["unknown-kill", "falling-stars"] } : queued));
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
await vi.waitFor(() => expect(useDungeonRaceStore.getState().error).toContain("does not support"));
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false, ready: false });
expect(request.mock.calls.map(([path]) => path)).toContain("/api/dungeon-races/queue");
expect(request.mock.calls.some(([, init]) => init.method === "DELETE")).toBe(true);
});
});
+42 -7
View File
@@ -8,6 +8,10 @@ import { dungeonContentIsUsable, installContentPack } from "../content/contentMa
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
import { createRaceBot, raceBotProgress, type RaceBot, type RaceBotSource, type RaceBotTier } from "./dungeonRaceBot";
import type { PartyRole } from "../game/partyRoles";
import { finishRaceCombatLog, startRaceCombatLog, stopRaceCombatLog,
type RaceCombatLog } from "../game/dungeonRaceCombatLog";
import { rollDungeonRaceAffixes, validDungeonRaceAffixes, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
import { cleanupDungeonRaceAffixes } from "../game/dungeonRaceAffixLifecycle";
export type RacePool = "authored" | "randomized";
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
@@ -26,6 +30,7 @@ export interface RaceServerState {
readonly seed?: string;
readonly dungeonId?: string | null;
readonly difficultyId?: string;
readonly affixIds?: readonly string[];
readonly startAt?: number | null;
readonly prepareDeadline?: number;
readonly ready?: boolean;
@@ -42,6 +47,7 @@ interface RaceState {
seed: string | null;
dungeonId: string | null;
difficultyId: string | null;
affixIds: DungeonRaceAffixPair | null;
startAt: number | null;
serverOffset: number;
opponent: RaceOpponent | null;
@@ -77,6 +83,7 @@ let queueRequest: Promise<RaceServerState> | null = null;
let pendingLeave: Promise<void> | null = null;
let pendingCleanupToken: string | null = null;
let raceRevision = 0;
let pendingCombatLog: RaceCombatLog | null = null;
function stopPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = null; }
function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The race could not continue."; }
@@ -99,6 +106,9 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
try {
await prepareContent(dungeonId, seed);
if (raceRevision !== revision || get().seed !== seed || get().status === "void") return;
if (!validDungeonRaceAffixes(get().affixIds)) {
throw new Error("This race uses mechanics your client does not support. Update the game to race.");
}
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
if (raceRevision !== revision || get().seed !== seed) return;
installDungeonShellRuntime();
@@ -106,8 +116,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
});
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
startRaceCombatLog(seed, dungeonId);
set({ inGame: true, preparingContent: false });
} catch (error) {
stopRaceCombatLog();
if (raceRevision === revision) {
set({ error: errorMessage(error), preparingContent: false });
if (get().mode === "online") await get().leave();
@@ -125,8 +137,16 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
&& RACE_STATUS_ORDER[response.status] < RACE_STATUS_ORDER[previous.status]) return;
const result = response.result ?? null;
const offset = response.serverTime - Date.now();
if (response.matchId && !validDungeonRaceAffixes(response.affixIds)) {
const message = "This race uses mechanics your client does not support. Update the game to race.";
void get().leave().finally(() => {
if (get().mode === null) set({ error: message });
});
return;
}
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
affixIds: validDungeonRaceAffixes(response.affixIds) ? response.affixIds : null,
startAt: response.startAt ?? null,
serverOffset: offset, opponent: response.opponent ?? null,
opponentProgress: response.opponentProgress ?? null, result,
@@ -138,7 +158,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
}
return {
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: null,
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "",
queueAt: null, botAt: null,
@@ -187,9 +207,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
set({ error: "No Bite-Sized dungeon is available for practice." }); return;
}
const seed = `practice-${globalThis.crypto.randomUUID()}`;
const affixIds = rollDungeonRaceAffixes(seed);
set({ mode: "practice", status: "preparing", matchId: seed, seed, dungeonId: resolvedDungeon,
difficultyId: null,
opponent: createRaceBot(seed, tier, source) });
difficultyId: null, affixIds,
opponent: createRaceBot(seed, tier, source, affixIds) });
await enterPreparedRun(resolvedDungeon, seed);
},
sync: async () => {
@@ -223,6 +244,8 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
if (finishPromise) return finishPromise;
if (!state.matchId || !state.startAt || ["void", "idle"].includes(state.status)
|| state.clearDurationMs != null) return Promise.resolve();
pendingCombatLog ??= finishRaceCombatLog(state.seed ?? state.matchId,
state.startAt - state.serverOffset, state.mode === "practice");
if (state.mode === "practice") {
const now = Date.now();
const result = state.result ?? (now < state.startAt + (state.opponent as RaceBot).finishMs ? "win" : "loss");
@@ -234,9 +257,9 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
const matchId = state.matchId;
const report = requestOnlineJson<RaceServerState>(
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/finish`, {
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds }),
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds, combatLog: pendingCombatLog }),
}, sessionToken)
.then((response) => { if (get().matchId === matchId) applyServerState(response); })
.then((response) => { if (get().matchId === matchId) { pendingCombatLog = null; applyServerState(response); } })
.catch((error: unknown) => { if (get().matchId === matchId) set({ error: errorMessage(error) }); })
.finally(() => { if (finishPromise === report) finishPromise = null; });
finishPromise = report;
@@ -252,7 +275,11 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
opponentProgress: raceBotProgress(now - state.startAt, state.opponent) });
},
leave: async () => {
const token = get().mode === "online" ? sessionToken : null;
const departing = get();
const token = departing.mode === "online" ? sessionToken : null;
const partialLog = departing.seed && departing.startAt && departing.clearDurationMs === null
? finishRaceCombatLog(departing.seed, departing.startAt - departing.serverOffset,
departing.mode === "practice", false) : null;
const request = queueRequest;
get().reset();
const revision = raceRevision;
@@ -260,6 +287,12 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
pendingCleanupToken = token;
const closing = (async () => {
try { await request; } catch { /* A failed queue request needs no server cleanup. */ }
if (partialLog && departing.matchId) {
try {
await requestOnlineJson(`/api/dungeon-races/${encodeURIComponent(departing.matchId)}/combat-log`,
{ method: "POST", body: JSON.stringify({ combatLog: partialLog }) }, token);
} catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); }
}
await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, token);
if (pendingCleanupToken === token) pendingCleanupToken = null;
})();
@@ -270,8 +303,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
},
reset: () => {
raceRevision += 1;
cleanupDungeonRaceAffixes();
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: null,
stopRaceCombatLog(); pendingCombatLog = null;
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
},
+51
View File
@@ -0,0 +1,51 @@
// Development-only inspection surface for the live combat-summon renderer.
import { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { Canvas } from "@react-three/fiber";
import { Grid, OrbitControls } from "@react-three/drei";
import { preloadAbilityCatalogsForClasses } from "../game/abilityCatalog";
import { useCombatStore } from "../game/combatStore";
import { setMobPosition } from "../game/mobRuntimeRegistry";
import { CombatSummonPopulation } from "../scene/CombatSummonPopulation";
function Preview() {
const [status, setStatus] = useState("Casting Shadowfiend…");
const canvas = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
void preloadAbilityCatalogsForClasses(["priest"]).then(() => {
const combat = useCombatStore.getState();
combat.initializeCharacter({ classId: "priest", level: 80 });
combat.registerMob("shadowfiend-preview-target", {
name: "Target", level: 80, maxHealth: 100_000, xpReward: 0,
});
setMobPosition("shadowfiend-preview-target", [3, 0, 0]);
combat.selectMob("shadowfiend-preview-target");
const result = combat.castAbility("wow335-priest-shadowfiend", [0, 0, 0], 1_000);
setStatus(result.ok ? "Shadowfiend summon active" : `Cast failed: ${result.reason}`);
}).catch((error) => setStatus(`Catalog failed: ${String(error)}`));
}, []);
return <>
<div style={{ padding: 16, display: "flex", alignItems: "center", gap: 16 }}>
<span>{status}</span>
<button onClick={() => {
if (!canvas.current) return;
const link = document.createElement("a");
link.download = "shadowfiend-summon-preview.png";
link.href = canvas.current.toDataURL("image/png");
link.click();
}}>Save screenshot</button>
</div>
<div style={{ height: "calc(100vh - 55px)" }}>
<Canvas camera={{ position: [4, 3, 5], fov: 50 }} gl={{ preserveDrawingBuffer: true }}
onCreated={({ gl }) => { canvas.current = gl.domElement; }}>
<color attach="background" args={["#0b121b"]} />
<ambientLight intensity={1} />
<Grid args={[20, 20]} cellColor="#304051" sectionColor="#4a637d" />
<CombatSummonPopulation active />
<OrbitControls target={[0, 0.9, 0]} />
</Canvas>
</div>
</>;
}
if (import.meta.env.DEV) createRoot(document.getElementById("root")!).render(<Preview />);
+11 -2
View File
@@ -23,6 +23,8 @@ import {
onlineSessionOwnsWorldSimulation,
} from "./onlineSessionRuntime";
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes,
suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime";
import {
DUNGEON_SIMULATION_FIXED_STEP_MS,
useDungeonSimulationStore,
@@ -66,6 +68,7 @@ export function CombatBridge() {
mountedResetRevision.current = encounterResetRevision;
encounterMobCursor.current = {};
useCombatStore.getState().resetEncounter();
resetDungeonRaceAffixEffects();
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
}, [encounterResetRevision]);
@@ -153,8 +156,12 @@ export function CombatBridge() {
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
for (const event of combatEvents.events) if (!event.entityId.startsWith("race-affix-"))
dispatchActiveDungeonEntityEvent(event);
advanceDungeonRaceAffixes(epochNow, combatEvents.events.filter((event) => (
event.kind === "entity-death" && !event.entityId.startsWith("race-affix-")
)).map((event) => event.entityId));
const registeredEntityIds = Object.keys(mobs).filter((id) => !id.startsWith("race-affix-"));
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
now: epochNow,
@@ -205,8 +212,10 @@ export function CombatBridge() {
// Reset the clock on resume so a long pause/background period cannot
// leak into the first simulation delta.
previous = performance.now();
resumeDungeonRaceAffixes();
timer = window.setInterval(advance, 100);
} else if (!shouldRun && timer !== null) {
suspendDungeonRaceAffixes();
window.clearInterval(timer);
timer = null;
}
+121 -11
View File
@@ -5,7 +5,7 @@ import { subscribeCombatEvents } from "../combatEvents";
import { resetCombatSpatialProvider } from "../combatSpatial";
import { useCombatStore } from "../combatStore";
import { dungeonEnemyScaling } from "../dungeonBossScaling";
import { requireDungeonDefinition } from "../dungeonRegistry";
import { prepareDungeonSessionDefinition, requireDungeonDefinition } from "../dungeonRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "../mobRuntimeRegistry";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "../partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "../partyRuntimeRegistry";
@@ -23,10 +23,16 @@ const scenario: BenchmarkScenarioDefinition = {
/** Uses the live boss stats, companion AI and baseline player policy with a controlled clock. */
export async function runDungeonBossBenchmark(
build: BenchmarkBuildDefinition, bossId: string, seed: string, mechanics = false,
options: { readonly dungeonId?: string; readonly durationMs?: number; readonly partySeed?: string } = {},
) {
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
const dungeon = requireDungeonDefinition("ragefire-chasm");
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
const dungeon = options.dungeonId
? prepareDungeonSessionDefinition(options.dungeonId, seed)
: requireDungeonDefinition("ragefire-chasm");
if (!dungeon) throw new Error(`Unknown dungeon: ${options.dungeonId}`);
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((entry) => entry.id === bossId);
if (!spawn) throw new Error(`Unknown boss ${bossId} in ${dungeon.id}.`);
const entity = dungeon.entities[spawn.entityId];
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source);
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
@@ -38,12 +44,14 @@ export async function runDungeonBossBenchmark(
let selfHealing = 0; let allyHealing = 0;
const healingByAbility: Record<string, number> = {};
const damageByAbility: Record<string, number> = {};
const damageByActor: Record<string, number> = {};
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === bossId) {
if (event.sourceActorId === PLAYER_AGGRO_ID) playerDamage += event.effectiveAmount;
else partyDamage += event.effectiveAmount;
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}`;
damageByAbility[key] = (damageByAbility[key] ?? 0) + event.effectiveAmount;
damageByActor[event.sourceActorId] = (damageByActor[event.sourceActorId] ?? 0) + event.effectiveAmount;
}
if (event.kind === "damage" && event.sourceActorId === bossId) incomingDamage += event.effectiveAmount;
if (event.kind === "healing") {
@@ -60,7 +68,8 @@ export async function runDungeonBossBenchmark(
specializationId: specialization?.id, role: build.role, level: build.level,
talentRanks: build.talentRanks, inventory: build.inventory, equipment: build.equipment });
combat.setPlayerPosition([-2, 0, 0]);
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
usePartyStore.getState().initializeParty(options.partySeed ?? seed, build.level,
useCombatStore.getState().maxHealth, build.role, 5, build.source);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs(now);
combat.applyPrecombatPreparation(now);
@@ -77,31 +86,132 @@ export async function runDungeonBossBenchmark(
actorId: build.role === "tank" ? PLAYER_AGGRO_ID : tank!.id, role: "tank", abilityId: "benchmark-pull",
}, "physical");
const controller = new BenchmarkController();
for (let elapsed = 100; elapsed <= scenario.durationMs; elapsed += 100) {
const durationMs = options.durationMs ?? scenario.durationMs;
const activeScenario = durationMs === scenario.durationMs ? scenario : { ...scenario, durationMs };
for (let elapsed = 100; elapsed <= durationMs; elapsed += 100) {
now = 1_000_000 + elapsed;
combat.tick(.1, now);
advanceDungeonPartyCombat(now);
if (mechanics) combat.advanceMobCombat(now);
if (controller.act(now, build.role, scenario, false).cast) casts += 1;
if (controller.act(now, build.role, activeScenario, false).cast) casts += 1;
const state = useCombatStore.getState();
const mob = state.mobs[bossId];
if (mob.dead || (mechanics && state.health <= 0)) {
return { seconds: mob.dead ? ((mob.diedAt ?? now) - 1_000_000) / 1000 : null,
elapsedSeconds: elapsed / 1000, outcome: mob.dead ? "defeated" : "player-death",
maxHealth: scaling.maxHealth, playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
maxHealth: scaling.maxHealth, playerDamage, partyDamage, damageByActor, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
playerAlive: state.health > 0, deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
attacks: mob.attackRevision, specialization: specialization?.displayName,
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`),
partyActorIds: usePartyStore.getState().members.map((member) => member.id) };
}
}
return { seconds: null, elapsedSeconds: scenario.durationMs / 1000, outcome: "timeout",
return { seconds: null, elapsedSeconds: durationMs / 1000, outcome: "timeout",
maxHealth: scaling.maxHealth, remainingHealth: useCombatStore.getState().mobs[bossId].health,
playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
playerDamage, partyDamage, damageByActor, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
playerAlive: useCombatStore.getState().health > 0,
deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
attacks: useCombatStore.getState().mobs[bossId].attackRevision,
specialization: specialization?.displayName,
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`),
partyActorIds: usePartyStore.getState().members.map((member) => member.id) };
} finally {
stop(); restoreClock(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry();
resetPartyRuntimeClock(); resetCombatSpatialProvider();
}
}
/** Measures a compact pull using the route's real RoM trash stats and the live five-seat combat runtime. */
export async function runBiteSizedTrashBenchmark(
build: BenchmarkBuildDefinition, dungeonId: string, seed: string,
options: { readonly durationMs?: number; readonly partySeed?: string; readonly maxMobs?: number } = {},
) {
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
const dungeon = prepareDungeonSessionDefinition(dungeonId, seed);
if (!dungeon) throw new Error(`Unknown dungeon: ${dungeonId}`);
const spawns = dungeon.staticSpawns.filter((spawn) => dungeon.entities[spawn.entityId]?.kind !== "boss")
.slice(0, options.maxMobs ?? 6);
if (spawns.length < 2) throw new Error(`${dungeonId} has fewer than two trash mobs.`);
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
let now = 1_000_000;
const restoreClock = installCombatClock(() => now);
const damageByActor: Record<string, number> = {};
const damageByAbility: Record<string, number> = {};
const mobIds = new Set(spawns.map((spawn) => spawn.id));
const stop = subscribeCombatEvents((event) => {
if (event.kind !== "damage" || !mobIds.has(event.targetActorId)) return;
damageByActor[event.sourceActorId] = (damageByActor[event.sourceActorId] ?? 0) + event.effectiveAmount;
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}`;
damageByAbility[key] = (damageByAbility[key] ?? 0) + event.effectiveAmount;
});
try {
const combat = useCombatStore.getState();
combat.initializeCharacter({ classId: build.classId, raceId: build.raceId,
secondaryClassId: build.secondaryClassId, specializationId: specialization?.id,
role: build.role, level: build.level, talentRanks: build.talentRanks,
inventory: build.inventory, equipment: build.equipment });
combat.setPlayerPosition([-2, 0, 0]);
usePartyStore.getState().initializeParty(options.partySeed ?? seed, build.level,
useCombatStore.getState().maxHealth, build.role, 5, build.source);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs(now);
combat.applyPrecombatPreparation(now);
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
let totalHealth = 0;
for (const [index, spawn] of spawns.entries()) {
const entity = dungeon.entities[spawn.entityId];
const scaling = dungeonEnemyScaling(dungeon, entity, spawn.id, build.level, "normal", 1, build.source);
totalHealth += scaling.maxHealth;
combat.registerMob(spawn.id, { name: entity.name, serverEntry: entity.combat?.serverEntry,
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
const angle = index / spawns.length * Math.PI * 2;
setMobPosition(spawn.id, [Math.cos(angle) * 1.5, 0, Math.sin(angle) * 1.5]);
}
for (const member of usePartyStore.getState().members) {
const preferred = partyCombatRangeBand(member).preferred;
registerPartyRuntimePosition(member.id, [preferred <= 6 ? -1 : -preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, spawns[0].id, true);
}
combat.selectMob(spawns[0].id);
for (const spawn of spawns) combat.damageMob(spawn.id, 1, now, {
actorId: build.role === "tank" ? PLAYER_AGGRO_ID : tank!.id,
role: "tank", abilityId: "benchmark-pull",
}, "physical");
const controller = new BenchmarkController();
const durationMs = options.durationMs ?? 120_000;
const areaScenario: BenchmarkScenarioDefinition = { ...scenario, id: "bite-sized-trash",
hostileTargets: spawns.length, durationMs };
for (let elapsed = 100; elapsed <= durationMs; elapsed += 100) {
now = 1_000_000 + elapsed;
combat.tick(.1, now);
// The scene normally publishes one current line-of-sight target per
// companion. Mirror that update when a benchmark has several mobs.
const currentParty = usePartyStore.getState();
for (const member of currentParty.members) {
const targetId = currentParty.memberTargetIds[member.id]
?? useCombatStore.getState().selectedTargetId;
if (targetId) updatePartyRuntimeTargetVisibility(member.id, targetId, true);
}
advanceDungeonPartyCombat(now);
const active = spawns.find((spawn) => !useCombatStore.getState().mobs[spawn.id]?.dead);
if (!active) {
const members = usePartyStore.getState().members;
return { seconds: elapsed / 1_000, outcome: "defeated", totalHealth,
mobCount: spawns.length, damageByActor, damageByAbility,
party: members.map((member) => `${member.classId}:${member.specialization}`),
partyActorIds: members.map((member) => member.id) };
}
if (useCombatStore.getState().selectedTargetId !== active.id) combat.selectMob(active.id);
const livingCount = spawns.filter((spawn) => !useCombatStore.getState().mobs[spawn.id]?.dead).length;
controller.act(now, build.role, { ...areaScenario, hostileTargets: livingCount }, false);
}
const members = usePartyStore.getState().members;
return { seconds: durationMs / 1_000, outcome: "timeout", totalHealth,
mobCount: spawns.length, damageByActor, damageByAbility,
party: members.map((member) => `${member.classId}:${member.specialization}`),
partyActorIds: members.map((member) => member.id) };
} finally {
stop(); restoreClock(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry();
resetPartyRuntimeClock(); resetCombatSpatialProvider();
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { prepareDungeonSessionDefinition, requireDungeonDefinition } from "./dungeonRegistry";
describe("Bite-Sized boss durability", () => {
for (const [id, sourceId] of [
["bite-sized-hoto-teeth", "hoto"],
["bite-sized-stormwind-stockade", "stormwind-stockade"],
] as const) {
it(`shortens ${id} without changing ${sourceId} or its trash`, () => {
const route = prepareDungeonSessionDefinition(id, "bite-sized-balance-0")!;
const source = requireDungeonDefinition(sourceId);
const boss = route.bosses[0];
const spawn = route.encounterSpawnGroups?.flatMap((group) => group.spawns)
.find((candidate) => candidate.id === boss.id)
?? route.staticSpawns.find((candidate) => candidate.id === boss.id);
expect(spawn).toBeDefined();
const entity = route.entities[spawn!.entityId];
const short = dungeonEnemyScaling(route, entity, boss.id, 60, "normal", 1, "wow");
const campaign = dungeonEnemyScaling(source, entity, boss.id, 60, "normal", 1, "wow");
expect(short.maxHealth).toBeGreaterThan(25_000);
expect(short.maxHealth).toBeLessThan(50_000);
expect(campaign.maxHealth).toBeGreaterThan(short.maxHealth);
const trash = route.staticSpawns.find((candidate) => route.entities[candidate.entityId]?.kind === "mob")!;
expect(dungeonEnemyScaling(route, route.entities[trash.entityId], trash.id,
60, "normal", 1, "wow")).toEqual(dungeonEnemyScaling(source,
route.entities[trash.entityId], trash.id, 60, "normal", 1, "wow"));
});
}
});
+2
View File
@@ -392,6 +392,8 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
}
return;
}
if (event.abilityId === "wow335-priest-penance" && event.sourceSpellId === 47540
&& (event.phase === "release" || event.phase === "impact")) return;
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
+38
View File
@@ -154,6 +154,44 @@ describe("combat presentation", () => {
}
});
it("uses WoW's channel hand, Penance missile, and distinct heal and damage impacts", () => {
const manifest = JSON.parse(readFileSync(
path.resolve("public/assets/game/combat-effects/manifest.json"), "utf8",
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
const entry = (spellId: number) => manifest.entries.find((candidate) => (
candidate.source === "wow335" && candidate.spellId === spellId
));
const models = (spellId: number, phase: CombatPresentationPhase) =>
originalCombatPhaseAssets(entry(spellId), phase, "wow335-priest-penance").map((asset) => asset.model);
expect(models(47540, "cast-start")).toEqual(["Spells/Holy_Precast_High_Hand.mdx"]);
expect(models(47666, "tick")).toEqual(["spells/priest_pennance_missile.mdx"]);
expect(models(47750, "tick")).toEqual(["spells/priest_pennance_missile.mdx"]);
expect(models(47666, "impact")).toEqual(["Spells/Holy_ImpactDD_Low_Chest.mdx"]);
expect(models(47750, "impact")).toEqual(["Spells/FlashHeal_Base.mdx"]);
expect(models(47540, "impact")).toEqual([]);
for (const spellId of [47540, 47666, 47750]) {
expect(Object.values(entry(spellId)?.phases ?? {}).flat().every((asset) => asset.effectGraphUrl))
.toBe(true);
}
});
it("packages Shadowfiend's creature display as a textured, renderable model", () => {
const manifest = JSON.parse(readFileSync(
path.resolve("public/assets/game/combat-effects/manifest.json"), "utf8",
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
const summon = manifest.entries.find((entry) => entry.key === "wow335:34433")?.phases?.summon?.[0];
expect(summon?.model).toBe("Creature/FleshBeast/FleshBeast.mdx");
expect(summon?.effectKind).toBe("wow-m2-model");
const graph = JSON.parse(readFileSync(path.resolve("public", summon!.effectGraphUrl!.slice(1)), "utf8")) as {
readonly sourceModel: string;
readonly mesh: { readonly indices: readonly number[] };
readonly textures: readonly { readonly url?: string }[];
};
expect(graph.sourceModel).toBe("creature/fleshbeast/fleshbeast.m2");
expect(graph.mesh.indices.length).toBeGreaterThan(0);
expect(graph.textures[0]?.url).toMatch(/\/textures\/.*\.png$/);
});
it("selects source-linked RuneWaker sprite phases for live combat", () => {
const manifest = JSON.parse(readFileSync(
path.resolve("public/assets/game/combat-effects/manifest.json"),
+28 -2
View File
@@ -28,6 +28,21 @@ export type CombatPresentationSchool =
export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "fallback";
export type CombatPresentationPosition = readonly [number, number, number];
export const PENANCE_DAMAGE_VISUAL_SPELL_ID = 47666;
export const PENANCE_HEAL_VISUAL_SPELL_ID = 47750;
export function penancePulseVisualSpellId(hostile: boolean): number {
return hostile ? PENANCE_DAMAGE_VISUAL_SPELL_ID : PENANCE_HEAL_VISUAL_SPELL_ID;
}
export function isPenancePulseVisual(
event: Pick<CombatPresentationEvent, "abilityId" | "sourceSpellId">,
): boolean {
return event.abilityId === "wow335-priest-penance"
&& (event.sourceSpellId === PENANCE_DAMAGE_VISUAL_SPELL_ID
|| event.sourceSpellId === PENANCE_HEAL_VISUAL_SPELL_ID);
}
export interface CombatPresentationSettings {
readonly originalMediaEnabled: boolean;
readonly vfxMode: CombatVfxMode;
@@ -200,7 +215,7 @@ export interface CombatEffectManifestEntry {
readonly source: Exclude<CombatPresentationSource, "fallback">;
readonly spellId: number;
readonly visualIds?: readonly number[];
readonly phases?: Readonly<Partial<Record<CombatPresentationPhase, readonly CombatEffectPhaseAsset[]>>>;
readonly phases?: Readonly<Partial<Record<CombatPresentationPhase | "summon", readonly CombatEffectPhaseAsset[]>>>;
readonly sounds?: readonly CombatEffectSoundAsset[];
}
@@ -249,6 +264,13 @@ export function combatEffectManifestEntry(
return manifestBySpellId.get(spellId) ?? null;
}
/** Client display 19110 for the priest's creature 19668. */
export function shadowfiendOriginalModelAsset(): CombatEffectPhaseAsset | null {
return combatEffectManifestEntry(34433, "wow335")?.phases?.summon?.find((asset) => (
asset.effectKind === "wow-m2-model" && Boolean(asset.effectGraphUrl)
)) ?? null;
}
export function originalCombatPhaseAssets(
entry: CombatEffectManifestEntry | null | undefined,
phase: CombatPresentationPhase,
@@ -256,7 +278,11 @@ export function originalCombatPhaseAssets(
): readonly CombatEffectPhaseAsset[] {
const sourcePhase = abilityId === "wow335-priest-holy-nova" && phase === "impact"
? "cast-start"
: phase;
: abilityId === "wow335-priest-penance" && phase === "cast-start"
? "tick"
: abilityId === "wow335-priest-penance" && phase === "tick"
? "release"
: phase;
if (abilityId === "wow335-priest-holy-nova" && phase !== "impact") return [];
return (entry?.phases?.[sourcePhase] ?? []).filter((asset) => (
typeof asset.effectGraphUrl === "string"
+37 -3
View File
@@ -1879,12 +1879,27 @@ describe("combat store", () => {
resourcePools: { ...state.resourcePools, mana: Math.round(state.maxResource / 2) },
}));
expect(useCombatStore.getState().castAbility("wow335-priest-shadowfiend", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().summons.filter((summon) => summon.sourceAbilityId === "wow335-priest-shadowfiend"))
.toHaveLength(1);
const fiends = useCombatStore.getState().summons.filter((summon) => (
summon.sourceAbilityId === "wow335-priest-shadowfiend"
));
expect(fiends).toHaveLength(1);
expect(fiends[0]?.creatureId).toBe(19668);
expect(fiends[0]?.pulseIntervalMs).toBe(1_500);
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "resource"
&& event.sourceActorId === fiends[0]?.id)).toBe(false);
const manaBeforeAttack = useCombatStore.getState().resource;
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().mobs["fiend-target"].health).toBeLessThan(100_000);
expect(useCombatStore.getState().resource).toBeGreaterThanOrEqual(manaBeforeAttack + Math.floor(useCombatStore.getState().maxResource * 0.01));
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage", sourceActorId: fiends[0]?.id,
targetActorId: "fiend-target", abilityId: "wow335-priest-shadowfiend", school: "shadow" }),
expect.objectContaining({ kind: "resource", sourceActorId: fiends[0]?.id,
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "wow335-priest-shadowfiend",
effectiveAmount: useCombatStore.getState().maxResource * 0.05 }),
]));
expect(useCombatStore.getState().resource).toBeGreaterThanOrEqual(
manaBeforeAttack + Math.floor(useCombatStore.getState().maxResource * 0.05),
);
});
it("casts Kill Shot only below 20 percent health and resolves one direct hit", () => {
@@ -1946,6 +1961,10 @@ describe("combat store", () => {
registerTarget("penance-target", [8, 0, 0], 100_000, 0);
useCombatStore.getState().damagePlayer(500);
const casterHealth = useCombatStore.getState().health;
const visuals: CombatPresentationEvent[] = [];
const stopVisuals = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.abilityId === "wow335-priest-penance") visuals.push(message.event);
});
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: 3,
completesAt: 3_000, nextTickAt: 1_000 });
@@ -1964,6 +1983,12 @@ describe("combat store", () => {
const after = useCombatStore.getState().mobs["penance-target"].health;
useCombatStore.getState().tick(0, 4_000);
expect(useCombatStore.getState().mobs["penance-target"].health).toBe(after);
stopVisuals();
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
["cast-start", 47540],
...Array.from({ length: 3 }, () => [["tick", 47666], ["impact", 47666]]).flat(),
["release", 47540],
]);
});
it("channels Penance healing and stops future pulses when interrupted", () => {
@@ -1972,6 +1997,10 @@ describe("combat store", () => {
usePartyStore.getState().selectSelf();
useCombatStore.getState().damagePlayer(2_000);
const before = useCombatStore.getState().health;
const visuals: CombatPresentationEvent[] = [];
const stopVisuals = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.abilityId === "wow335-priest-penance") visuals.push(message.event);
});
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().tick(0, 1_000);
const first = useCombatStore.getState().health;
@@ -1983,6 +2012,11 @@ describe("combat store", () => {
useCombatStore.getState().tick(0, 3_000);
expect(useCombatStore.getState().health).toBe(interruptedAt);
expect(useCombatStore.getState().activeCast).toBeNull();
stopVisuals();
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
["cast-start", 47540], ["tick", 47750], ["impact", 47750],
["tick", 47750], ["impact", 47750], ["cast-cancel", 47540],
]);
});
it("channels ground-area Volley over its selected point without a target or post-channel damage", () => {
+26 -15
View File
@@ -101,6 +101,7 @@ import {
type CombatPosition,
} from "./mobRuntimeRegistry";
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
import { raceAffixHasteMultiplier, raceAffixMobDamageMultiplier } from "./dungeonRaceAffixBuffs";
import {
getPartyRuntimePosition,
partyRuntimeDistanceSquared,
@@ -251,6 +252,7 @@ import {
} from "./combatRotation";
import {
emitCombatPresentation,
penancePulseVisualSpellId,
presentationDeliveryForAbility,
presentationSchoolForAbility,
presentationSourceForAbility,
@@ -975,17 +977,19 @@ function emitPlayerAbilityPresentation(
targetId: string | null,
targetPosition: CombatPosition | null,
instantCast = false,
penanceHostile = false,
): void {
const delivery = presentationDeliveryForAbility(ability);
const penancePulse = ability.id === "wow335-priest-penance" && phase === "tick";
const delivery = penancePulse ? "projectile" : presentationDeliveryForAbility(ability);
const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null);
emitCombatPresentation({
const event = {
occurredAt,
phase,
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
sourceSpellId: penancePulse ? penancePulseVisualSpellId(penanceHostile) : ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
@@ -995,7 +999,9 @@ function emitPlayerAbilityPresentation(
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
...(instantCast ? { instantCast: true } : {}),
});
} as const;
emitCombatPresentation(event);
if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" });
}
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
@@ -1013,6 +1019,7 @@ function emitPlayerAbilityResolutionPresentation(
instantCast: boolean,
): void {
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition, instantCast);
if (ability.id === "wow335-priest-penance") return;
emitPlayerAbilityPresentation(
ability,
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
@@ -1128,7 +1135,8 @@ function playerAutoAttackProfile(
const weaponDamage = weapon
? (weapon.damageMin + weapon.damageMax) / 2
: playerCombatPower(state.level) * 0.35;
const intervalMs = hastedDuration(speedMs, stats.meleeHastePercent);
const intervalMs = Math.max(500, Math.round(hastedDuration(speedMs, stats.meleeHastePercent)
/ raceAffixHasteMultiplier()));
return {
damage: Math.max(1, Math.round(weaponDamage + stats.attackPower / 14 * speedSeconds)),
intervalMs,
@@ -1827,7 +1835,7 @@ function talentAdjustedCastTime(
duration,
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
);
return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier)));
return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier * raceAffixHasteMultiplier())));
}
function talentAdjustedCooldown(ability: AbilityDefinition, modifiers: readonly TalentModifier[]): number {
@@ -1852,7 +1860,7 @@ function talentAdjustedGcd(
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
1_000,
);
return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier)));
return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier * raceAffixHasteMultiplier())));
}
function talentAdjustedDuration(
@@ -3285,7 +3293,7 @@ function executeTriggeredSpellInWork(
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
position: getMobPosition(targetId ?? "") ?? [0, 0, 0],
nextActionAt: now + 1_000,
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200,
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
...initialSummonCombatRuntime(),
});
@@ -5779,7 +5787,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
position: groundPosition ?? playerOrigin,
nextActionAt: now + 1_000,
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200,
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
...initialSummonCombatRuntime(),
...(/totem/i.test(ability.name) ? { exclusiveGroup: "totem" } : {}),
@@ -7590,7 +7598,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
maxHealth,
position,
nextActionAt: now + 1_000,
pulseIntervalMs: isTotem ? 2_000 : 2_200,
pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : isTotem ? 2_000 : 2_200,
attackDamage: Math.max(1, Math.round(owner.level * 4 * Math.max(0.15, effect.coefficient))),
...initialSummonCombatRuntime(),
...(isTotem ? { exclusiveGroup: `${ownerActorId}:totem` } : {}),
@@ -8685,7 +8693,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.mob,
hit.attack,
defender,
talentAdjustedIncomingDamage(hit.amount, current.talentModifiers),
talentAdjustedIncomingDamage(Math.round(hit.amount * raceAffixMobDamageMultiplier(hit.mob.id, now)), current.talentModifiers),
hit.actorId,
hit.attackRevision,
now,
@@ -8943,7 +8951,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.mob,
hit.attack,
defender,
hit.amount,
Math.round(hit.amount * raceAffixMobDamageMultiplier(hit.mob.id, now)),
hit.actorId,
hit.attackRevision,
now,
@@ -9644,8 +9652,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId,
summon.attackDamage,
now,
{ actorId: summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId },
"physical",
{ actorId: summonAbility?.name === "Shadowfiend" ? summon.id : summon.ownerActorId,
role: "damage", abilityId: summon.sourceAbilityId },
summonAbility?.name === "Shadowfiend" ? "shadow" : "physical",
);
if (summonAbility?.name === "Shadowfiend" && result.amount > 0) {
shadowfiendManaRestores.push({
@@ -9758,7 +9767,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
for (const restore of shadowfiendManaRestores) {
get().restoreActorResource(
restore.ownerActorId, "mana", 1, true, restore.summonId, restore.abilityId, now,
restore.ownerActorId, "mana", 5, true, restore.summonId, restore.abilityId, now,
);
}
@@ -10009,6 +10018,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
get().playerPosition,
cast.targetId,
cast.groundPosition ?? (cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition),
false,
channelHostile,
);
ticksCompleted += 1;
nextTickAt = channelPulseAt(ability, cast.startedAt, cast.originalDurationMs, ticksCompleted);
+22 -2
View File
@@ -5,6 +5,7 @@ import { finalDungeonBoss } from "./dungeonRunStore";
import type { DungeonDefinition } from "./dungeonTypes";
import type { PopulationEntityDefinition } from "./mobPopulation";
import type { ContentCategoryId } from "../app/contentCategories";
import { isBiteSizedDungeonId } from "./dungeonContentSource";
import { resolveBossBalanceOverride } from "./bossBalanceOverrides";
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
@@ -39,6 +40,15 @@ const WOW_BOSS_TEST_DAMAGE_SCALE: readonly (readonly [level: number, scale: numb
];
const BOSS_TEST_HEALTH_FLOOR_MULTIPLIER = 0.6;
const FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER = 1.9;
// A single boss follows one or two short packs. Measured Ready Priest party
// damage changes sharply with unlocks, so use a small bracket curve to keep
// these bosses near the existing 25–45 second mechanic window at levels 1–80.
const BITE_SIZED_BOSS_HEALTH_BANDS: readonly (readonly [number, number, number])[] = [
[1, 2.2, 2.45], [9, 0.95, 1.1], [10, 0.65, 0.8], [20, 0.7, 0.85],
[30, 0.82, 0.94], [40, 0.85, 1], [50, 0.7, 0.82],
[55, 0.43, 0.53], [60, 0.4, 0.5], [65, 0.33, 0.4],
[70, 0.35, 0.43], [80, 0.35, 0.43],
];
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
@@ -81,8 +91,16 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
// Match durability to the character's game, including when crossing dungeon catalogs.
const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing);
const biteSizedBoss = boss && !testing && isBiteSizedDungeonId(dungeon.id);
const biteSizedFloorScale = biteSizedBoss
? interpolatedFloor(characterLevel, BITE_SIZED_BOSS_HEALTH_BANDS.map(([levelAnchor, floor]) => [levelAnchor, floor]))
: 1;
const biteSizedCeilingScale = biteSizedBoss
? interpolatedFloor(characterLevel, BITE_SIZED_BOSS_HEALTH_BANDS.map(([levelAnchor, , ceiling]) => [levelAnchor, ceiling]))
: Number.POSITIVE_INFINITY;
const finaleFloor = finalBoss && dungeon.finalBossDurability !== "standard";
const bossFloor = partyHealthFloor * (finaleFloor ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : 1);
const bossFloor = partyHealthFloor * (finaleFloor ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER
: biteSizedFloorScale);
// A test room's sole objective must not promote every boss to a campaign
// finale. A reduced floor still prevents high-level source creatures with
// tiny extrapolated level-two health from dying before mechanics can run.
@@ -93,7 +111,9 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const scaledHealth = boss
? testing
? Math.max(Math.min(originalHealth, testingBossCeiling), testingBossFloor)
: Math.max(originalHealth, bossFloor)
: biteSizedBoss
? Math.min(Math.max(originalHealth, bossFloor), partyHealthFloor * biteSizedCeilingScale)
: Math.max(originalHealth, bossFloor)
: originalHealth;
const testingDamageScale = testing && characterSource === "wow"
? interpolatedFloor(level, WOW_BOSS_TEST_DAMAGE_SCALE)
+50
View File
@@ -0,0 +1,50 @@
import { useCombatStore } from "./combatStore";
import { useDungeonRaceAffixStore } from "./dungeonRaceAffixRuntime";
import { getPartyRuntimePosition, type PartyWorldPosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function away(position: PartyWorldPosition, origin: PartyWorldPosition, distance: number): PartyWorldPosition {
const dx = position[0] - origin[0]; const dz = position[2] - origin[2];
const length = Math.hypot(dx, dz);
const x = length > 0.01 ? dx / length : 1;
const z = length > 0.01 ? dz / length : 0;
return [position[0] + x * distance, position[1], position[2] + z * distance];
}
/** Returns a temporary safe movement goal; the scene still validates and routes it on navmesh. */
export function raceAffixAvoidanceDestination(
memberId: string,
position: PartyWorldPosition,
now: number,
): PartyWorldPosition | null {
for (const effect of useDungeonRaceAffixStore.getState().effects) {
if (now >= effect.expiresAt) continue;
const center = effect.kind === "storm" && effect.targetId
? effect.targetId === "__player__" ? useCombatStore.getState().playerPosition
: getPartyRuntimePosition(effect.targetId) ?? effect.position
: effect.position;
if (Math.abs(position[1] - center[1]) > 3) continue;
const planar = Math.hypot(position[0] - center[0], position[2] - center[2]);
if (effect.kind === "storm" && effect.targetId === memberId && now < effect.resolvesAt) {
const others = [useCombatStore.getState().playerPosition,
...usePartyStore.getState().members.filter((member) => member.id !== memberId && member.health > 0)
.map((member) => getPartyRuntimePosition(member.id)).filter((point): point is PartyWorldPosition => point !== null)];
const nearest = others.sort((left, right) => Math.hypot(left[0] - position[0], left[2] - position[2])
- Math.hypot(right[0] - position[0], right[2] - position[2]))[0];
if (nearest && Math.hypot(nearest[0] - position[0], nearest[2] - position[2]) < 4)
return away(position, nearest, 4);
continue;
}
if (effect.kind === "ring" && now < effect.resolvesAt && planar >= 3.5 && planar <= 7.5) {
if (planar <= 5.5) return away(position, center, -(planar - 3));
return away(position, center, 8 - planar);
}
if (["volatile", "ash", "star", "storm", "predator"].includes(effect.kind)
&& (now < effect.resolvesAt || ["ash", "predator"].includes(effect.kind))
&& planar <= effect.radius + 0.7) {
if (effect.kind === "storm" && effect.targetId === memberId) continue;
return away(position, center, effect.radius + 1 - planar);
}
}
return null;
}
+40
View File
@@ -0,0 +1,40 @@
const lastStand = new Map<string, { stacks: number; expiresAt: number }>();
let boonUntil = 0;
export function applyLastStand(id: string, now: number): number {
const previous = lastStand.get(id);
const stacks = Math.min(3, (previous && previous.expiresAt > now ? previous.stacks : 0) + 1);
lastStand.set(id, { stacks, expiresAt: now + 6_000 });
return stacks;
}
export function lastStandStacks(id: string, now: number): number {
const state = lastStand.get(id);
if (!state || state.expiresAt <= now) { lastStand.delete(id); return 0; }
return state.stacks;
}
export function activeLastStand(now: number): readonly { id: string; stacks: number }[] {
return [...lastStand.keys()].flatMap((id) => {
const stacks = lastStandStacks(id, now);
return stacks ? [{ id, stacks }] : [];
});
}
export function raceAffixMobDamageMultiplier(id: string, now: number): number {
return 1 + lastStandStacks(id, now) * 0.25;
}
export function grantRaceAffixBoon(now: number): void { boonUntil = now + 8_000; }
export function raceAffixHasteMultiplier(now = Date.now()): number { return boonUntil > now ? 1.15 : 1; }
export function raceAffixBoonUntil(): number { return boonUntil; }
export function shiftDungeonRaceAffixBuffs(elapsedMs: number): void {
if (elapsedMs <= 0) return;
for (const [id, state] of lastStand) lastStand.set(id, { ...state, expiresAt: state.expiresAt + elapsedMs });
if (boonUntil > 0) boonUntil += elapsedMs;
}
export function resetDungeonRaceAffixBuffs(): void {
lastStand.clear();
boonUntil = 0;
}
+4
View File
@@ -0,0 +1,4 @@
let cleanup: (() => void) | null = null;
export function registerDungeonRaceAffixCleanup(handler: () => void): void { cleanup = handler; }
export function cleanupDungeonRaceAffixes(): void { cleanup?.(); }
+232
View File
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createRaceBot } from "../app/dungeonRaceBot";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { useCombatStore } from "./combatStore";
import { lastStandStacks, raceAffixBoonUntil, raceAffixHasteMultiplier,
raceAffixMobDamageMultiplier } from "./dungeonRaceAffixBuffs";
import { raceAffixAvoidanceDestination } from "./dungeonRaceAffixAvoidance";
import { advanceDungeonRaceAffixes, activeRaceOrbId, clearDungeonRaceAffixes,
resumeDungeonRaceAffixes, suspendDungeonRaceAffixes, useDungeonRaceAffixStore } from "./dungeonRaceAffixRuntime";
import { dungeonRaceAffixPace, rollDungeonRaceAffixes, validDungeonRaceAffixes } from "./dungeonRaceAffixes";
import { useDungeonRunStore } from "./dungeonRunStore";
import { dungeonDefinitionById } from "./dungeonRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
const dungeonId = "bite-sized-stormwind-stockade";
const definition = dungeonDefinitionById(dungeonId)!;
const trashIds = definition.staticSpawns.filter((spawn) => !definition.bosses.some((boss) => boss.id === spawn.id))
.map((spawn) => spawn.id);
function start(killId: string, movementId = "falling-stars") {
useDungeonRaceStore.setState({ mode: "practice", inGame: true, seed: "affix-test", dungeonId,
startAt: 10_000, affixIds: [killId, movementId] });
advanceDungeonRaceAffixes(10_000, []);
}
function deadTrash(id: string, position: readonly [number, number, number] = [0, 0, 0]) {
setMobPosition(id, position);
useCombatStore.getState().registerMob(id, { name: id, level: 20, maxHealth: 100 });
const mob = useCombatStore.getState().mobs[id];
useCombatStore.setState((state) => ({ mobs: { ...state.mobs,
[id]: { ...mob, dead: true, health: 0, diedAt: 10_000 } } }));
}
describe("Bite-Sized race mechanics", () => {
beforeEach(() => {
useDungeonRaceStore.getState().reset();
clearDungeonRaceAffixes();
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 20 });
useCombatStore.setState((state) => ({ mobs: {}, playerPosition: [0, 0, 0], health: state.maxHealth }));
usePartyStore.setState({ members: [] });
useDungeonRunStore.setState({ progress: null });
useGameStore.setState({ activeDungeonId: dungeonId, gameMode: "dungeon" });
});
it("rolls a valid shared pair and uses both affixes in the NPC pace", () => {
const pairs = new Set<string>();
for (let index = 0; index < 1_000; index += 1) {
const pair = rollDungeonRaceAffixes(`race-${index}`);
expect(validDungeonRaceAffixes(pair)).toBe(true);
expect(pair).toEqual(rollDungeonRaceAffixes(`race-${index}`));
pairs.add(pair.join("+"));
}
expect(pairs.size).toBe(20);
const pair = rollDungeonRaceAffixes("bot-pair");
const base = createRaceBot("bot-pair", "regular", "wow");
const tuned = createRaceBot("bot-pair", "regular", "wow", pair);
expect(Math.abs(tuned.finishMs - base.finishMs * dungeonRaceAffixPace(pair))).toBeLessThan(2);
expect(tuned.milestones.at(-1)?.atMs).toBe(tuned.finishMs);
});
it("deduplicates simultaneous volatile blasts and does not hit another floor", () => {
start("volatile-remains");
deadTrash(trashIds[0]); deadTrash(trashIds[1]);
advanceDungeonRaceAffixes(10_100, trashIds.slice(0, 2));
const full = useCombatStore.getState().health;
advanceDungeonRaceAffixes(15_099, []);
expect(useCombatStore.getState().health).toBe(full);
advanceDungeonRaceAffixes(15_100, []);
expect(useCombatStore.getState().health).toBeLessThan(full);
expect(useCombatStore.getState().health).toBeGreaterThan(full * 0.6);
const hit = useCombatStore.getState().health;
advanceDungeonRaceAffixes(15_200, []);
expect(useCombatStore.getState().health).toBe(hit);
useCombatStore.setState({ playerPosition: [0, 8, 0] });
deadTrash(trashIds[2]);
advanceDungeonRaceAffixes(16_000, [trashIds[2]]);
advanceDungeonRaceAffixes(21_000, []);
expect(useCombatStore.getState().health).toBe(hit);
});
it("stacks nearby Last Stand to three and expires after six seconds", () => {
start("last-stand");
const survivor = trashIds[0]; const far = trashIds[1];
setMobPosition(survivor, [2, 0, 0]);
useCombatStore.getState().registerMob(survivor, { name: "survivor", maxHealth: 100 });
setMobPosition(far, [12, 0, 0]);
useCombatStore.getState().registerMob(far, { name: "far", maxHealth: 100 });
for (let index = 0; index < 4; index += 1) {
const id = trashIds[index + 2]; deadTrash(id);
advanceDungeonRaceAffixes(10_100 + index * 100, [id]);
}
expect(lastStandStacks(survivor, 10_400)).toBe(3);
expect(raceAffixMobDamageMultiplier(survivor, 10_400)).toBe(1.75);
expect(raceAffixMobDamageMultiplier(far, 10_400)).toBe(1);
expect(raceAffixMobDamageMultiplier(survivor, 16_401)).toBe(1);
});
it("limits burning patches to three and ticks only on their floor", () => {
start("lingering-ash");
for (let index = 0; index < 4; index += 1) {
const id = trashIds[index]; deadTrash(id);
advanceDungeonRaceAffixes(10_100 + index * 100, [id]);
}
expect(useDungeonRaceAffixStore.getState().effects.filter((effect) => effect.kind === "ash")).toHaveLength(3);
const full = useCombatStore.getState().health;
advanceDungeonRaceAffixes(11_500, []);
expect(useCombatStore.getState().health).toBeLessThan(full);
useCombatStore.setState({ playerPosition: [0, 8, 0] });
const after = useCombatStore.getState().health;
advanceDungeonRaceAffixes(12_500, []);
expect(useCombatStore.getState().health).toBe(after);
});
it("ticks Ash for eight seconds and Predator for six seconds", () => {
start("lingering-ash");
deadTrash(trashIds[0]);
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
let previous = useCombatStore.getState().health;
for (let second = 1; second <= 8; second += 1) {
advanceDungeonRaceAffixes(10_100 + second * 1_000, []);
const current = useCombatStore.getState().health;
expect(current).toBeLessThan(previous);
previous = current;
}
expect(useDungeonRaceAffixStore.getState().effects.some((effect) => effect.kind === "ash")).toBe(false);
clearDungeonRaceAffixes();
useDungeonRaceStore.setState({ seed: "predator-test", affixIds: ["last-stand", "predator-mark"] });
advanceDungeonRaceAffixes(10_000, []);
advanceDungeonRaceAffixes(20_000, []);
previous = useCombatStore.getState().health;
for (let second = 3; second <= 8; second += 1) {
advanceDungeonRaceAffixes(20_000 + second * 1_000, []);
const current = useCombatStore.getState().health;
expect(current).toBeLessThan(previous);
previous = current;
}
expect(useDungeonRaceAffixStore.getState().effects.some((effect) => effect.kind === "predator")).toBe(false);
});
it("lets damage defeat an orb and pulses if a later orb expires", () => {
start("unstable-orb");
deadTrash(trashIds[0]);
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
const orb = activeRaceOrbId();
expect(orb).toBeTruthy();
useCombatStore.getState().damageMob(orb!, 1_000, 10_200);
const full = useCombatStore.getState().health;
advanceDungeonRaceAffixes(18_100, []);
expect(activeRaceOrbId()).toBeNull();
expect(useCombatStore.getState().health).toBe(full);
deadTrash(trashIds[1]);
advanceDungeonRaceAffixes(25_100, [trashIds[1]]);
expect(activeRaceOrbId()).toBeTruthy();
advanceDungeonRaceAffixes(33_100, []);
expect(useCombatStore.getState().health).toBeLessThan(full);
expect(activeRaceOrbId()).toBeNull();
});
it("requires a pickup before granting eight seconds of party haste", () => {
start("risky-boon");
deadTrash(trashIds[0]);
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
expect(raceAffixBoonUntil()).toBe(0);
advanceDungeonRaceAffixes(10_600, []);
expect(raceAffixHasteMultiplier(10_600)).toBe(1.15);
expect(raceAffixHasteMultiplier(18_601)).toBe(1);
});
it.each([
["falling-stars", "star"], ["stormcall", "storm"],
["arcane-pulse", "ring"], ["predator-mark", "predator"],
])("creates the %s warning on the ten-second cadence", (movementId, kind) => {
start("last-stand", movementId);
advanceDungeonRaceAffixes(19_999, []);
expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(0);
advanceDungeonRaceAffixes(20_000, []);
const effect = useDungeonRaceAffixStore.getState().effects[0];
expect(effect.kind).toBe(kind);
expect(effect.resolvesAt).toBe(22_000);
const destination = raceAffixAvoidanceDestination("npc", kind === "ring" ? [5, 0, 0] : [0, 0, 0], 20_100);
expect(destination).toBeTruthy();
const distance = Math.hypot(destination![0] - effect.position[0], destination![2] - effect.position[2]);
expect(kind === "ring" ? distance < 4 || distance > 7 : distance > effect.radius).toBe(true);
});
it("freezes a warning and the next hazard across an offline pause", () => {
start("volatile-remains");
deadTrash(trashIds[0]);
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
suspendDungeonRaceAffixes(11_000);
resumeDungeonRaceAffixes(21_000);
expect(useDungeonRaceAffixStore.getState().effects[0].resolvesAt).toBe(25_100);
expect(useDungeonRaceAffixStore.getState().nextMovementAt).toBe(30_000);
const full = useCombatStore.getState().health;
advanceDungeonRaceAffixes(25_099, []);
expect(useCombatStore.getState().health).toBe(full);
advanceDungeonRaceAffixes(25_100, []);
expect(useCombatStore.getState().health).toBeLessThan(full);
});
it("damages only the warned ring band on its floor", () => {
start("last-stand", "arcane-pulse");
advanceDungeonRaceAffixes(20_000, []);
useCombatStore.setState({ playerPosition: [3, 0, 0] });
const full = useCombatStore.getState().health;
advanceDungeonRaceAffixes(22_000, []);
expect(useCombatStore.getState().health).toBe(full);
advanceDungeonRaceAffixes(30_000, []);
useCombatStore.setState({ playerPosition: [5, 8, 0] });
advanceDungeonRaceAffixes(32_000, []);
expect(useCombatStore.getState().health).toBe(full);
useCombatStore.setState({ playerPosition: [0, 0, 0] });
advanceDungeonRaceAffixes(40_000, []);
useCombatStore.setState({ playerPosition: [5, 0, 0] });
advanceDungeonRaceAffixes(42_000, []);
expect(useCombatStore.getState().health).toBeLessThan(full);
});
it("clears live hazards and buffs when the race run ends", () => {
start("risky-boon");
deadTrash(trashIds[0]);
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
advanceDungeonRaceAffixes(10_600, []);
expect(raceAffixBoonUntil()).toBeGreaterThan(0);
useDungeonRaceStore.getState().reset();
expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(0);
expect(raceAffixBoonUntil()).toBe(0);
});
});
+295
View File
@@ -0,0 +1,295 @@
import { create } from "zustand";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { useCombatStore, type MobCombatState } from "./combatStore";
import { useDungeonRunStore } from "./dungeonRunStore";
import { dungeonDefinitionById } from "./dungeonRegistry";
import { hashDungeonRaceAffixSeed, type DungeonRaceAffixPair } from "./dungeonRaceAffixes";
import { applyLastStand, grantRaceAffixBoon, resetDungeonRaceAffixBuffs, shiftDungeonRaceAffixBuffs } from "./dungeonRaceAffixBuffs";
import { registerDungeonRaceAffixCleanup } from "./dungeonRaceAffixLifecycle";
import { getMobPosition, removeMobPosition, setMobPosition, type CombatPosition } from "./mobRuntimeRegistry";
import { getPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
export type RaceAffixEffectKind = "volatile" | "ash" | "orb" | "boon" | "star" | "storm" | "ring" | "predator";
export interface RaceAffixEffect {
readonly id: string;
readonly kind: RaceAffixEffectKind;
readonly position: CombatPosition;
readonly createdAt: number;
readonly resolvesAt: number;
readonly expiresAt: number;
readonly radius: number;
readonly targetId?: string;
readonly tickAt?: number;
readonly trail?: readonly { readonly at: number; readonly position: CombatPosition }[];
}
interface RaceAffixRuntimeState {
readonly seed: string | null;
readonly pair: DungeonRaceAffixPair | null;
readonly effects: readonly RaceAffixEffect[];
readonly nextMovementAt: number;
readonly lastKillProcAt: number;
readonly sequence: number;
readonly movementIndex: number;
readonly wiped: boolean;
}
const emptyState = (): RaceAffixRuntimeState => ({
seed: null, pair: null, effects: [], nextMovementAt: 0,
lastKillProcAt: Number.NEGATIVE_INFINITY, sequence: 0, movementIndex: 0, wiped: false,
});
export const useDungeonRaceAffixStore = create<RaceAffixRuntimeState>(() => emptyState());
let suspendedAt: number | null = null;
/** The offline simulation stops while paused or hidden; preserve remaining warning and buff time. */
export function suspendDungeonRaceAffixes(now = Date.now()): void {
if (suspendedAt === null && useDungeonRaceAffixStore.getState().seed) suspendedAt = now;
}
export function resumeDungeonRaceAffixes(now = Date.now()): void {
if (suspendedAt === null) return;
const elapsedMs = Math.max(0, now - suspendedAt);
suspendedAt = null;
if (!elapsedMs) return;
const state = useDungeonRaceAffixStore.getState();
if (!state.seed) return;
shiftDungeonRaceAffixBuffs(elapsedMs);
useDungeonRaceAffixStore.setState({
effects: state.effects.map((effect) => ({ ...effect,
createdAt: effect.createdAt + elapsedMs, resolvesAt: effect.resolvesAt + elapsedMs,
expiresAt: effect.expiresAt + elapsedMs,
...(effect.tickAt === undefined ? {} : { tickAt: effect.tickAt + elapsedMs }),
...(effect.trail ? { trail: effect.trail.map((point) => ({ ...point, at: point.at + elapsedMs })) } : {}),
})),
nextMovementAt: state.nextMovementAt + elapsedMs,
lastKillProcAt: state.lastKillProcAt + elapsedMs,
});
}
function livingActors(): readonly { id: string; health: number; maxHealth: number; position: CombatPosition }[] {
const combat = useCombatStore.getState();
const members = usePartyStore.getState().members;
return [
...(combat.health > 0 ? [{ id: "__player__", health: combat.health,
maxHealth: combat.maxHealth, position: combat.playerPosition }] : []),
...members.filter((member) => member.health > 0).flatMap((member) => {
const position = getPartyRuntimePosition(member.id);
return position ? [{ id: member.id, health: member.health, maxHealth: member.maxHealth, position }] : [];
}),
];
}
function near(a: CombatPosition, b: CombatPosition, radius: number): boolean {
const dx = a[0] - b[0]; const dz = a[2] - b[2];
return Math.abs(a[1] - b[1]) <= 3 && dx * dx + dz * dz <= radius * radius;
}
function hitActors(ids: Iterable<string>, percent: number, school: "fire" | "nature" | "arcane" | "shadow"): void {
const targets = new Set(ids);
if (targets.has("__player__")) {
const combat = useCombatStore.getState();
if (combat.health > 0) combat.damagePlayer(Math.max(1, Math.round(combat.maxHealth * percent)), school, combat.level);
}
for (const member of usePartyStore.getState().members) {
if (member.health > 0 && targets.has(member.id)) {
usePartyStore.getState().damageMember(member.id, Math.max(1, Math.round(member.maxHealth * percent)),
school, useCombatStore.getState().level);
}
}
}
function mobIsOriginalTrash(mob: MobCombatState | undefined, originalIds: ReadonlySet<string>): mob is MobCombatState {
return Boolean(mob && !mob.boss && originalIds.has(mob.id));
}
function removeEffectMob(effect: RaceAffixEffect): void {
if (effect.kind !== "orb") return;
useCombatStore.getState().removeMob(effect.id);
removeMobPosition(effect.id);
}
export function resetDungeonRaceAffixEffects(now = Date.now()): void {
const state = useDungeonRaceAffixStore.getState();
for (const effect of state.effects) removeEffectMob(effect);
resetDungeonRaceAffixBuffs();
useDungeonRaceAffixStore.setState({ effects: [], nextMovementAt: now + 10_000,
lastKillProcAt: Number.NEGATIVE_INFINITY, wiped: false });
}
export function clearDungeonRaceAffixes(): void {
suspendedAt = null;
for (const effect of useDungeonRaceAffixStore.getState().effects) removeEffectMob(effect);
resetDungeonRaceAffixBuffs();
useDungeonRaceAffixStore.setState(emptyState());
}
registerDungeonRaceAffixCleanup(clearDungeonRaceAffixes);
export function activeRaceOrbId(): string | null {
const effect = useDungeonRaceAffixStore.getState().effects.find((candidate) => candidate.kind === "orb");
return effect && !useCombatStore.getState().mobs[effect.id]?.dead ? effect.id : null;
}
function createMovementEffect(id: string, kind: string, now: number, targetRoll: number): RaceAffixEffect | null {
const actors = livingActors();
if (!actors.length) return null;
const target = actors[targetRoll % actors.length];
const base = { id, position: target.position, createdAt: now, resolvesAt: now + 2_000,
expiresAt: now + 2_500, radius: 3 };
if (kind === "falling-stars") return { ...base, kind: "star" };
if (kind === "stormcall") return { ...base, kind: "storm", targetId: target.id };
if (kind === "arcane-pulse") return { ...base, kind: "ring", radius: 7 };
return { ...base, kind: "predator", targetId: target.id, radius: 2,
expiresAt: now + 8_000, tickAt: now + 3_000,
trail: [{ at: now, position: target.position }] };
}
function actorPosition(id: string): CombatPosition | null {
return id === "__player__" ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id);
}
/** Advances only while the playable dungeon combat tick is active. */
export function advanceDungeonRaceAffixes(now: number, deathIds: readonly string[]): void {
const race = useDungeonRaceStore.getState();
const game = useGameStore.getState();
const progress = useDungeonRunStore.getState().progress;
if (!race.inGame || !race.seed || !race.affixIds || race.dungeonId !== game.activeDungeonId) {
if (useDungeonRaceAffixStore.getState().seed) clearDungeonRaceAffixes();
return;
}
if (progress?.finishedAt !== null && progress?.finishedAt !== undefined) {
if (useDungeonRaceAffixStore.getState().seed) clearDungeonRaceAffixes();
return;
}
let state = useDungeonRaceAffixStore.getState();
if (state.seed !== race.seed) {
clearDungeonRaceAffixes();
state = { ...emptyState(), seed: race.seed, pair: race.affixIds, nextMovementAt: now + 10_000 };
useDungeonRaceAffixStore.setState(state);
}
const actors = livingActors();
if (!actors.length) {
if (!state.wiped) resetDungeonRaceAffixEffects(now);
useDungeonRaceAffixStore.setState({ wiped: true });
return;
}
if (state.wiped) {
useDungeonRaceAffixStore.setState({ wiped: false, nextMovementAt: now + 10_000 });
state = useDungeonRaceAffixStore.getState();
}
let effects = [...state.effects];
let sequence = state.sequence;
let lastKillProcAt = state.lastKillProcAt;
const [killId, movementId] = race.affixIds;
const mobs = useCombatStore.getState().mobs;
const definition = dungeonDefinitionById(game.activeDungeonId);
const originalIds = new Set(definition?.staticSpawns.map((spawn) => spawn.id) ?? []);
for (const deathId of deathIds) {
const mob = mobs[deathId];
if (!mobIsOriginalTrash(mob, originalIds)) continue;
const position = getMobPosition(deathId) ?? mob.homePosition;
if (!position) continue;
const id = `race-affix-${race.seed}-${++sequence}`;
if (killId === "volatile-remains") {
effects.push({ id, kind: "volatile", position, createdAt: now, resolvesAt: now + 5_000,
expiresAt: now + 5_500, radius: 4 });
} else if (killId === "last-stand") {
for (const survivor of Object.values(mobs)) {
const survivorPosition = getMobPosition(survivor.id);
if (survivor.id !== deathId && mobIsOriginalTrash(survivor, originalIds) && !survivor.dead
&& survivorPosition && near(position, survivorPosition, 8)) applyLastStand(survivor.id, now);
}
} else if (killId === "lingering-ash") {
effects.push({ id, kind: "ash", position, createdAt: now, resolvesAt: now,
expiresAt: now + 8_000, radius: 2.5, tickAt: now + 1_000 });
const ash = effects.filter((effect) => effect.kind === "ash");
if (ash.length > 3) effects = effects.filter((effect) => effect.id !== ash[0].id);
} else if (now - lastKillProcAt >= 15_000 && !effects.some((effect) => effect.kind === "orb" || effect.kind === "boon")) {
lastKillProcAt = now;
if (killId === "unstable-orb") {
setMobPosition(id, position);
useCombatStore.getState().registerMob(id, { name: "Unstable Orb", level: mob.level,
maxHealth: Math.max(20, Math.round(mob.maxHealth * 0.1)), xpReward: 0, hasLoot: false,
aggroRange: 0, moveSpeed: 0, damageMultiplier: 0.01 });
effects.push({ id, kind: "orb", position, createdAt: now, resolvesAt: now + 8_000,
expiresAt: now + 8_000, radius: 1 });
} else if (killId === "risky-boon") {
effects.push({ id, kind: "boon", position, createdAt: now, resolvesAt: now,
expiresAt: now + 8_000, radius: 1.5 });
}
}
}
let nextMovementAt = state.nextMovementAt;
let movementIndex = state.movementIndex;
if (now >= nextMovementAt) {
const id = `race-affix-${race.seed}-${++sequence}`;
const effect = createMovementEffect(id, movementId, now,
hashDungeonRaceAffixSeed(`${race.seed}:hazard:${movementIndex++}`));
if (effect) effects.push(effect);
nextMovementAt = now + 10_000;
}
const volatileHits = new Set<string>();
const nextEffects: RaceAffixEffect[] = [];
for (const effect of effects) {
if (effect.kind === "volatile" && now >= effect.resolvesAt && effect.tickAt === undefined) {
for (const actor of livingActors()) if (near(effect.position, actor.position, effect.radius)) volatileHits.add(actor.id);
nextEffects.push({ ...effect, tickAt: now });
continue;
}
if (effect.kind === "ash" && now >= (effect.tickAt ?? Infinity)
&& (effect.tickAt ?? Infinity) <= effect.expiresAt) {
hitActors(livingActors().filter((actor) => near(effect.position, actor.position, effect.radius)).map((actor) => actor.id), 0.04, "fire");
if (now < effect.expiresAt) nextEffects.push({ ...effect, tickAt: (effect.tickAt ?? now) + 1_000 });
continue;
}
if (effect.kind === "boon" && now >= effect.createdAt + 500
&& livingActors().some((actor) => actor.id === "__player__"
&& near(effect.position, actor.position, effect.radius))) {
grantRaceAffixBoon(now);
continue;
}
if (effect.kind === "orb") {
if (useCombatStore.getState().mobs[effect.id]?.dead) { removeEffectMob(effect); continue; }
if (now >= effect.expiresAt) {
hitActors(livingActors().map((actor) => actor.id), 0.15, "arcane");
removeEffectMob(effect);
continue;
}
}
if (["star", "storm", "ring"].includes(effect.kind) && now >= effect.resolvesAt && effect.tickAt === undefined) {
const center = effect.kind === "storm" ? actorPosition(effect.targetId ?? "") ?? effect.position : effect.position;
const hits = livingActors().filter((actor) => {
if (effect.kind === "storm" && actor.id === effect.targetId) return false;
if (effect.kind === "ring") {
const dx = actor.position[0] - center[0]; const dz = actor.position[2] - center[2];
const squared = dx * dx + dz * dz;
return Math.abs(actor.position[1] - center[1]) <= 3 && squared >= 16 && squared <= 49;
}
return near(center, actor.position, effect.radius);
});
hitActors(hits.map((actor) => actor.id), effect.kind === "ring" ? 0.18 : 0.2,
effect.kind === "storm" ? "nature" : effect.kind === "ring" ? "arcane" : "fire");
nextEffects.push({ ...effect, position: center, tickAt: now });
continue;
}
if (effect.kind === "predator") {
const current = actorPosition(effect.targetId ?? "");
const trail = [...(effect.trail ?? []), ...(current ? [{ at: now, position: current }] : [])]
.filter((point) => point.at >= now - 1_500);
const delayed = trail.find((point) => point.at >= now - 1_000) ?? trail[0];
const position = now < effect.resolvesAt ? current ?? effect.position
: delayed?.position ?? effect.position;
if (now >= (effect.tickAt ?? Infinity) && (effect.tickAt ?? Infinity) <= effect.expiresAt) {
hitActors(livingActors().filter((actor) => near(position, actor.position, 2)).map((actor) => actor.id), 0.05, "shadow");
if (now < effect.expiresAt) nextEffects.push({ ...effect, position, trail,
tickAt: (effect.tickAt ?? now) + 1_000 });
} else if (now < effect.expiresAt) nextEffects.push({ ...effect, position, trail });
continue;
}
if (now < effect.expiresAt) nextEffects.push(effect);
}
if (volatileHits.size) hitActors(volatileHits, 0.2, "fire");
useDungeonRaceAffixStore.setState({ effects: nextEffects, nextMovementAt,
lastKillProcAt, sequence, movementIndex });
}
+15
View File
@@ -0,0 +1,15 @@
{
"kill": [
{ "id": "volatile-remains", "name": "Volatile Remains", "description": "Trash corpses explode after five seconds. Move out of the marked blast.", "botMultiplier": 1.04 },
{ "id": "last-stand", "name": "Last Stand", "description": "Nearby trash gains stacking damage when an ally dies.", "botMultiplier": 1.05 },
{ "id": "lingering-ash", "name": "Lingering Ash", "description": "Trash deaths leave short-lived burning ground.", "botMultiplier": 1.04 },
{ "id": "unstable-orb", "name": "Unstable Orb", "description": "Destroy the orb from a trash death before it pulses.", "botMultiplier": 1.06 },
{ "id": "risky-boon", "name": "Risky Boon", "description": "Collect a short-lived pickup for party haste.", "botMultiplier": 0.97 }
],
"movement": [
{ "id": "falling-stars", "name": "Falling Stars", "description": "Marked ground is struck by a falling star every ten seconds.", "botMultiplier": 1.05 },
{ "id": "stormcall", "name": "Stormcall", "description": "A marked ally must separate before lightning strikes.", "botMultiplier": 1.05 },
{ "id": "arcane-pulse", "name": "Arcane Pulse", "description": "Move inside or outside the warned arcane ring.", "botMultiplier": 1.04 },
{ "id": "predator-mark", "name": "Predator Mark", "description": "A ground hazard pursues a marked ally for six seconds.", "botMultiplier": 1.05 }
]
}
+34
View File
@@ -0,0 +1,34 @@
import catalog from "./dungeonRaceAffixes.json";
export type DungeonRaceAffix = (typeof catalog.kill)[number] | (typeof catalog.movement)[number];
export type DungeonRaceAffixPair = readonly [killId: string, movementId: string];
export function hashDungeonRaceAffixSeed(value: string): number {
let hash = 2_166_136_261;
for (const char of value) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16_777_619); }
return hash >>> 0;
}
export function rollDungeonRaceAffixes(seed: string): DungeonRaceAffixPair {
return [
catalog.kill[hashDungeonRaceAffixSeed(`${seed}:kill-affix`) % catalog.kill.length].id,
catalog.movement[hashDungeonRaceAffixSeed(`${seed}:movement-affix`) % catalog.movement.length].id,
];
}
export function validDungeonRaceAffixes(value: unknown): value is DungeonRaceAffixPair {
return Array.isArray(value) && value.length === 2
&& catalog.kill.some((affix) => affix.id === value[0])
&& catalog.movement.some((affix) => affix.id === value[1]);
}
export function dungeonRaceAffix(id: string): DungeonRaceAffix | undefined {
return [...catalog.kill, ...catalog.movement].find((entry) => entry.id === id);
}
export function dungeonRaceAffixPace(pair: DungeonRaceAffixPair): number {
return pair.reduce((factor, id) => factor * (dungeonRaceAffix(id)?.botMultiplier ?? 1), 1);
}
export const DUNGEON_RACE_KILL_AFFIXES = catalog.kill;
export const DUNGEON_RACE_MOVEMENT_AFFIXES = catalog.movement;
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { appendCombatEvent, MAX_COMBAT_EVENT_HISTORY } from "./combatEvents";
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type MobCombatState } from "./combatStore";
import { RaceCombatRecorder } from "./dungeonRaceCombatLog";
function mob(id: string, dead = false, diedAt: number | null = null): MobCombatState {
return { id, name: id, boss: id === "boss", maxHealth: 1_000,
engaged: true, dead, diedAt, despawned: false } as MobCombatState;
}
describe("race combat recording", () => {
afterEach(() => {
vi.useRealTimers();
useCombatStore.setState({ mobs: {} });
});
it("keeps earlier events beyond the HUD ring and times kills and overlapping packs", () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
useCombatStore.setState({ mobs: {} });
const recorder = new RaceCombatRecorder("race-test", "bite-sized-test");
try {
vi.setSystemTime(1_200);
useCombatStore.setState({ mobs: { first: mob("first"), boss: mob("boss") } });
let hudHistory = [] as ReturnType<typeof appendCombatEvent>;
for (let id = 1; id <= MAX_COMBAT_EVENT_HISTORY + 75; id += 1) {
hudHistory = appendCombatEvent(hudHistory, { occurredAt: 1_200 + id,
kind: "damage", sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: id % 2 ? "first" : "boss", abilityId: "test-aoe",
rawAmount: 2, effectiveAmount: 2, overheal: 0, absorbed: 0,
critical: false }, id);
}
vi.setSystemTime(2_000);
useCombatStore.setState({ mobs: { first: mob("first", true, 2_000),
boss: mob("boss", true, 2_000) } });
const log = recorder.finish(1_000, 2_000);
expect(hudHistory).toHaveLength(MAX_COMBAT_EVENT_HISTORY);
expect(log.events).toHaveLength(MAX_COMBAT_EVENT_HISTORY + 75);
expect(log.droppedEventCount).toBe(0);
expect(log.kills).toHaveLength(2);
expect(log.kills.every((kill) => kill.firstDamageAtMs !== null
&& kill.durationMs < 800)).toBe(true);
expect(log.packs).toHaveLength(1);
expect(log.packs[0].mobIds).toEqual(["first", "boss"]);
expect(log.packs[0].partyDamage.find((entry) => entry.actorId === PLAYER_AURA_ENTITY_ID)?.damage)
.toBe((MAX_COMBAT_EVENT_HISTORY + 75) * 2);
} finally { recorder.stop(); }
});
});
+220
View File
@@ -0,0 +1,220 @@
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
export const MAX_RACE_COMBAT_EVENTS = 10_000;
export const MAX_RACE_COMBAT_KILLS = 1_000;
export interface RaceCombatEvent {
readonly atMs: number;
readonly kind: StructuredCombatEvent["kind"];
readonly source: string;
readonly target: string;
readonly ability?: string;
readonly raw: number;
readonly effective: number;
readonly overheal: number;
readonly absorbed: number;
readonly critical: boolean;
readonly outcome?: string;
}
export interface RaceMobKill {
readonly id: string;
readonly name: string;
readonly boss: boolean;
readonly maxHealth: number;
readonly engagedAtMs: number;
readonly firstDamageAtMs: number | null;
readonly diedAtMs: number;
readonly durationMs: number;
}
export interface RaceCombatPack {
readonly startedAtMs: number;
readonly endedAtMs: number;
readonly durationMs: number;
readonly mobIds: readonly string[];
readonly partyDamage: readonly { actorId: string; damage: number }[];
}
export interface RaceCombatLog {
readonly version: 1;
readonly runId: string;
readonly dungeonId: string;
readonly durationMs: number;
readonly completed: boolean;
readonly events: readonly RaceCombatEvent[];
readonly droppedEventCount: number;
readonly kills: readonly RaceMobKill[];
readonly packs: readonly RaceCombatPack[];
readonly droppedKillCount: number;
readonly party: readonly { actorId: string; label: string; classId: string;
level: number; role: string }[];
readonly partyDamage: readonly { actorId: string; damage: number }[];
}
interface MobTiming {
id: string;
name: string;
boss: boolean;
maxHealth: number;
engagedAt: number;
diedAt: number | null;
}
let active: RaceCombatRecorder | null = null;
let lastPracticeLog: RaceCombatLog | null = null;
function boundedText(value: string): string { return value.slice(0, 128); }
function elapsed(now: number, startedAt: number): number {
return Math.max(0, Math.round(now - startedAt));
}
/** Captures the entire race stream, independent of the 200-event HUD history. */
export class RaceCombatRecorder {
private readonly events: StructuredCombatEvent[] = [];
private readonly mobs = new Map<string, MobTiming>();
private readonly firstDamageAt = new Map<string, number>();
private readonly damageByTarget = new Map<string, Map<string, number>>();
private readonly ignoredMobIds = new Set<string>();
private droppedEventCount = 0;
private droppedKillCount = 0;
private readonly unsubscribeEvents: () => void;
private readonly unsubscribeMobs: () => void;
constructor(readonly runId: string, readonly dungeonId: string) {
this.unsubscribeEvents = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.effectiveAmount > 0
&& !this.firstDamageAt.has(event.targetActorId) && this.firstDamageAt.size < 2_000) {
this.firstDamageAt.set(event.targetActorId, event.occurredAt);
}
if (event.kind === "damage" && event.effectiveAmount > 0) {
let sources = this.damageByTarget.get(event.targetActorId);
if (!sources && this.damageByTarget.size < 2_000) {
sources = new Map(); this.damageByTarget.set(event.targetActorId, sources);
}
if (sources && (sources.has(event.sourceActorId) || sources.size < 20)) {
sources.set(event.sourceActorId,
(sources.get(event.sourceActorId) ?? 0) + event.effectiveAmount);
}
}
if (this.events.length < MAX_RACE_COMBAT_EVENTS) this.events.push(event);
else this.droppedEventCount += 1;
});
this.unsubscribeMobs = useCombatStore.subscribe((state, previous) => {
if (state.mobs !== previous.mobs) this.observeMobs(state.mobs);
});
}
private observeMobs(mobs: ReturnType<typeof useCombatStore.getState>["mobs"]): void {
const now = Date.now();
for (const mob of Object.values(mobs)) {
if (mob.despawned || (!mob.engaged && !mob.dead)) continue;
let timing = this.mobs.get(mob.id);
if (!timing) {
if (this.mobs.size >= MAX_RACE_COMBAT_KILLS) {
if (!this.ignoredMobIds.has(mob.id)) { this.ignoredMobIds.add(mob.id); this.droppedKillCount += 1; }
continue;
}
timing = { id: boundedText(mob.id), name: boundedText(mob.name), boss: mob.boss,
maxHealth: Math.max(0, Math.round(mob.maxHealth)), engagedAt: Math.min(now, mob.diedAt ?? now),
diedAt: null };
this.mobs.set(mob.id, timing);
}
if (mob.dead && timing.diedAt === null) timing.diedAt = mob.diedAt ?? now;
}
}
finish(startedAt: number, finishedAt = Date.now(), completed = true): RaceCombatLog {
this.unsubscribeEvents();
this.unsubscribeMobs();
const combat = useCombatStore.getState();
const party = [{ actorId: PLAYER_AURA_ENTITY_ID, label: "Player", classId: combat.classId,
level: combat.level, role: combat.gearRole }, ...usePartyStore.getState().members.map((member) => ({
actorId: member.id, label: boundedText(member.name), classId: member.classId,
level: member.level, role: member.role,
}))];
const partyIds = new Set(party.map((member) => member.actorId));
const damage = new Map<string, number>([...partyIds].map((id) => [id, 0]));
for (const mobId of this.mobs.keys()) for (const [actorId, dealt] of this.damageByTarget.get(mobId) ?? []) {
if (partyIds.has(actorId)) damage.set(actorId, (damage.get(actorId) ?? 0) + dealt);
}
const completedMobs = [...this.mobs.values()].filter((mob) => mob.diedAt !== null)
.sort((left, right) => (this.firstDamageAt.get(left.id) ?? left.engagedAt)
- (this.firstDamageAt.get(right.id) ?? right.engagedAt));
const packWindows: { start: number; end: number; ids: string[] }[] = [];
for (const mob of completedMobs) {
const firstDamageAt = this.firstDamageAt.get(mob.id) ?? mob.engagedAt;
const latest = packWindows.at(-1);
if (latest && firstDamageAt <= latest.end + 2_000) {
latest.end = Math.max(latest.end, mob.diedAt!);
latest.ids.push(mob.id);
} else packWindows.push({ start: firstDamageAt, end: mob.diedAt!, ids: [mob.id] });
}
const packs = packWindows.map((window) => {
const ids = new Set(window.ids);
const partyDamage = new Map<string, number>([...partyIds].map((id) => [id, 0]));
for (const mobId of ids) for (const [actorId, dealt] of this.damageByTarget.get(mobId) ?? []) {
if (partyIds.has(actorId)) partyDamage.set(actorId, (partyDamage.get(actorId) ?? 0) + dealt);
}
return { startedAtMs: elapsed(window.start, startedAt), endedAtMs: elapsed(window.end, startedAt),
durationMs: Math.max(1, Math.round(window.end - window.start)), mobIds: window.ids,
partyDamage: [...partyDamage].map(([actorId, total]) => ({ actorId: boundedText(actorId),
damage: Math.round(total) })) };
});
const events = this.events.map((event) => ({ atMs: elapsed(event.occurredAt, startedAt), kind: event.kind,
source: boundedText(event.sourceActorId), target: boundedText(event.targetActorId),
...(event.abilityId ? { ability: boundedText(event.abilityId) } : {}),
raw: Math.round(event.rawAmount), effective: Math.round(event.effectiveAmount),
overheal: Math.round(event.overheal), absorbed: Math.round(event.absorbed),
critical: event.critical, ...(event.outcome ? { outcome: boundedText(event.outcome) } : {}) }));
let removedForSize = 0;
let log: RaceCombatLog = {
version: 1, runId: this.runId, dungeonId: this.dungeonId,
durationMs: elapsed(finishedAt, startedAt),
completed,
events,
droppedEventCount: this.droppedEventCount,
kills: completedMobs.map((mob) => ({
id: mob.id, name: mob.name, boss: mob.boss, maxHealth: mob.maxHealth,
engagedAtMs: elapsed(mob.engagedAt, startedAt), diedAtMs: elapsed(mob.diedAt!, startedAt),
firstDamageAtMs: this.firstDamageAt.has(mob.id)
? elapsed(this.firstDamageAt.get(mob.id)!, startedAt) : null,
durationMs: Math.max(1, Math.round(mob.diedAt!
- (this.firstDamageAt.get(mob.id) ?? mob.engagedAt))),
})),
packs,
droppedKillCount: this.droppedKillCount,
party,
partyDamage: [...damage].map(([actorId, total]) => ({ actorId: boundedText(actorId),
damage: Math.round(total) })),
};
while (events.length && new TextEncoder().encode(JSON.stringify(log)).byteLength > 3_500_000) {
removedForSize += Math.min(100, events.length);
events.splice(-100);
log = { ...log, events, droppedEventCount: this.droppedEventCount + removedForSize };
}
return log;
}
stop(): void { this.unsubscribeEvents(); this.unsubscribeMobs(); }
}
export function startRaceCombatLog(runId: string, dungeonId: string): void {
active?.stop();
active = new RaceCombatRecorder(runId, dungeonId);
lastPracticeLog = null;
}
export function finishRaceCombatLog(runId: string, startedAt: number, practice: boolean,
completed = true): RaceCombatLog | null {
if (active?.runId !== runId) return null;
const log = active.finish(startedAt, Date.now(), completed);
active = null;
if (practice) lastPracticeLog = log;
return log;
}
export function stopRaceCombatLog(): void { active?.stop(); active = null; }
export function recentPracticeRaceCombatLog(): RaceCombatLog | null { return lastPracticeLog; }
+6
View File
@@ -210,6 +210,12 @@ export interface DungeonPresentation {
readonly intensity: number;
};
readonly directionalLight: { readonly color: string; readonly intensity: number; readonly offset: Vector3Tuple };
readonly pointLights?: readonly {
readonly position: Vector3Tuple;
readonly color: string;
readonly intensity: number;
readonly distance: number;
}[];
readonly materials: DungeonMaterialRoles;
readonly map: DungeonMapPresentation;
}
+38 -2
View File
@@ -49,8 +49,10 @@ describe("RoM bite-sized finales", () => {
expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`);
expect(slice.assets).toBe(source.assets);
expect(slice.staticSpawns).toHaveLength(id === "bite-sized-barren-caves-anglo" ? 4
: id === "bite-sized-kawaks-tomb-linobia" || id === "bite-sized-hall-of-survivors-pursuer" ? 5 : 6);
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.includes(spawn))).toBe(true);
: id === "bite-sized-kawaks-tomb-linobia" || id === "bite-sized-hall-of-survivors-pursuer" ? 5
: id === "bite-sized-hoto-teeth" ? 10 : 6);
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.some((candidate) =>
candidate.id === spawn.id && candidate.entityId === spawn.entityId))).toBe(true);
expect(slice.bosses).toHaveLength(1);
expect(slice.bosses[0].eventOnly).toBe(true);
expect(slice.staticSpawns).not.toContainEqual(expect.objectContaining({ id: slice.bosses[0].id }));
@@ -117,4 +119,38 @@ describe("RoM bite-sized finales", () => {
}), `${id} route to ${spawn.id}`).not.toBeNull();
}
}, 30_000);
it("stages both Hall packs and the finale in the twin-statue chamber", () => {
const slice = DUNGEON_REGISTRY["bite-sized-hall-of-survivors-pursuer"];
const guardians = slice.staticSpawns.filter((spawn) => spawn.id.includes("59919"));
const hunters = slice.staticSpawns.filter((spawn) => spawn.id.includes("119628"));
expect(guardians).toHaveLength(3);
expect(hunters).toHaveLength(2);
// The western side room lies beyond x=-185; the statue chamber is to its east.
expect([...slice.staticSpawns, slice.encounterSpawnGroups![0].spawns[0]].every(
(spawn) => spawn.position[0] > -185 && spawn.position[0] < -158)).toBe(true);
expect(Math.min(...hunters.map((spawn) => spawn.position[2]))
- Math.max(...guardians.map((spawn) => spawn.position[2]))).toBeGreaterThan(10);
});
it("fills Hoto with three readable source Ayam clusters and a cooler cavern palette", () => {
const slice = DUNGEON_REGISTRY["bite-sized-hoto-teeth"];
const first = slice.staticSpawns.filter((spawn) => spawn.position[2] > 40);
const middle = slice.staticSpawns.filter((spawn) => spawn.position[2] > 8 && spawn.position[2] < 25);
const last = slice.staticSpawns.filter((spawn) => spawn.position[2] < 2);
expect([first.length, middle.length, last.length]).toEqual([3, 4, 3]);
const entrance = slice.entrance.footPosition;
expect(first.every((spawn) => Math.hypot(spawn.position[0] - entrance[0],
spawn.position[2] - entrance[2]) < 16)).toBe(true);
expect(first.every((spawn) => first.every((other) => Math.hypot(
spawn.position[0] - other.position[0], spawn.position[2] - other.position[2]) < 8))).toBe(true);
expect(slice.presentation.ambientLight.intensity).toBe(0.48);
expect(slice.presentation.ambientLight.color).toBe("#91acb0");
expect(slice.presentation.hemisphereLight.groundColor).toBe("#244451");
expect(slice.presentation.directionalLight.color).toBe("#f3c28b");
expect(slice.presentation.pointLights).toHaveLength(3);
expect(slice.presentation.pointLights?.map((light) => light.color)).toEqual([
"#58afc6", "#ffc06d", "#68bfad",
]);
});
});
+56 -15
View File
@@ -26,10 +26,21 @@ interface RoMSlice {
readonly start: Vector3Tuple;
readonly forward: Vector3Tuple;
readonly finaleBossName: string;
readonly finalePosition?: Vector3Tuple;
readonly mobSpawnIds: readonly string[];
readonly mobSpawnPositions?: Readonly<Record<string, Vector3Tuple>>;
readonly pointLights?: NonNullable<DungeonDefinition["presentation"]["pointLights"]>;
readonly summary: string;
readonly adaptation: string;
readonly lighting?: { readonly ambient: number; readonly hemisphere: number; readonly directional: number };
readonly lighting?: {
readonly ambient: number;
readonly hemisphere: number;
readonly directional: number;
readonly ambientColor?: string;
readonly skyColor?: string;
readonly groundColor?: string;
readonly directionalColor?: string;
};
}
const ROM_SLICES: readonly RoMSlice[] = [
@@ -130,12 +141,29 @@ const ROM_SLICES: readonly RoMSlice[] = [
forward: [0.70710678, 0, -0.70710678],
finaleBossName: "Teeth of the Reef",
mobSpawnIds: [
"rw-hoto-spawn-5978644", "rw-hoto-spawn-5978652", "rw-hoto-spawn-5978647",
"rw-hoto-spawn-5978485", "rw-hoto-spawn-5978483",
"rw-hoto-spawn-5978486", "rw-hoto-spawn-5978484",
"rw-hoto-spawn-5978631", "rw-hoto-spawn-5979808",
"rw-hoto-spawn-5978631", "rw-hoto-spawn-5979808", "rw-hoto-spawn-5978629",
],
// Keep all three within the 16 m entry aggro circle and close enough for
// three-target companion AoE; the old staggered positions pulled one at a time.
mobSpawnPositions: {
"rw-hoto-spawn-5978644": [120, -33.6, 53],
"rw-hoto-spawn-5978652": [123, -33.6, 54],
"rw-hoto-spawn-5978647": [126, -33.6, 55],
},
summary: "Clear three Ayam packs across the reef cavern, then defeat one surprise Hoto boss.",
adaptation: "The approach guard, mage, and warrior are moved onto the short route. Jiasha, earlier boss rooms, and unrelated Hoto population are outside this run.",
lighting: {
ambient: 0.48, hemisphere: 0.7, directional: 0.88,
ambientColor: "#91acb0", skyColor: "#c2c6ad", groundColor: "#244451", directionalColor: "#f3c28b",
},
pointLights: [
{ position: [123, -30.1, 50], color: "#58afc6", intensity: 30, distance: 17 },
{ position: [132, -34, 16], color: "#ffc06d", intensity: 27, distance: 16 },
{ position: [135, -33, -4], color: "#68bfad", intensity: 30, distance: 16 },
],
summary: "Clear the Ayam guards, casters, and defenders, then defeat one surprise Hoto boss.",
adaptation: "Jiasha, earlier boss rooms, and unrelated Hoto population are outside this short run.",
},
{
sourceId: "kawaks-tomb",
@@ -146,6 +174,8 @@ const ROM_SLICES: readonly RoMSlice[] = [
start: [-85, -9.4, -243],
forward: [-1, 0, 0],
finaleBossName: "Linobia",
// Move Linobia off the source room's raised northwest edge onto its open center floor.
finalePosition: [-170, -9.295, -242],
mobSpawnIds: [
"rw-kawaks-tomb-spawn-41889142", "rw-kawaks-tomb-spawn-41889139",
"rw-kawaks-tomb-spawn-41892423", "rw-kawaks-tomb-spawn-41890637",
@@ -163,13 +193,20 @@ const ROM_SLICES: readonly RoMSlice[] = [
start: [-155, -17.55, 35],
forward: [-1, 0, 0],
finaleBossName: "Divine Punishment Pursuer",
finalePosition: [-171, -17.55, 31],
mobSpawnIds: [
"rw-hall-of-survivors-spawn-11962803", "rw-hall-of-survivors-spawn-11962817",
"rw-hall-of-survivors-spawn-5991940", "rw-hall-of-survivors-spawn-5991941",
"rw-hall-of-survivors-spawn-5991939",
"rw-hall-of-survivors-spawn-11962803", "rw-hall-of-survivors-spawn-11962817",
],
summary: "Clear the hunters and Rune Guardians, then defeat one surprise Hall boss.",
adaptation: "The other Pursuer and Seeker bosses, earlier rooms, and their combined source scripts are outside this short run.",
// The two Hunters originally stood in the western side room. Reposition them
// as a distinct pack at the far end of the open chamber with the statues.
mobSpawnPositions: {
"rw-hall-of-survivors-spawn-11962803": [-165, -17.55, 38],
"rw-hall-of-survivors-spawn-11962817": [-175, -17.55, 38],
},
summary: "Clear two packs around the ward's twin statues, then defeat one surprise Hall boss in the chamber.",
adaptation: "The Hunters and selected boss are moved out of the western side room into the statue chamber. Other Pursuer and Seeker bosses, earlier rooms, and combined source scripts are outside this short run.",
},
];
@@ -180,18 +217,20 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string
throw new Error(`${slice.sourceId} is missing ${slice.finaleBossName} or its boss list.`);
}
const boss = source.bosses[seededBossIndex(seed, source.bosses.length)];
const finalePosition = slice.finalePosition ?? finale.position;
const sourceBossSpawn = source.staticSpawns.find((spawn) => spawn.id === boss.id);
if (!sourceBossSpawn || source.entities[sourceBossSpawn.entityId]?.kind !== "boss") {
throw new Error(`${slice.sourceId} is missing source boss ${boss.name}.`);
}
const bossSpawn = { ...sourceBossSpawn, position: finale.position,
const bossSpawn = { ...sourceBossSpawn, position: finalePosition,
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
const mobSpawns = slice.mobSpawnIds.map((id) => {
const spawn = source.staticSpawns.find((candidate) => candidate.id === id);
if (!spawn || source.entities[spawn.entityId]?.kind === "boss" || !source.entities[spawn.entityId]) {
throw new Error(`${slice.sourceId} is missing source mob ${id}.`);
}
return spawn;
const position = slice.mobSpawnPositions?.[id];
return position ? { ...spawn, position } : spawn;
});
const bossGroup: DungeonEncounterSpawnGroupDefinition = {
id: `${slice.id}:boss`,
@@ -234,12 +273,12 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string
},
],
};
const objective: DungeonBossObjective = { ...boss, position: finale.position, optional: false, eventOnly: true };
const objective: DungeonBossObjective = { ...boss, position: finalePosition, optional: false, eventOnly: true };
const entityIds = new Set([...mobSpawns, bossSpawn].map((spawn) => spawn.entityId));
const entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id)));
const areas = [
{ id: "entrance", name: slice.entranceName, center: slice.start, radius: 22 },
{ id: "boss-room", name: `${boss.name}'s Room`, center: finale.position, radius: 32 },
{ id: "boss-room", name: `${boss.name}'s Room`, center: finalePosition, radius: 32 },
];
return {
@@ -252,11 +291,13 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string
presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
unchartedAreaName: slice.entranceName,
ambientLight: { color: "#e2dccc", intensity: slice.lighting?.ambient ?? 0.9 },
hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: slice.lighting?.hemisphere ?? 1.2 },
ambientLight: { color: slice.lighting?.ambientColor ?? "#e2dccc", intensity: slice.lighting?.ambient ?? 0.9 },
hemisphereLight: { skyColor: slice.lighting?.skyColor ?? "#ded9d0",
groundColor: slice.lighting?.groundColor ?? "#5a5350", intensity: slice.lighting?.hemisphere ?? 1.2 },
directionalLight: { ...source.presentation.directionalLight,
color: slice.lighting ? source.presentation.directionalLight.color : "#fff0d9",
intensity: slice.lighting?.directional ?? 1.35 } },
color: slice.lighting?.directionalColor ?? (slice.lighting ? source.presentation.directionalLight.color : "#fff0d9"),
intensity: slice.lighting?.directional ?? 1.35 },
pointLights: slice.pointLights ?? source.presentation.pointLights },
areas,
entities,
staticSpawns: mobSpawns,
+13
View File
@@ -379,6 +379,12 @@ describe("party combat runtime", () => {
useCombatStore.getState().synchronizeActors();
registerEngagedMob("penance-boss", 100_000);
const visuals: CombatPresentationEvent[] = [];
const stopVisuals = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === healer.id
&& message.event.abilityId === penance.id) visuals.push(message.event);
});
advanceDungeonPartyCombat(1_000);
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast)
.toMatchObject({ mode: "channel", startedAt: 1_000, completesAt: 3_000 });
@@ -394,6 +400,13 @@ describe("party combat runtime", () => {
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast).toBeNull();
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.nextActionAt).toBe(3_250);
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === penance.id)).toBe(false);
stopVisuals();
expect(visuals[0]?.durationMs).toBe(2_000);
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
["cast-start", 47540],
...Array.from({ length: 3 }, () => [["tick", 47750], ["impact", 47750]]).flat(),
["release", 47540],
]);
});
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
+32 -15
View File
@@ -19,6 +19,8 @@ import {
} from "./abilityRules";
import type { MobCombatState } from "./combatStore";
import { useCombatStore } from "./combatStore";
import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs";
import { activeRaceOrbId } from "./dungeonRaceAffixRuntime";
import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
import type { MobVector3 } from "./mobPopulation";
import { requireDungeonDefinition } from "./dungeonRegistry";
@@ -48,6 +50,7 @@ import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import {
emitCombatPresentation,
penancePulseVisualSpellId,
presentationDeliveryForAbility,
presentationSchoolForAbility,
type CombatPresentationDelivery,
@@ -664,7 +667,7 @@ function memberSwingMs(member: PartyMember): number {
|| member.specialization === "Feral (Cat)"
? 1_550
: 1_900;
return hastedDuration(
return Math.round(hastedDuration(
base,
ratingPercent(
member.gearStats.hasteRating,
@@ -675,7 +678,7 @@ function memberSwingMs(member: PartyMember): number {
member.classId,
),
500,
);
) / raceAffixHasteMultiplier());
}
function emitPartyAbility(
@@ -686,25 +689,33 @@ function emitPartyAbility(
now: number,
instantCast = false,
): void {
const penancePulse = ability.id === "wow335-priest-penance"
&& ability.effects.some((effect) => effect.kind === "heal" || effect.kind === "damage")
&& (phase === "release" || phase === "impact");
if (ability.id === "wow335-priest-penance" && phase === "impact" && !penancePulse) return;
const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition;
const targetPosition = targetId === PLAYER_AGGRO_ID
? useCombatStore.getState().playerPosition
: getMobPosition(targetId) ?? getPartyRuntimePosition(targetId) ?? origin;
emitCombatPresentation({
occurredAt: now,
phase,
phase: penancePulse && phase === "release" ? "tick" : phase,
actorGroup: "party",
sourceActorId: member.id,
targetActorId: targetId,
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
sourceSpellId: penancePulse
? penancePulseVisualSpellId(Boolean(useCombatStore.getState().mobs[targetId]))
: ability.dbcSpellId,
name: ability.name,
source: ability.source === "runewaker" ? "runewaker" : ability.source?.startsWith("coa") ? "ascension" : "wow335",
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
delivery: penancePulse ? "projectile" : presentationDeliveryForAbility(ability),
origin,
targetPosition,
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(ability.id === "wow335-priest-penance" && phase === "cast-start"
? { durationMs: ability.castTimeMs } : {}),
...(instantCast ? { instantCast: true } : {}),
});
}
@@ -1686,6 +1697,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
party.activeMobId,
partyOrigin,
);
if (!targetId && party.command !== "stop") targetId = activeRaceOrbId();
members = members.map((member) => {
const profile = resourceProfileForClass(member.classId);
const passiveRates: Partial<Record<ResourceType, number>> = {
@@ -1867,7 +1879,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
status: desiredStatus,
nextActionAt: activeCast.mode === "channel"
? Math.max(now + 250, execution.member.globalCooldownEndsAt)
: now + Math.max(250, castingAbility.gcdMs),
: now + Math.max(250, Math.round(castingAbility.gcdMs / raceAffixHasteMultiplier(now))),
};
} else {
nextMembers[index] = {
@@ -1900,6 +1912,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const instantOutOfCombatResurrection = !targetId
&& supportIntent.ability.effects.some((effect) => effect.kind === "resurrection");
if (supportIntent.ability.castTimeMs > 0 && !instantOutOfCombatResurrection) {
const castTimeMs = Math.round(supportIntent.ability.castTimeMs / raceAffixHasteMultiplier(now));
const castOrigin = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition;
nextMembers[index] = {
...committed,
@@ -1909,14 +1922,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
school: presentationSchoolForAbility(supportIntent.ability),
mode: supportIntent.ability.castMode === "channel" ? "channel" : "cast",
startedAt: now,
completesAt: now + supportIntent.ability.castTimeMs,
originalDurationMs: supportIntent.ability.castTimeMs,
completesAt: now + castTimeMs,
originalDurationMs: castTimeMs,
pushbackHits: 0,
origin: castOrigin,
targetId: supportIntent.targetId,
},
activeCastTargetId: supportIntent.targetId,
nextActionAt: now + supportIntent.ability.castTimeMs,
nextActionAt: now + castTimeMs,
};
emitPartyAbility(member, supportIntent.ability, supportIntent.targetId, "cast-start", now);
} else {
@@ -1930,7 +1943,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = {
...execution.member,
status: "healing",
nextActionAt: now + Math.max(250, supportIntent.ability.gcdMs),
nextActionAt: now + Math.max(250, Math.round(supportIntent.ability.gcdMs / raceAffixHasteMultiplier(now))),
};
}
continue;
@@ -1942,6 +1955,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
targetId,
partyOrigin,
);
if (!targetId && party.command !== "stop") targetId = activeRaceOrbId();
if (!targetId) {
nextMembers[index] = member.status === "ready" && member.nextActionAt === 0
? member
@@ -1950,7 +1964,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
}
const desiredStatus = member.role === "tank" ? "guarding" : "attacking";
const memberPosition = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition;
const memberTargetId = member.role === "tank"
const memberTargetId = member.role === "damage" && party.command !== "stop" && activeRaceOrbId()
? activeRaceOrbId()
: member.role === "tank"
? tankPriorityTargetId(
currentCombat.mobs,
nextMembers,
@@ -2115,6 +2131,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
});
}
if (selectedAbility.castTimeMs > 0) {
const castTimeMs = Math.round(selectedAbility.castTimeMs / raceAffixHasteMultiplier(now));
nextMembers[index] = {
...committed,
status: desiredStatus,
@@ -2123,14 +2140,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
school: presentationSchoolForAbility(selectedAbility),
mode: selectedAbility.castMode === "channel" ? "channel" : "cast",
startedAt: now,
completesAt: now + selectedAbility.castTimeMs,
originalDurationMs: selectedAbility.castTimeMs,
completesAt: now + castTimeMs,
originalDurationMs: castTimeMs,
pushbackHits: 0,
origin: memberPosition,
targetId: abilityTargetId,
},
activeCastTargetId: abilityTargetId,
nextActionAt: now + selectedAbility.castTimeMs,
nextActionAt: now + castTimeMs,
};
emitPartyAbility(member, selectedAbility, abilityTargetId, "cast-start", now);
} else {
@@ -2138,7 +2155,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = {
...execution.member,
status: desiredStatus,
nextActionAt: now + Math.max(250, selectedAbility.gcdMs),
nextActionAt: now + Math.max(250, Math.round(selectedAbility.gcdMs / raceAffixHasteMultiplier(now))),
};
}
}
+2 -2
View File
@@ -38,7 +38,7 @@ import {
updateCombatEffectQueue,
originalInstantCastEvent,
originalInstantTerminalEvent,
risingTideArrivalDelayMs,
originalProjectileArrivalDelayMs,
type ActiveCombatEffect,
} from "./combatEffectRuntime";
import { OriginalCombatEffect } from "./OriginalCombatEffect";
@@ -391,7 +391,7 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
return;
}
const event = message.event;
const arrivalDelay = risingTideArrivalDelayMs(event);
const arrivalDelay = originalProjectileArrivalDelayMs(event);
if (arrivalDelay > 0) {
const timer = setTimeout(() => {
pendingArrivals.delete(timer);
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useFrame } from "@react-three/fiber";
import { DoubleSide, Group, SRGBColorSpace, Texture, TextureLoader } from "three";
import { subscribeContentManager } from "../content/contentManager";
import { fetchCombatMediaAsset, combatMediaPackReady } from "../game/combatMediaPack";
import { preloadCombatEffectManifest, shadowfiendOriginalModelAsset } from "../game/combatPresentation";
import { type CombatSummon, useCombatStore } from "../game/combatStore";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import { OriginalWowModel } from "./OriginalWowModel";
import { parseOriginalWowModelGraph, type OriginalWowModelGraph } from "./originalWowModelRuntime";
const SHADOWFIEND_CREATURE_ID = 19668;
interface ShadowfiendMedia {
readonly graph: OriginalWowModelGraph;
readonly textures: ReadonlyMap<number, Texture>;
}
function ShadowfiendFallback() {
return <group name="shadowfiend-fallback">
<mesh position={[0, 0.66, 0]} scale={[0.56, 0.77, 0.46]}>
<icosahedronGeometry args={[1, 1]} />
<meshStandardMaterial color="#241335" emissive="#492269" emissiveIntensity={0.7} roughness={0.85} />
</mesh>
{[-1, 1].map((side) => <group key={side} position={[side * 0.55, 0.9, 0]} rotation={[0, 0, side * -0.42]}>
<mesh position={[0, -0.37, 0]}><coneGeometry args={[0.18, 0.88, 5]} />
<meshStandardMaterial color="#2c1741" emissive="#371456" emissiveIntensity={0.5} />
</mesh>
<mesh position={[0, -0.83, 0.1]}><coneGeometry args={[0.12, 0.4, 5]} />
<meshStandardMaterial color="#a352dd" emissive="#8d35cc" emissiveIntensity={1.4} />
</mesh>
</group>)}
{[-1, 1].map((side) => <mesh key={side} position={[0.49, 0.87, side * 0.18]}>
<sphereGeometry args={[0.075, 8, 8]} />
<meshBasicMaterial color="#d68cff" />
</mesh>)}
<pointLight color="#7739b8" intensity={0.7} distance={2.5} />
</group>;
}
function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon; readonly active: boolean }) {
const [media, setMedia] = useState<ShadowfiendMedia | null>(null);
const mediaReady = useSyncExternalStore(subscribeContentManager, combatMediaPackReady, () => false);
const group = useRef<Group>(null);
const playback = useRef({ elapsedMs: 0, decaying: false });
useEffect(() => {
if (!mediaReady) {
setMedia(null);
return;
}
let cancelled = false;
const ownedTextures: Texture[] = [];
void (async () => {
await preloadCombatEffectManifest();
const url = shadowfiendOriginalModelAsset()?.effectGraphUrl;
if (!url) return;
const response = await fetchCombatMediaAsset(url);
if (!response.ok) throw new Error(`Shadowfiend model request failed: ${response.status}`);
const graph = parseOriginalWowModelGraph(await response.json());
const textures = new Map<number, Texture>();
const loader = new TextureLoader();
for (const [index, source] of graph.textures.entries()) {
if (!source.url) continue;
const image = await fetchCombatMediaAsset(source.url);
if (!image.ok) throw new Error(`Shadowfiend texture request failed: ${image.status}`);
const objectUrl = URL.createObjectURL(await image.blob());
try {
const texture = await loader.loadAsync(objectUrl);
texture.colorSpace = SRGBColorSpace;
ownedTextures.push(texture);
textures.set(index, texture);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
if (cancelled) ownedTextures.forEach((texture) => texture.dispose());
else setMedia({ graph, textures });
})().catch(() => { if (cancelled) ownedTextures.forEach((texture) => texture.dispose()); });
return () => {
cancelled = true;
ownedTextures.forEach((texture) => texture.dispose());
};
}, [mediaReady]);
useFrame((_, delta) => {
if (!group.current) return;
const live = useCombatStore.getState().summons.find((candidate) => candidate.id === summon.id);
if (!live) return;
const goal = live.position;
const blend = Math.min(1, delta * 9);
group.current.position.x += (goal[0] - group.current.position.x) * blend;
group.current.position.y += (goal[1] + 0.13 - group.current.position.y) * blend;
group.current.position.z += (goal[2] - group.current.position.z) * blend;
const target = live.targetId ? getMobPosition(live.targetId) : null;
if (target) group.current.rotation.y = Math.atan2(goal[2] - target[2], target[0] - goal[0]);
if (active) playback.current.elapsedMs += Math.min(delta, 0.1) * 1_000;
});
return <group ref={group} name={`combat-summon:${summon.id}`}
position={[summon.position[0], summon.position[1] + 0.13, summon.position[2]]}>
<mesh name="shadowfiend-ground-aura" rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.1, 0]}>
<ringGeometry args={[0.38, 0.76, 28]} />
<meshBasicMaterial color="#8a46be" transparent opacity={0.38} depthWrite={false} side={DoubleSide} />
</mesh>
{media
? <group position={[0, 1.3, 0]} scale={0.65}><OriginalWowModel graph={media.graph} textures={media.textures}
active={active} playbackRef={playback} loop /></group>
: <ShadowfiendFallback />}
</group>;
}
/** Player and companion combat summons live outside the static dungeon population. */
export function CombatSummonPopulation({ active }: { readonly active: boolean }) {
const summons = useCombatStore((state) => state.summons);
return <group name="combat-summon-population">
{summons.filter((summon) => summon.creatureId === SHADOWFIEND_CREATURE_ID && summon.health > 0)
.map((summon) => <ShadowfiendMember key={summon.id} summon={summon} active={active} />)}
</group>;
}
+82
View File
@@ -0,0 +1,82 @@
import { Html } from "@react-three/drei";
import { DoubleSide } from "three";
import { useCombatStore } from "../game/combatStore";
import { activeLastStand } from "../game/dungeonRaceAffixBuffs";
import { useDungeonRaceAffixStore, type RaceAffixEffect } from "../game/dungeonRaceAffixRuntime";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
const COLORS: Record<RaceAffixEffect["kind"], string> = {
volatile: "#ff7b36", ash: "#e65327", orb: "#d073ff", boon: "#f5cc56",
star: "#ffb43e", storm: "#69c8ff", ring: "#ae7dff", predator: "#d34781",
};
function visualPosition(effect: RaceAffixEffect, now: number): readonly [number, number, number] {
if ((effect.kind === "storm" || (effect.kind === "predator" && now < effect.resolvesAt)) && effect.targetId) {
const position = effect.targetId === "__player__"
? useCombatStore.getState().playerPosition : getPartyRuntimePosition(effect.targetId);
if (position) return position;
}
return effect.position;
}
function GroundMarker({ effect, now }: { readonly effect: RaceAffixEffect; readonly now: number }) {
const position = visualPosition(effect, now);
const warning = now < effect.resolvesAt;
const isRing = effect.kind === "ring";
const color = COLORS[effect.kind];
return <group position={[position[0], position[1] + 0.09, position[2]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
{isRing ? <ringGeometry args={[4, 7, 64]} /> : <circleGeometry args={[effect.radius, 48]} />}
<meshBasicMaterial color={color} transparent opacity={warning ? 0.3 : 0.48}
side={DoubleSide} depthWrite={false} />
</mesh>
{effect.kind === "star" && <mesh position={[0, warning
? 2.4 - Math.min(1, (now - effect.createdAt) / 2_000) * 1.9 : 0.45, 0]}>
<octahedronGeometry args={[warning ? 0.32 : 0.55, 0]} />
<meshBasicMaterial color="#fff2b0" transparent opacity={warning ? 0.95 : 0.65}
depthTest={false} />
</mesh>}
{warning && <Html center position={[0, 0.7, 0]} style={{ pointerEvents: "none" }}>
<span style={{ color: "white", background: "#101015dc", border: `1px solid ${color}`,
borderRadius: 6, padding: "2px 6px", fontWeight: 700, whiteSpace: "nowrap" }}>
{Math.max(1, Math.ceil((effect.resolvesAt - now) / 1_000))}
</span>
</Html>}
</group>;
}
export function DungeonRaceAffixEffects() {
const effects = useDungeonRaceAffixStore((state) => state.effects);
const now = Date.now();
const buffs = activeLastStand(now);
return <>
{effects.map((effect) => effect.kind === "orb" ? (
<group key={effect.id} position={[effect.position[0], effect.position[1] + 1.2, effect.position[2]]}>
<mesh onClick={(event) => { event.stopPropagation(); useCombatStore.getState().selectMob(effect.id); }}>
<icosahedronGeometry args={[0.65, 1]} />
<meshStandardMaterial color={COLORS.orb} emissive={COLORS.orb} emissiveIntensity={0.75} />
</mesh>
<Html center position={[0, 1, 0]} style={{ pointerEvents: "none" }}>
<span className="race-affix-world-countdown">Orb · {Math.max(1, Math.ceil((effect.expiresAt - now) / 1_000))}s</span>
</Html>
</group>
) : effect.kind === "boon" ? (
<group key={effect.id} position={[effect.position[0], effect.position[1] + 0.75, effect.position[2]]}>
<mesh><octahedronGeometry args={[0.5, 0]} />
<meshStandardMaterial color={COLORS.boon} emissive={COLORS.boon} emissiveIntensity={0.65} />
</mesh>
<Html center position={[0, 0.9, 0]} style={{ pointerEvents: "none" }}>
<span className="race-affix-world-countdown">Boon · {Math.max(1, Math.ceil((effect.expiresAt - now) / 1_000))}s</span>
</Html>
</group>
) : <GroundMarker key={effect.id} effect={effect} now={now} />)}
{buffs.map(({ id, stacks }) => {
const position = getMobPosition(id);
return position ? <mesh key={id} position={[position[0], position[1] + 1.8, position[2]]}>
<sphereGeometry args={[0.18 + stacks * 0.06, 10, 10]} />
<meshBasicMaterial color="#ff4b37" />
</mesh> : null;
})}
</>;
}
+8
View File
@@ -22,6 +22,7 @@ import { useGameStore } from "../game/store";
import { DungeonEnvironment } from "./DungeonEnvironment";
import { dungeonRenderDistance } from "./dungeonRenderDistance";
import { MobPopulation } from "./MobPopulation";
import { CombatSummonPopulation } from "./CombatSummonPopulation";
import { PartyPopulation } from "./PartyPopulation";
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
import { PlayerRig } from "./PlayerRig";
@@ -42,6 +43,7 @@ import {
import { WailingNaralexEvent } from "./WailingNaralexEvent";
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
import { CombatEffects } from "./CombatEffects";
import { DungeonRaceAffixEffects } from "./DungeonRaceAffixEffects";
import { PLAYER_GRAVITY } from "../game/playerMovement";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
@@ -389,6 +391,10 @@ export function GameScene() {
shadow-mapSize={[1024, 1024]}
shadow-bias={-0.00025}
/>
{dungeon.presentation.pointLights?.map((light, index) => (
<pointLight key={index} position={light.position} color={light.color}
intensity={light.intensity} distance={light.distance} decay={2} />
))}
<Physics
key={encounterWorldKey(activeDungeonId, sessionRevision)}
gravity={[0, PLAYER_GRAVITY, 0]}
@@ -406,6 +412,7 @@ export function GameScene() {
/>
<DungeonSimulationSceneBridge />
<GroundEffectTelegraphs />
<DungeonRaceAffixEffects />
{gameMode === "dungeon" && (
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
)}
@@ -416,6 +423,7 @@ export function GameScene() {
staticSpawns={staticSpawns}
allowedRuntimeIds={allowedRuntimeIds}
/>
<CombatSummonPopulation active={!simulationBlocked} />
<WailingNaralexEvent />
<ShelvedGameSceneFeatures />
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
+2 -1
View File
@@ -20,6 +20,7 @@ import {
} from "three";
import {
combatOriginalPhaseAssets,
isPenancePulseVisual,
preloadCombatEffectManifest,
type CombatEffectPhaseAsset,
type CombatPresentationEvent,
@@ -723,7 +724,7 @@ function LoadedOriginalCombatEffect({
)
: maximum
), 0), [loaded]);
const projectile = event.phase === "release"
const projectile = (event.phase === "release" || (event.phase === "tick" && isPenancePulseVisual(event)))
&& event.delivery === "projectile"
&& event.targetPosition !== undefined;
const projectileDurationMs = projectile ? projectileTravelDurationMs(event) : 0;
+28
View File
@@ -33,6 +33,7 @@ import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { isBiteSizedDungeonId } from "../game/dungeonContentSource";
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import { raceAffixAvoidanceDestination } from "../game/dungeonRaceAffixAvoidance";
import {
enemyControlMovementMultiplier,
enemyControlPreventsMovement,
@@ -1113,10 +1114,37 @@ function PartyActors({
const activeControlMechanic = member.controlledUntil > combatNow
? member.controlMechanic
: null;
const avoidanceGoal = member.health > 0 && !enemyControlPreventsMovement(activeControlMechanic)
? raceAffixAvoidanceDestination(member.id, current, combatNow) : null;
if (member.health <= 0) {
const signal = movingSignalsRef.current.get(member.id);
if (signal) signal.current = false;
} else if (avoidanceGoal) {
const avoidance = avoidanceGoal;
const safe = navigationGraph
? findNearestPartyNavigationSurface(navigationGraph, avoidance, 5)?.position ?? avoidance
: avoidance;
const goalKey = `member:${member.id}|race-affix-avoid`;
let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState();
if (partyRouteNeedsPlanning(route, goalKey, current, safe, routeNow, {
replanDistance: 0.6, replanIntervalMs: 200, blockedRetryMs: 300,
})) {
route = planPartyRoute(navigationGraph, goalKey, current, safe, routeNow, {
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5, localFinishDistance: 0.4,
replanDistance: 0.6, replanIntervalMs: 200, blockedRetryMs: 300,
directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed,
});
combatRoutesRef.current.set(member.id, route);
}
hasDesired = nextPartyRouteWaypoint(route, current, desired);
if (!hasDesired && directSegmentAllowed(current, safe)) {
desired[0] = safe[0]; desired[1] = safe[1]; desired[2] = safe[2];
hasDesired = true;
}
movementExpected = hasDesired;
faceX = desired[0] - current[0]; faceZ = desired[2] - current[2];
} else if (member.activeCast || enemyControlPreventsMovement(activeControlMechanic)) {
// Casting and crowd control are intentional immobility, not stuck navigation.
} else if (rejoining) {
+22 -6
View File
@@ -9,7 +9,7 @@ import {
originalInstantCastEvent,
originalInstantTerminalEvent,
projectileTravelDurationMs,
risingTideArrivalDelayMs,
originalProjectileArrivalDelayMs,
risingTideImpactPlacement,
shouldUseGeneratedOriginalFallback,
updateCombatEffectQueue,
@@ -59,19 +59,35 @@ describe("combat-effect runtime budget", () => {
.toEqual({ scale: 1, opacity: 1 });
});
it("delays only the Rising Tide target arrival by the projectile's travel time", () => {
it("delays source-backed target impacts until their projectiles arrive", () => {
const release = event(1, { source: "runewaker", sourceSpellId: 490256,
phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] });
const impact = { ...release, id: 2, phase: "impact" as const };
expect(projectileTravelDurationMs(release)).toBe(500);
expect(risingTideArrivalDelayMs(release)).toBe(0);
expect(risingTideArrivalDelayMs(impact)).toBe(500);
expect(risingTideArrivalDelayMs({ ...impact, sourceSpellId: 491147 })).toBe(0);
expect(risingTideArrivalDelayMs({ ...impact, delivery: "area" })).toBe(0);
expect(originalProjectileArrivalDelayMs(release)).toBe(0);
expect(originalProjectileArrivalDelayMs(impact)).toBe(500);
expect(originalProjectileArrivalDelayMs({ ...impact, sourceSpellId: 491147 })).toBe(0);
expect(originalProjectileArrivalDelayMs({ ...impact, delivery: "area" })).toBe(0);
expect(originalProjectileArrivalDelayMs({ ...impact, source: "wow335",
abilityId: "wow335-priest-penance", sourceSpellId: 47666 })).toBe(500);
expect(originalProjectileArrivalDelayMs({ ...release, source: "wow335", phase: "tick",
abilityId: "wow335-priest-penance", sourceSpellId: 47750 })).toBe(0);
expect(projectileTravelDurationMs({ ...release, targetPosition: [100, 0, 0] })).toBe(550);
expect(projectileTravelDurationMs({ ...release, targetPosition: [1, 0, 0] })).toBe(180);
});
it("keeps Penance's channel hand during pulses and drops its empty terminal flash", () => {
const cast = event(1, { phase: "cast-start", actorGroup: "player", sourceActorId: "player",
abilityId: "wow335-priest-penance", sourceSpellId: 47540 });
const pulse = event(2, { ...cast, id: 2, phase: "tick", sourceSpellId: 47666 });
const terminal = event(3, { ...cast, id: 3, phase: "release" });
const during = enqueue([cast, pulse], "high");
expect(during.map((effect) => effect.event.phase)).toEqual(["cast-start", "tick"]);
expect(updateCombatEffectQueue(during, terminal, "high")).toEqual([
{ key: during[1].key, event: pulse },
]);
});
it("places Rising Tide impact on a giant target's caster-facing body surface", () => {
const cast = event(1, { origin: [0, 0, 0], targetPosition: [10, 0, 0] });
const verdan = risingTideImpactPlacement(cast, { height: 15.85, radius: 5.3 });
+8 -4
View File
@@ -2,6 +2,7 @@ import type {
CombatPresentationEvent,
CombatVfxQuality,
} from "../game/combatPresentation";
import { isPenancePulseVisual } from "../game/combatPresentation";
import type { CombatEffectTargetVisualSize } from "./combatEffectAnchorRegistry";
export interface ActiveCombatEffect {
@@ -38,12 +39,13 @@ export function projectileTravelDurationMs(
return Number.isFinite(distance) ? Math.max(180, Math.min(550, distance / 28 * 1_000)) : 0;
}
/** RuneWaker's Rising Tide impact model and sound are target arrival events. */
export function risingTideArrivalDelayMs(
/** Keep source impact models and sounds aligned with travelling projectiles. */
export function originalProjectileArrivalDelayMs(
event: Pick<CombatPresentationEvent,
"phase" | "source" | "sourceSpellId" | "origin" | "targetPosition" | "delivery">,
"phase" | "source" | "sourceSpellId" | "abilityId" | "origin" | "targetPosition" | "delivery">,
): number {
return event.phase === "impact" && event.source === "runewaker" && event.sourceSpellId === 490256
return event.phase === "impact" && ((event.source === "runewaker" && event.sourceSpellId === 490256)
|| isPenancePulseVisual(event))
? projectileTravelDurationMs(event) : 0;
}
@@ -169,6 +171,8 @@ export function updateCombatEffectQueue(
&& (event.phase === "cast-cancel" || !effect.event.instantCast)
))
: current;
if (event.abilityId === "wow335-priest-penance" && event.sourceSpellId === 47540
&& (event.phase === "release" || event.phase === "impact")) return withoutCast;
if (quality === "off") return current.length === 0 ? current : [];
if (event.phase === "cast-cancel") {
return withoutCast.length === current.length ? current : withoutCast;
+16 -2
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
import { raceAffixBoonUntil } from "../game/dungeonRaceAffixBuffs";
import { useGameStore } from "../game/store";
import "./dungeonRace.css";
@@ -18,18 +20,30 @@ export function DungeonRaceHud({ region }: { readonly region: "status" | "overla
const elapsed = race.startAt ? Math.max(0, serverNow - race.startAt) : 0;
const shownTime = race.clearDurationMs ?? elapsed;
const toast = race.result && race.toastAt && now - race.toastAt < 4_000;
const affixes = race.affixIds?.map((id) => dungeonRaceAffix(id)).filter((entry) => entry !== undefined) ?? [];
if (region === "status") return (
<section className="dungeon-race-hud" aria-label="Dungeon race status">
<span>Dungeon Race · {race.mode === "practice" ? "Practice" : "Ranked"}</span>
<strong>{formatDungeonDuration(shownTime)}</strong>
<small>vs {race.opponent.name}{race.opponent.type === "bot" ? ` · ${race.opponent.tier}` : ""}
{race.opponentProgress !== null ? ` · ${race.opponentProgress}%` : ""}</small>
<small className="dungeon-race-hud__affixes">Mechanics: {affixes.map((affix) => affix.name).join(" + ")}</small>
{raceAffixBoonUntil() > now && <small className="dungeon-race-hud__boon">
Party haste · {Math.ceil((raceAffixBoonUntil() - now) / 1_000)}s</small>}
</section>
);
return <>
{race.status === "preparing" && <div className="dungeon-race-wait" role="status">Waiting for the rival party to be ready…</div>}
{race.status === "preparing" && <div className="dungeon-race-wait" role="status">
<strong>Race mechanics</strong>
<div className="dungeon-race-countdown__affixes">{affixes.map((affix) => <p key={affix.id}>
<b>{affix.name}</b> — {affix.description}</p>)}</div>
<small>Waiting for the rival party to be ready…</small>
</div>}
{remaining !== null && remaining > 0 && race.status === "countdown" &&
<div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong></div>}
<div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong>
<div className="dungeon-race-countdown__affixes">{affixes.map((affix) => <p key={affix.id}>
<b>{affix.name}</b> — {affix.description}</p>)}</div>
</div>}
{toast && <div className={`dungeon-race-toast dungeon-race-toast--${race.result}`} role="status" aria-live="polite">
{race.result === "win" ? "You win" : "You lose"}
{race.result === "loss" ? <small>Keep going to claim the boss card.</small> : null}
+7 -2
View File
@@ -7,6 +7,7 @@ import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot";
import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById } from "../game/specializationCatalog";
@@ -14,7 +15,8 @@ import { BrandMark, FrontSurface } from "./FrontSurface";
import "./dungeonRace.css";
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
readonly wins?: number; readonly losses?: number; readonly durationMs?: number }
readonly wins?: number; readonly losses?: number; readonly durationMs?: number;
readonly affixIds?: readonly string[] }
interface BoardResponse { readonly entries: readonly BoardEntry[]; readonly total: number }
export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void }) {
@@ -76,7 +78,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
<h1>Bite-Sized Dungeon Race</h1></div><button className="front-button" type="button" onClick={() => { void race.leave(); onClose(); }}>Main menu</button></header>
<main className="dungeon-race-screen__layout">
<section className="dungeon-race-screen__setup">
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss.</p>
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss. Each race rolls one kill mechanic and one movement mechanic for both sides.</p>
<div className="dungeon-race-screen__choices" role="group" aria-label="Race type">
<button className="front-button" type="button" aria-pressed={!practice} disabled={!session?.accessToken || busy} onClick={() => setPractice(false)}>Online ranked</button>
<button className="front-button" type="button" aria-pressed={practice} disabled={busy} onClick={() => setPractice(true)}>Offline practice</button>
@@ -133,9 +135,12 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
{!boardError && board?.entries.length === 0 && <p>No results yet.</p>}
{board?.entries.length ? <table><thead><tr><th>Rank</th><th>Player</th>
<th>{boardScope === "dungeon" && boardView === "times" ? "Best time" : "Wins"}</th>
{boardScope === "dungeon" && boardView === "times" && <th>Mechanics</th>}
{!(boardScope === "dungeon" && boardView === "times") && <th>Losses</th>}</tr></thead><tbody>
{board.entries.map((entry) => <tr key={entry.accountId}><td>#{entry.rank}</td><td>{entry.username}</td>
<td>{entry.durationMs !== undefined ? formatDungeonDuration(entry.durationMs) : entry.wins}</td>
{boardScope === "dungeon" && boardView === "times" && <td>{entry.affixIds?.length
? entry.affixIds.map((id) => dungeonRaceAffix(id)?.name ?? id).join(" + ") : "Legacy"}</td>}
{entry.durationMs === undefined && <td>{entry.losses}</td>}</tr>)}</tbody></table> : null}
</section>
</main>
+23 -1
View File
@@ -16,6 +16,8 @@ import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
import { isLeaderboardFullClear } from "../game/dungeonLeaderboardRules";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { recentPracticeRaceCombatLog } from "../game/dungeonRaceCombatLog";
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
export interface RunPlacementState {
readonly scope: LeaderboardScope | "race";
@@ -26,13 +28,14 @@ export interface RunPlacementState {
}
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry, onAnother,
startingAnother = false, anotherMessage }: {
onExportCombatLog, startingAnother = false, anotherMessage }: {
readonly progress: DungeonRunProgress;
readonly ranking: RunPlacementState;
readonly onFinish: () => void;
readonly onLoot: () => void;
readonly onRetry: () => void;
readonly onAnother?: () => void;
readonly onExportCombatLog?: () => void;
readonly startingAnother?: boolean;
readonly anotherMessage?: string;
}) {
@@ -80,10 +83,18 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
{!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null}
{ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null}
{ranking.scope === "race" && ranking.raceResult === "loss" ? <p className="dungeon-results__note">Your boss card and loot are still yours.</p> : null}
{ranking.scope === "race" && <div className="dungeon-results__note">Mechanics:
{useDungeonRaceStore.getState().affixIds?.map((id) => {
const affix = dungeonRaceAffix(id);
return <p key={id}><b>{affix?.name ?? id}</b>{affix ? ` — ${affix.description}` : ""}</p>;
}) ?? " None"}
</div>}
{anotherMessage ? <p className="dungeon-results__note" role="status">{anotherMessage}</p> : null}
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
<footer>
{onExportCombatLog ? <ControllerButton {...controls} controlId="run-combat-log" className="button"
onClick={onExportCombatLog}>Download combat log</ControllerButton> : null}
{onAnother ? <ControllerButton {...controls} controlId="run-another" className="button button--primary"
disabled={startingAnother} onClick={onAnother}>
{startingAnother ? "Starting next dungeon…" : randomizedBiteSized
@@ -139,6 +150,17 @@ export function DungeonRunResults() {
? "The party leader can start the next random Bite-Sized dungeon."
: "";
return <DungeonRunResultsDialog progress={progress} ranking={ranking} onRetry={() => setRetry((value) => value + 1)}
onExportCombatLog={raceRun && race.mode === "practice" && recentPracticeRaceCombatLog()
? () => {
const log = recentPracticeRaceCombatLog();
if (!log) return;
const href = URL.createObjectURL(new Blob([JSON.stringify(log, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = `${log.dungeonId}-${log.runId}-combat.json`;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(href), 0);
} : undefined}
onAnother={canStartAnother ? () => {
setStartingAnother(true);
setAnotherError("");
+5
View File
@@ -2,8 +2,13 @@
.dungeon-race-hud span{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#c2ac78}
.dungeon-race-hud strong{font-variant-numeric:tabular-nums;font-size:21px;line-height:1.1}
.dungeon-race-hud small{font-size:10px;white-space:nowrap}
.dungeon-race-hud .dungeon-race-hud__affixes{max-width:220px;white-space:normal;color:#ffdf9b;letter-spacing:.03em;line-height:1.2}
.dungeon-race-hud__boon{color:#f5cc56}
.race-affix-world-countdown{color:white;background:#101015e8;border:1px solid #c3a462;border-radius:6px;padding:2px 6px;font-size:11px;font-weight:700;white-space:nowrap}
.dungeon-race-wait,.dungeon-race-countdown{position:absolute;z-index:55;top:38%;left:50%;transform:translateX(-50%);padding:14px 22px;border:1px solid #ab945f;border-radius:12px;background:#091217ef;color:#f5e8ca;text-align:center;pointer-events:none}
.dungeon-race-countdown strong{display:block;font-size:42px}
.dungeon-race-countdown__affixes{max-width:440px;text-align:left;font-size:12px}
.dungeon-race-countdown__affixes p{margin:5px 0}
.dungeon-race-toast{position:absolute;z-index:65;top:74px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;padding:9px 18px;border:1px solid #a79261;border-radius:9px;background:#101516f2;color:#ffe4a3;font-size:22px;font-weight:700;pointer-events:none}
.dungeon-race-toast--loss{border-color:#bd6b63;color:#ffb2a8}
.dungeon-race-toast small{font-size:11px;color:#f5e8ca}